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

تصویرِ نمونه شش ستون و چهار سطر دارد. آدرس پیکسلها از صفر شروع میشود: x ستون و y سطر است؛ x به راست و y به پایین زیاد میشود. پیکسلِ آخر آدرس (5,3) دارد، اما مرزِ راستِ قاب شش و مرز پایین چهار است. همین تفاوت میان آخرین آدرس و مرزِ بعد از آن، منشأ بسیاری از تغییرهای اشتباهِ یکپیکسلی است.
راهنمای مختصات Pillow، مختصات را به گوشههای ضمنیِ پیکسل مربوط میکند؛ مرکزِ خانهٔ نخست در (0.5,0.5) است. در این برنامه، مستطیل فقط گوشههای صحیح میگیرد. مختصاتِ مرکز را خودکار گرد نمیکنیم و به گوشه تبدیل نمیکنیم. واحد px است؛ از اندازهٔ قاب، مساحت فیزیکیِ قطعه یا مقیاسِ میلیمتری به دست نمیآید.
مستطیل را بهترتیب left، top، right و bottom میدهیم. خانهٔ پیکسل با آدرس x و y وقتی انتخاب میشود که x از left کوچکتر نباشد و از right کوچکتر باشد؛ برای y نیز همین شرط است. پس کادر (1,1,3,3) چهار خانه دارد، نه نه خانه. عرض دو و ارتفاع دو است؛ مرزِ راست و پایین داخلِ شمارش نیستند.
۲. ناحیهٔ درخواستی را با قاب تقاطع بدهیم

در توضیح Rect در OpenCV هم قرارداد رایجِ شاملِ چپ و بالا و خارجبودنِ راست و پایین بیان شده است. اینجا OpenCV را اجرا نمیکنیم؛ منبع فقط به روشنشدن قرارداد کمک میکند. نامِ مشترکِ مستطیل، برابریِ تمام تنظیماتِ مرکز، مرز و گردکردن میان کتابخانهها را ثابت نمیکند.
درخواستِ دستی (-2,1,4,5) از سمت چپ و پایینِ قاب بیرون زده است. تابعِ ما ابتدا آن را با قاب تقاطع میدهد و (0,1,4,4) حاصل میشود؛ خروجی چهار ستون و سه سطر، یعنی دوازده خانه است. min و max برای محدودکردن گوشهها به کار میروند. مقدارِ روشناییِ خانهها در این مرحله تغییر نمیکند؛ هنوز هیچ بازنمونهگیری یا تغییر اندازهای مطرح نیست.
اگر تنها به لبهٔ راستِ قاب برسیم، مانند (6,0,8,4)، تقاطعِ غیرخالی نداریم. کادرِ با عرض یا ارتفاع صفر نیز همین وضعیت را دارد. سیاستِ ما برای همهٔ اینها None است؛ تصویرِ سیاهِ ساختگی را جای «خانهای انتخاب نشد» نمیگذاریم. کادرِ وارونه، مانند راستِ کوچکتر از چپ، خطای ورودی است و با تعویضِ خودکارِ گوشهها اصلاح نمیشود.
۳. clipping برنامه با crop خام یکی نیست

