فهرست مقاله
در نگهداری آزمونهای پردازش تصویر، تغییر مبدأ مختصات یا جهت ماتریس میتواند نتیجهای ظاهراً قابل قبول بسازد که یک پیکسل جابهجا است. آزمون رفتوبرگشت بهتنهایی هم کافی نیست؛ دو تابع با اشتباه مشترک ممکن است یکدیگر را خنثی کنند. در این درس، تبدیل آفینِ نقطههای ساختگی را با پاسخ دستی، مرجع کسری و قرارداد مرکز پیکسل بررسی میکنیم. فایل تصویر، شدت روشنایی، دوربین یا الگوریتم ثبت تصویر اجرا نمیشود.
۱. سطر و ستون با زوج مختصات یکسان نیستند

قرارداد ما یکمبناست: x شمارهٔ ستون و y شمارهٔ سطر است؛ x به راست و y به پایین زیاد میشود. بنابراین مرکز پیکسلِ سطر دو و ستون سه، زوج مختصات سه و دو دارد. این انتخاب با توضیح مختصات درونی تصویر در MathWorks سازگار است. صرف اینکه کد با پایتون نوشته شده، مبدأ یا ترتیب زوج را خودکار تعیین نمیکند.
مختصات نقطه پیوستهاند؛ یک و نیم نیز معنا دارد، حتی اگر برای دسترسی مستقیم به آرایه باید اندیس گسسته داشته باشیم. مختصات را به int گرد نمیکنیم و از آنها برای خواندن آرایه استفاده نمیکنیم. واحد ورودی و خروجی pixel است؛ میلیمتر یا فاصلهٔ واقعی صحنه نداریم. نام واحد، کالیبراسیون دوربین یا اندازهٔ فیزیکی پیکسل را ایجاد نمیکند.
مرکز گوشهای را با مرز بیرونی تصویر جدا نگه میداریم. در قابِ چهار ستون و سه سطر، مراکز گوشهای از یک تا چهار در x و از یک تا سه در y هستند. مرز پشتیبان از نیم تا چهارونیم و از نیم تا سهونیم امتداد دارد. حدود درونی imref2d نیز همین تفاوت نیمپیکسلی را بیان میکند. قاب این درس فقط توصیف هندسی است؛ آرایهای تخصیص نمیدهد.
۲. بردار ستونی و جهت نگاشت را صریح بنویسیم

نقطهٔ ورودی p و خروجی q را بردار ستونی میگیریم و مینویسیم q برابر A ضربدر p بهعلاوهٔ t است. ماتریس ثابتِ نمونه، سطر نخست دو و یک و سطر دوم منفی یک و یک دارد. جابهجایی t برابر ده و منفی چهار پیکسل است. پس u برابر دو x بهعلاوهٔ y بهعلاوهٔ ده و v برابر منفی x بهعلاوهٔ y منهای چهار خواهد بود.
برای p برابر سه و دو، جواب دستی u برابر هجده و v برابر منفی پنج است. منفی شدن مؤلفهٔ خروجی در این قرارداد خطا نیست؛ هنوز قاب خروجی، برش یا محدودیت نمایش تعریف نکردهایم. مؤلفههای A نسبت مختصات همواحد و بیبُعدند و t واحد پیکسل دارد. این نگاشت، شدت پیکسل یا تعداد خانههای یک تصویر خروجی را مشخص نمیکند.
معکوس، ابتدا t را از q کم میکند و سپس A معکوس را اعمال میکند. دترمینان نمونه سه است و A معکوس برابر یکسومِ ماتریس با سطرهای یک، منفی یک و یک، دو است. برای q برابر هجده و منفی پنج، اختلاف با t برابر هشت و منفی یک میشود؛ خروجی معکوس سه و دو است. جابهجایی را پیش از معکوس کم میکنیم، نه پس از آن.
در شرح رسمی warpAffine در OpenCV، جهت ماتریس برای نمونهگیری تصویر به پرچم WARP_INVERSE_MAP وابسته است؛ بدون آن، ماتریس برای نمونهگیری معکوس میشود. اینجا فقط نقطه نگاشت میکنیم و آن API را فراخوانی نمیکنیم. شباهت نام «آفین» مجوز کپی مستقیم تنظیمات مبدأ، بردار سطری یا جهت یک کتابخانه به کتابخانهٔ دیگر نیست.
۳. کد کامل و مرجع مستقل برای آزمون نگاشت