Image.crop در Pillow ناحیهای با چهار مختصات میگیرد. در اجرای ثبتشدهٔ ما، crop خامِ همان درخواستِ بیرونقاب، اندازهٔ شش در چهار داد و خانههای بیرونیِ تصویر L را با صفر پر کرد. این شاهدِ همان نسخه و حالتِ تصویری است؛ آن را به رنگِ پرکنندهٔ همهٔ حالتها تعمیم نمیدهیم. تابعِ ما عمداً پیش از crop، کادر را محدود میکند.
تصویرِ ساختگی با حالت L و مقدارِ هر خانه برابر یک بهعلاوهٔ ده y بهعلاوهٔ x ساخته میشود. بنابراین در برشِ دستی، سطر نخست 11،12،13،14، سطر دوم 21،22،23،24 و سطر سوم 31،32،33،34 است. اینها شدتهای دلخواهِ آزموناند و ولتاژ، نورسنجی یا اندازهگیریِ تصویر واقعی نیستند. فرمول ساده کمک میکند جابهجایی سطر و ستون را هم ببینیم.
"""Actual Pillow crop of tiny synthetic L-mode images with a bounded ROI profile."""
import json
import platform
from itertools import product
import PIL
from PIL import Image
def clip_box(box, width, height, *, unit="px"):
if unit != "px":
raise ValueError("unit must be px")
if any(type(v) is not int or not 1 <= v <= 64 for v in (width, height)):
raise ValueError("bounded positive integer frame size required")
if type(box) not in (list, tuple) or len(box) != 4:
raise ValueError("box must be a four-item list or tuple")
if any(type(v) is not int or not -128 <= v <= 128 for v in box):
raise ValueError("box coordinates must be exact int in [-128,128]")
left, top, right, bottom = box
if right < left or bottom < top:
raise ValueError("inverted box")
left, top = max(0, left), max(0, top)
right, bottom = min(width, right), min(height, bottom)
return None if right <= left or bottom <= top else (left, top, right, bottom)
def crop_checked(image, box, *, unit="px"):
if not isinstance(image, Image.Image) or image.mode != "L":
raise ValueError("only an already-created L-mode image is supported")
clipped = clip_box(box, image.width, image.height, unit=unit)
if clipped is None:
return None
return clipped, image.crop(clipped)
def make_image(width, height):
image = Image.new("L", (width, height))
for y in range(height):
for x in range(width):
image.putpixel((x, y), 1 + 10 * y + x)
return image
def pixel_reference(box, width, height):
# Full frame enumeration; no max/min clipping or crop call in this oracle.
left, top, right, bottom = box
return [(x, y) for y in range(height) for x in range(width)
if left <= x < right and top <= y < bottom]
def rejects(function, *args, **kwargs):
try:
function(*args, **kwargs)
except ValueError:
return True
return False
def run_example():
checks = {}
def check(name, condition):
checks[name] = bool(condition)
if not checks[name]:
raise AssertionError(name)
image = make_image(6, 4)
original = image.tobytes()
requested = (-2, 1, 4, 5)
clipped, result = crop_checked(image, requested)
hand_pixels = [[11, 12, 13, 14], [21, 22, 23, 24], [31, 32, 33, 34]]
actual_pixels = [[result.getpixel((x, y)) for x in range(result.width)] for y in range(result.height)]
check("hand_clipped_box_and_dimensions", clipped == (0, 1, 4, 4) and result.size == (4, 3))
check("hand_crop_pixels_exact", actual_pixels == hand_pixels and len(result.tobytes()) == 12)
one = crop_checked(image, (5, 3, 6, 4))[1]
check("one_pixel_at_last_valid_address", one.size == (1, 1) and one.getpixel((0, 0)) == 36)
inner = crop_checked(image, (1, 1, 3, 3))[1]
check("right_and_bottom_excluded", inner.size == (2, 2) and list(inner.tobytes()) == [12, 13, 22, 23])
check("empty_or_disjoint_explicit_none", all(crop_checked(image, b) is None for b in ((2, 1, 2, 4), (0, 3, 6, 3), (6, 0, 8, 4), (-4, -4, -1, -1))))
whole = crop_checked(image, (0, 0, 6, 4))[1]
check("whole_frame_same_values_source_unchanged", whole is not image and whole.tobytes() == original and image.tobytes() == original)
raw = image.crop(requested)
check("raw_outside_crop_padding_is_not_our_clipping", raw.size == (6, 4) and raw.getpixel((0, 0)) == 0 and raw.getpixel((2, 0)) == 11 and raw.getpixel((2, 3)) == 0 and result.size != raw.size)
whole.putpixel((0, 0), 200)
check("crop_edit_does_not_edit_source", image.getpixel((0, 0)) == 1 and image.tobytes() == original)
check("clipping_idempotent_on_nonempty_result", clip_box(clipped, 6, 4) == clipped)
cases = 0
for width, height in product((2, 4), (2, 3)):
small = make_image(width, height)
for left, right in product((-1, 0, 2, 4), repeat=2):
if right < left:
continue
for top, bottom in product((-1, 0, 1, 3), repeat=2):
if bottom < top:
continue
box = (left, top, right, bottom)
coordinates = pixel_reference(box, width, height)
output = crop_checked(small, box)
if not coordinates:
if output is not None:
raise AssertionError("empty pixel oracle")
else:
bounds, cropped = output
xs, ys = zip(*coordinates)
expected_bounds = (min(xs), min(ys), max(xs) + 1, max(ys) + 1)
expected_bytes = bytes(1 + 10 * y + x for x, y in coordinates)
if bounds != expected_bounds or cropped.tobytes() != expected_bytes or cropped.width * cropped.height != len(coordinates):
raise AssertionError("independent pixel-set oracle")
cases += 1
check("independent_pixel_set_400_boxes_one_group", cases == 400)
inclusive_mutant_count = (clipped[2] - clipped[0] + 1) * (clipped[3] - clipped[1] + 1)
check("inclusive_plus_one_mutation_detected", inclusive_mutant_count == 20 and inclusive_mutant_count != len(pixel_reference(requested, 6, 4)) == 12)
left_pixels = set(pixel_reference((0, 0, 3, 4), 6, 4))
right_pixels = set(pixel_reference((3, 0, 6, 4), 6, 4))
check("touching_rois_partition_without_duplicate_border", not left_pixels.intersection(right_pixels) and len(left_pixels.union(right_pixels)) == 24)
check("inverted_or_out_of_contract_coordinates_rejected", all(rejects(clip_box, b, 6, 4) for b in ((2, 0, 1, 3), (0, 3, 2, 1), (-129, 0, 1, 1), (0, 0, 129, 1))))
check("box_shape_numeric_types_nonfinite_bool_rejected", all(rejects(clip_box, b, 6, 4) for b in ((0, 0, 1), None, "0011", (False, 0, 1, 1), (0, 0, 1.0, 1), (0, float("nan"), 1, 1), (0, 0, float("inf"), 1))))
check("image_mode_and_frame_domain_rejected", rejects(crop_checked, None, (0, 0, 1, 1)) and rejects(crop_checked, Image.new("RGB", (2, 2)), (0, 0, 1, 1)) and rejects(clip_box, (0, 0, 1, 1), 0, 4) and rejects(clip_box, (0, 0, 1, 1), 65, 4))
check("coordinate_unit_not_inferred", rejects(crop_checked, image, (0, 0, 1, 1), unit="mm"))
return {"python_version": platform.python_version(), "pillow_version": PIL.__version__,
"scope": "actual Pillow cropping of synthetic already-created L-mode images; own half-open integer-pixel profile, clip before crop, empty None; no camera/file image, resampling, affine warp or vision-model quality test",
"input_domain": {"frame_dimensions_px_each": [1, 64], "coordinate_values_px": [-128, 128], "unit": "px", "mode": "L"},
"hand_request_box": list(requested), "hand_clipped_box": list(clipped), "hand_crop_size": list(result.size), "hand_crop_pixels": actual_pixels,
"raw_unclipped_crop_size": list(raw.size), "raw_padding_value_in_recorded_L_fixture": 0,
"inclusive_plus_one_wrong_count": inclusive_mutant_count, "correct_pixel_count": 12,
"reference_boxes_in_one_group": cases, "checks": checks}
if __name__ == "__main__":
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
قراردادِ تابع محدود است: تصویرِ ازقبلساختهشدهٔ L با هر بُعد بین یک و ۶۴؛ چهار مختصاتِ int دقیق بین منفی ۱۲۸ و مثبت ۱۲۸؛ واحد دقیقاً px. bool، float، NaN، بینهایت، شکلِ نامناسب و گوشهٔ وارونه رد میشوند. حالت RGB نیز در این نمایه پشتیبانی نشده است؛ ردشدن آن، به معنی ناتوانیِ خودِ Pillow در برشِ RGB نیست.
۴. مرجعِ مجموعهٔ پیکسل و تغییرِ عمدیِ اشتباه