فایل affine_pixel_coordinates.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. ماتریس دو در دو و نقطه و جابهجایی، زوج list یا tuple هستند. مقدارها باید int یا float دقیق و پس از تبدیل متناهی باشند؛ bool، مختلط، رشته، NaN و بینهایت رد میشوند. عدد صحیح بسیار بزرگ ممکن است هنگام تبدیل گرد شود یا از دامنه بیرون برود؛ ورودی را اندازهگیری دقیق تلقی نمیکنیم.
"""Offline affine POINT coordinates; no image intensities, interpolation or warp."""
from fractions import Fraction
import json
import math
import platform
import sys
def finite_number(value):
if type(value) not in (int, float):
raise ValueError('coordinate_or_coefficient_type')
try:
converted = float(value)
except OverflowError:
raise ValueError('coordinate_or_coefficient_range') from None
if not math.isfinite(converted):
raise ValueError('coordinate_or_coefficient_finite')
return converted
def point_pair(point):
if type(point) not in (list, tuple) or len(point) != 2:
raise ValueError('point_shape')
return tuple(finite_number(value) for value in point)
def evaluate(matrix, point, shift):
try:
result = tuple(math.fsum((row[0] * point[0], row[1] * point[1], offset))
for row, offset in zip(matrix, shift, strict=True))
except (OverflowError, ValueError):
raise ArithmeticError('nonfinite_intermediate_coordinate') from None
if not all(math.isfinite(value) for value in result):
raise ArithmeticError('nonfinite_output_coordinate')
return result
class AffinePoints:
"""Column-vector q=A*p+t, with coordinates explicitly in pixel units."""
def __init__(self, matrix, shift, unit='pixel'):
if type(unit) is not str or unit != 'pixel':
raise ValueError('unit_must_be_pixel')
if type(matrix) not in (list, tuple) or len(matrix) != 2:
raise ValueError('matrix_shape')
self.matrix = tuple(point_pair(row) for row in matrix)
self.shift = point_pair(shift)
(a, b), (c, d) = self.matrix
try:
determinant = math.fsum((a * d, -b * c))
except (OverflowError, ValueError):
raise ValueError('computed_determinant_not_finite') from None
if not math.isfinite(determinant) or determinant == 0.0:
raise ValueError('computed_determinant_zero_or_nonfinite')
self.determinant = determinant
self.inverse_matrix = ((d / determinant, -b / determinant),
(-c / determinant, a / determinant))
if not all(math.isfinite(value) for row in self.inverse_matrix for value in row):
raise ValueError('inverse_coefficients_not_finite')
def forward(self, point):
return evaluate(self.matrix, point_pair(point), self.shift)
def inverse_point(self, point):
observed = point_pair(point)
difference = tuple(value - offset for value, offset in zip(observed, self.shift, strict=True))
if not all(math.isfinite(value) for value in difference):
raise ArithmeticError('inverse_subtraction_not_finite')
return evaluate(self.inverse_matrix, difference, (0.0, 0.0))
def frame_corners(width, height):
if any(type(value) is not int or not 1 <= value <= 1_000_000 for value in (width, height)):
raise ValueError('image_dimensions_exact_int_1_to_1000000')
return {'center_corners': ((1.0, 1.0), (float(width), 1.0),
(float(width), float(height)), (1.0, float(height))),
'support_corners': ((0.5, 0.5), (width + 0.5, 0.5),
(width + 0.5, height + 0.5), (0.5, height + 0.5))}
def exact_forward(matrix, shift, point):
"""Independent exact arithmetic for the represented small fixture values."""
rows = tuple(tuple(Fraction.from_float(float(v)) for v in row) for row in matrix)
p = tuple(Fraction.from_float(float(v)) for v in point)
t = tuple(Fraction.from_float(float(v)) for v in shift)
return tuple(sum((coefficient * value for coefficient, value in zip(row, p, strict=True)), offset)
for row, offset in zip(rows, t, strict=True))
def exact_inverse(matrix, shift, point):
(a, b), (c, d) = tuple(tuple(Fraction.from_float(float(v)) for v in row) for row in matrix)
u, v = tuple(Fraction.from_float(float(q)) - Fraction.from_float(float(t))
for q, t in zip(point, shift, strict=True))
determinant = a * d - b * c
return ((d * u - b * v) / determinant, (a * v - c * u) / determinant)
def points_close(left, right):
return all(math.isclose(a, float(b), rel_tol=1e-12, abs_tol=1e-12)
for a, b in zip(left, right, strict=True))
def signed_area(points):
return math.fsum(x * next_y - next_x * y
for (x, y), (next_x, next_y) in zip(points, points[1:] + points[:1], strict=True)) / 2
def rejects(call, error=ValueError):
try:
call()
except error:
return True
return False
def run_example():
matrix, shift = [[2, 1], [-1, 1]], [10, -4]
transform = AffinePoints(matrix, shift)
input_point, hand_output = (3, 2), (18, -5)
output_point = transform.forward(input_point)
restored = transform.inverse_point(hand_output)
corners = frame_corners(4, 3)
centers = [transform.forward(point) for point in corners['center_corners']]
support = [transform.forward(point) for point in corners['support_corners']]
hand_centers = [(13, -4), (19, -7), (21, -5), (15, -2)]
hand_support = [(11.5, -4), (19.5, -8), (22.5, -5), (14.5, -1)]
fractional_grid = [(x, y) for x in (-0.5, 0.5, 1.0, 3.0, 4.5) for y in (0.5, 1.0, 1.75, 2.0, 3.5)]
inverse_targets = [(-2, -1), (0, 0), (13, -4), (18, -5), (19.5, -8), (22.5, -5)]
zero_origin_shift = (12, -5) # t0 = A*(1,1) + t1 - (1,1), for both origins changed.
zero_transform = AffinePoints(matrix, zero_origin_shift)
checks = {}
checks['hand_forward_column_vector_point'] = output_point == hand_output
checks['hand_inverse_uses_subtract_translation_first'] = points_close(restored, input_point)
checks['forty_two_fixed_grid_roundtrips'] = all(points_close(transform.inverse_point(transform.forward((x, y))), (x, y))
for x in range(-2, 5) for y in range(-2, 4))
checks['independent_fraction_forward_twenty_five_points'] = all(points_close(transform.forward(p), exact_forward(matrix, shift, p)) for p in fractional_grid)
checks['independent_fraction_inverse_six_authored_targets'] = all(points_close(transform.inverse_point(p), exact_inverse(matrix, shift, p)) for p in inverse_targets)
checks['four_corner_centers_match_hand_values'] = centers == hand_centers
checks['half_pixel_support_is_distinct_from_center_rectangle'] = support == hand_support and corners['support_corners'] != corners['center_corners']
checks['zero_to_one_origin_change_requires_translation_conjugation'] = zero_transform.forward((2, 1)) == (17, -6) and tuple(v - 1 for v in output_point) == zero_transform.forward((2, 1))
identity = AffinePoints(((1, 0), (0, 1)), (0, 0))
checks['identity_map_and_inverse_preserve_fractional_point'] = identity.forward((0.5, 1.75)) == (0.5, 1.75) and identity.inverse_point((0.5, 1.75)) == (0.5, 1.75)
translation = AffinePoints(((1, 0), (0, 1)), (2, -3))
checks['pure_translation_and_inverse_have_opposite_shift'] = translation.forward((1, 2)) == (3, -1) and translation.inverse_point((3, -1)) == (1, 2)
q = transform.forward((4, 2))
checks['unit_x_displacement_maps_to_first_matrix_column'] = tuple(a - b for a, b in zip(q, output_point, strict=True)) == (2, -1)
midpoint = transform.forward((2, 2))
ends = (transform.forward((1, 1)), transform.forward((3, 3)))
checks['affine_midpoint_property_for_hand_points'] = midpoint == tuple((a + b) / 2 for a, b in zip(*ends, strict=True))
checks['signed_support_area_scales_by_positive_determinant_three'] = transform.determinant == 3.0 and signed_area(corners['support_corners']) == 12.0 and signed_area(support) == 36.0
checks['rejects_singular_underflowed_or_overflowed_determinant'] = all(rejects(lambda m=m: AffinePoints(m, (0, 0))) for m in (((1, 2), (2, 4)), ((1e-200, 0), (0, 1e-200)), ((1e200, 0), (0, 1e200))))
checks['rejects_matrix_point_shift_and_frame_shapes'] = all(rejects(call) for call in (
lambda: AffinePoints([[1, 0]], (0, 0)), lambda: AffinePoints(((1, 0, 0), (0, 1)), (0, 0)),
lambda: AffinePoints(matrix, (1,)), lambda: transform.forward((1,)),
lambda: transform.inverse_point('12'), lambda: frame_corners(True, 3),
lambda: frame_corners(0, 3), lambda: frame_corners(4, 1.0), lambda: frame_corners(1_000_001, 3)))
checks['rejects_bool_nonreal_nonfinite_and_integer_float_overflow'] = all(rejects(lambda v=v: transform.forward((v, 1))) and rejects(lambda v=v: AffinePoints(((v, 0), (0, 1)), (0, 0)))
for v in (True, '1', 1j, float('nan'), float('inf'), 10 ** 400))
checks['explicit_pixel_unit_and_nonfinite_output_guard'] = rejects(lambda: AffinePoints(matrix, shift, 'mm')) and rejects(lambda: transform.forward((sys.float_info.max, 0)), ArithmeticError)
saved_matrix, saved_shift = [row[:] for row in matrix], shift[:]
matrix[0][0], shift[0] = 999, 999
checks['copied_coefficients_and_repeatable_input_point'] = transform.forward(input_point) == output_point and transform.matrix == tuple(tuple(float(v) for v in row) for row in saved_matrix) and transform.shift == tuple(saved_shift) and input_point == (3, 2)
if len(checks) != 18 or not all(type(v) is bool and v for v in checks.values()):
raise AssertionError(checks)
return {'python_version': platform.python_version(), 'runtime': 'Python standard library only',
'coordinate_contract': 'one-based pixel centers; x=column, y=row increasing downward; column vector q=A*p+t; no image warp',
'matrix': [list(row) for row in transform.matrix], 'translation_pixel': list(transform.shift),
'determinant': transform.determinant, 'input_pixel': list(input_point),
'hand_output_pixel': list(hand_output), 'actual_output_pixel': list(output_point),
'inverse_restored_pixel': list(restored), 'frame_width_height': [4, 3],
'mapped_center_corners_pixel': [list(p) for p in centers],
'mapped_support_corners_pixel': [list(p) for p in support],
'input_support_area_pixel_squared': signed_area(corners['support_corners']),
'mapped_support_area_pixel_squared': signed_area(support),
'zero_based_equivalent_translation_pixel': list(zero_origin_shift),
'checks': checks, 'unique_checks': 18,
'limitations': ['Transforms coordinates only: no image pixels, intensities, interpolation, raster allocation, cropping or OpenCV/MATLAB execution.',
'Coordinate frame uses authored pixel units, not calibrated millimetres or camera geometry.',
'Computed zero/nonfinite determinant rejection is not a conditioning certificate; some mathematically invertible small matrices are rejected after float underflow.',
'Finite determinant/inverse entries and limited fixture roundtrips do not guarantee universal floating-point accuracy.']}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2, allow_nan=False))
ضریبها در tuple تازه نگه داشته میشوند تا تغییر فهرست اولیه، تبدیل ساختهشده را عوض نکند. جمع محصولات با fsum انجام میشود، ولی خطای ضربِ پیش از جمع حذف نمیشود. مرجع دوم با Fraction.from_float روی مقدار ذخیرهشده حساب کسری میکند. این مرجع برای نقاط کوچکِ انتخابشده مستقل از ضرایب معکوس float است؛ دادهٔ اعشاریِ پیش از ورود به برنامه را بازیابی نمیکند.
هجده گروه بررسی داریم: پاسخ دستی رفت و معکوس، ۴۲ نقطهٔ رفتوبرگشت، ۲۵ نقطه با مرجع رفتِ کسری و شش هدف معکوس با مرجع کسری. مراکز گوشه، مرز نیمپیکسل، تبدیل همانی، انتقال خالص، خاصیت نقطهٔ میانی و تغییر یک واحد x نیز آزموده میشوند. تعداد نقاط در یک گروه، تعداد آزمون مستقل تازه نیست. نگه داشتن پاسخ دستی، احتمال پنهان ماندن اشتباه مشترک رفت و برگشت را کم میکند.
۴. نتیجهٔ اجرا، تغییر مبدأ و محدودیت عددی