مرجع مستقل تمام آدرسهای داخلِ قابِ کوچک را پیمایش میکند و شرطِ عضویت در کادرِ اصلی را برای هرکدام میسنجد. در مسیرِ مرجع، تابعِ clipping و crop فراخوانی نمیشوند؛ فهرستِ خانههای منتخب، اندازه و بایتهای مورد انتظار را میسازد. چهارصد کادر روی چهار قابِ کوچک با این روش سنجیده شدهاند؛ مجموعهٔ آنها فقط یک گروه از شانزده گروهِ بررسی است.
{
"python_version": "3.14.5",
"pillow_version": "12.3.0",
"scope": "actual Pillow cropping of synthetic already-created L-mode images; own half-open integer-pixel profile, clip before crop, empty None; no camera/file image, resampling, affine warp or vision-model quality test",
"input_domain": {
"frame_dimensions_px_each": [
1,
64
],
"coordinate_values_px": [
-128,
128
],
"unit": "px",
"mode": "L"
},
"hand_request_box": [
-2,
1,
4,
5
],
"hand_clipped_box": [
0,
1,
4,
4
],
"hand_crop_size": [
4,
3
],
"hand_crop_pixels": [
[
11,
12,
13,
14
],
[
21,
22,
23,
24
],
[
31,
32,
33,
34
]
],
"raw_unclipped_crop_size": [
6,
4
],
"raw_padding_value_in_recorded_L_fixture": 0,
"inclusive_plus_one_wrong_count": 20,
"correct_pixel_count": 12,
"reference_boxes_in_one_group": 400,
"checks": {
"hand_clipped_box_and_dimensions": true,
"hand_crop_pixels_exact": true,
"one_pixel_at_last_valid_address": true,
"right_and_bottom_excluded": true,
"empty_or_disjoint_explicit_none": true,
"whole_frame_same_values_source_unchanged": true,
"raw_outside_crop_padding_is_not_our_clipping": true,
"crop_edit_does_not_edit_source": true,
"clipping_idempotent_on_nonempty_result": true,
"independent_pixel_set_400_boxes_one_group": true,
"inclusive_plus_one_mutation_detected": true,
"touching_rois_partition_without_duplicate_border": true,
"inverted_or_out_of_contract_coordinates_rejected": true,
"box_shape_numeric_types_nonfinite_bool_rejected": true,
"image_mode_and_frame_domain_rejected": true,
"coordinate_unit_not_inferred": true
}
}
برای بازبینیِ تغییرِ کد، یک محاسبهٔ عمداً غلط هم داریم: به هر دو امتدادِ کادرِ محدودشده یک اضافه کنیم. بهجای چهار ضربدر سه، پنج ضربدر چهار و عدد بیست میگیریم؛ مرجعِ پیکسلها همچنان دوازده است. این شکست نشان میدهد انتظارِ آزمون باید از قرارداد مستقل بیاید. اگر همان فرمولِ غلط را در تابع و مرجع کپی کنیم، آزمون میتواند بهاشتباه سبز بماند.
دو کادرِ (0,0,3,4) و (3,0,6,4) نیز قاب را بدون خانهٔ مشترک به دو بخش تقسیم میکنند. هرکدام دوازده و اجتماعشان ۲۴ خانه دارد. شرطِ شاملِ راست، مرزِ ستون سه را دوباره وارد میکرد. تکپیکسلِ (5,3,6,4)، ناحیهٔ خالی، ثابتماندنِ مبدأ و مستقلبودنِ کپیِ برش نیز بررسی شدهاند؛ ویرایشِ خروجی در آزمون، تصویرِ مبدأ را تغییر نداد.
کد با Python 3.14.5 و Pillow 12.3.0 نصبشده اجرا شد. مستندات جاریِ خواندهشده Pillow 13.0.0 است و کدِ محلیِ crop همان نسخهٔ اجرا نیز کامل بررسی شد؛ نسخهای نصب یا ارتقا ندادیم. تاریخ دسترسی ۲۵ مهر ۱۴۰۵ است. موفقیتِ این fixture، قراردادِ همهٔ قالبهای فایل، تصویر عظیم، جهت EXIF، نشت حافظه یا سرعتِ پردازش را تأیید نمیکند؛ اصلاً فایل تصویری باز نشده است.
آزمون تبدیل آفینِ مختصات دربارهٔ نقطههای پیوسته با قراردادِ یکمبنا بود؛ اینجا کادرِ صفرمبنای خانههای تصویر را داریم. ارزیابی ماسک با IoU و Dice هم معیارِ پیشزمینه را میسنجد. حتی امتیازِ همپوشانیِ خوب جای اثباتِ کادرِ درست را نمیگیرد؛ در این درس هیچ معیارِ مدلِ بینایی گزارش نشده است.
۵. چهار پرسش با پاسخ کامل

پرسش اول: چرا پیکسل آخر در قابِ شش در چهار، (6,4) نیست؟ پاسخ: آدرسها صفرمبنا هستند؛ آخرین خانه (5,3) است. شش و چهار، مرزهای بعد از آخرین ستون و سطرند و در شمارشِ نیمهباز وارد نمیشوند.
پرسش دوم: درخواستِ (-2,1,4,5) چند خانهٔ داخل قاب دارد؟ پاسخ: پس از تقاطع، (0,1,4,4) و اندازهٔ چهار در سه داریم؛ دوازده خانه. اندازهٔ شش در چهارِ crop خام، بهعلت سیاستِ بیرونقابِ متفاوت، جوابِ این قرارداد نیست.
پرسش سوم: برای کادرِ خالی، تصویر سیاه بدهیم؟ پاسخ: این نمایه None برمیگرداند. تصویرِ سیاه میتواند دادهٔ معتبرِ غیرخالی باشد و جایِ معنای «هیچ خانه» نیست؛ مصرفکننده باید این شاخه را صریح رسیدگی کند.
پرسش چهارم: افزودنِ یک به عرض و ارتفاعِ نمونه چه نتیجهای دارد؟ پاسخ: بیست بهجای دوازده خانه میدهد و با مرجعِ مستقل رد میشود. این تغییر، قراردادِ شاملِ دو انتها را با قراردادِ نیمهباز خلط کرده است؛ اصلاحش افزودنِ همان اشتباه به آزمون نیست.
منابع و مطالعهٔ بیشتر
- Pillow؛ دستگاه مختصات و گوشهٔ ضمنی پیکسل
- Pillow؛ Image.crop و مستطیل چپ، بالا، راست، پایین
- OpenCV 4.13؛ تعریف مرز نیمهباز Rect برای توضیح قرارداد
بررسی منابع: ۲۵ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.