خروجی نمونه با جدول دستی سازگار است. چهار مرکز گوشه به ترتیب به سیزده و منفی چهار، نوزده و منفی هفت، بیستویک و منفی پنج، و پانزده و منفی دو میروند. گوشههای مرز بیرونی جواب متفاوتی دارند؛ برای نمونه، نیم و نیم به یازدهونیم و منفی چهار میرود. جابهجایی این دو جدول، پهنا و محدودهٔ پشتیبان را اشتباه میکند.
{
"python_version": "3.14.5",
"runtime": "Python standard library only",
"coordinate_contract": "one-based pixel centers; x=column, y=row increasing downward; column vector q=A*p+t; no image warp",
"matrix": [
[
2.0,
1.0
],
[
-1.0,
1.0
]
],
"translation_pixel": [
10.0,
-4.0
],
"determinant": 3.0,
"input_pixel": [
3,
2
],
"hand_output_pixel": [
18,
-5
],
"actual_output_pixel": [
18.0,
-5.0
],
"inverse_restored_pixel": [
3.0,
2.0
],
"frame_width_height": [
4,
3
],
"mapped_center_corners_pixel": [
[
13.0,
-4.0
],
[
19.0,
-7.0
],
[
21.0,
-5.0
],
[
15.0,
-2.0
]
],
"mapped_support_corners_pixel": [
[
11.5,
-4.0
],
[
19.5,
-8.0
],
[
22.5,
-5.0
],
[
14.5,
-1.0
]
],
"input_support_area_pixel_squared": 12.0,
"mapped_support_area_pixel_squared": 36.0,
"zero_based_equivalent_translation_pixel": [
12,
-5
],
"checks": {
"hand_forward_column_vector_point": true,
"hand_inverse_uses_subtract_translation_first": true,
"forty_two_fixed_grid_roundtrips": true,
"independent_fraction_forward_twenty_five_points": true,
"independent_fraction_inverse_six_authored_targets": true,
"four_corner_centers_match_hand_values": true,
"half_pixel_support_is_distinct_from_center_rectangle": true,
"zero_to_one_origin_change_requires_translation_conjugation": true,
"identity_map_and_inverse_preserve_fractional_point": true,
"pure_translation_and_inverse_have_opposite_shift": true,
"unit_x_displacement_maps_to_first_matrix_column": true,
"affine_midpoint_property_for_hand_points": true,
"signed_support_area_scales_by_positive_determinant_three": true,
"rejects_singular_underflowed_or_overflowed_determinant": true,
"rejects_matrix_point_shift_and_frame_shapes": true,
"rejects_bool_nonreal_nonfinite_and_integer_float_overflow": true,
"explicit_pixel_unit_and_nonfinite_output_guard": true,
"copied_coefficients_and_repeatable_input_point": true
},
"unique_checks": 18,
"limitations": [
"Transforms coordinates only: no image pixels, intensities, interpolation, raster allocation, cropping or OpenCV/MATLAB execution.",
"Coordinate frame uses authored pixel units, not calibrated millimetres or camera geometry.",
"Computed zero/nonfinite determinant rejection is not a conditioning certificate; some mathematically invertible small matrices are rejected after float underflow.",
"Finite determinant/inverse entries and limited fixture roundtrips do not guarantee universal floating-point accuracy."
]
}
مساحت جبریِ پشتیبان ورودی دوازده پیکسلِ مربع و خروجی سیوشش است؛ دترمینان مثبت سه، نسبت مساحت را در همین چارچوبهای همجهت حفظ میکند. این عدد، شمار پیکسلِ تصویر خروجی نیست. برای قاب کمکی، عرض و ارتفاع صحیح از یک تا یکمیلیون پذیرفته میشوند؛ این سقف انتخاب تمرین است و محدودیت OpenCV یا ظرفیت حافظهٔ دستگاه معرفی نمیشود.
اگر هم مبدأ ورودی و هم مبدأ خروجی را صفرمبنا کنیم، t جدید فقط t منهای یک نیست. با بردار یک، یک، رابطهٔ صحیح t صفر برابر A ضربدر آن بردار بهعلاوهٔ t یک منهای همان بردار است؛ در این نمونه دوازده و منفی پنج میشود. نقطهٔ صفرمبنای دو و یک به هفده و منفی شش میرود که همان خروجی قبلی با یک واحد کاهش هر مؤلفه است.
دترمینان محاسبهشدهٔ صفر یا نامتناهی رد میشود، اما غیرصفر بودن آن گواه دقت معکوس نیست. حتی ماتریسِ ریاضیِ معکوسپذیر با ضرایب بسیار کوچک ممکن است در محاسبهٔ float دترمینان صفر بدهد. تلورانس مقایسه در این fixture نسبی و مطلقِ ده به توان منفی دوازده است؛ بخش مطلق واحد پیکسل دارد. این انتخاب، تضمین عمومی شرطیبودن عددی یا دقت فیزیکی نیست.
۵. چهار تمرین پاسخدار برای نگهداری قرارداد

تمرین اول: سطر دو و ستون سه را به چه زوجی میدهیم؟ پاسخ: سه و دو؛ خروجی هجده و منفی پنج است. زوج دو و سه، نقطهٔ دیگری است و هفده و منفی سه میدهد. با آزمون صریح، تغییر اتفاقی ترتیب فیلدها دیده میشود.
تمرین دوم: گوشهٔ بیرونی بالای چپ صفر و صفر است؟ پاسخ: در قرارداد یکمبنای ما نیم و نیم است؛ مرکز پیکسل نخست یک و یک است. نام «گوشه» بدون مشخص کردن مرکز یا مرز کافی نیست.
تمرین سوم: آیا مختصاتِ درست، تصویر خروجی را هم تولید کرده است؟ پاسخ: خیر؛ برای شدتها باید نمونهگیری، قاب و مرز تعریف شود. ارزیابی ماسک با IoU و Dice نیز جای آزمون همترازی مختصات را نمیگیرد.
تمرین چهارم: عبور رفتوبرگشت برای همهٔ ماتریسها کافی است؟ پاسخ: خیر؛ مرجع مستقل، پاسخ دستی و تحلیل دامنه لازماند. آزمون مرز درونیابی کالیبراسیون نمونهٔ دیگری از قرارداد واحد و مرز است. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدهاند؛ نه OpenCV و MATLAB اجرا شدهاند و نه ادعای دقت نامحدود مطرح است.
منابع و مطالعهٔ بیشتر
- MathWorks imref2d؛ مراکز و حدود درونی تصویر
- MathWorks؛ قرارداد سطر، ستون و مختصات تصویر
- OpenCV 4.13.0؛ جهت warpAffine و WARP_INVERSE_MAP
- Python 3.14؛ Fraction.from_float و حساب کسری مرجع
- Python 3.14؛ isclose، isfinite و fsum
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

