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

ورودی ما تاپلی از سطرهای تاپلیِ هماندازه است؛ هر بُعد یک تا دوازده و هر خانه دقیقاً عدد صحیح صفر یا یک است. یک، پیشزمینه و صفر، پسزمینه است. bool، شدت خاکستری، احتمال و رشته خودکار تبدیل نمیشوند. انتخاب آستانه پیش از این مرحله انجام شده است و برنامه از روی ماسک، کیفیت آن انتخاب را حدس نمیزند. خروجی نیز روی همان شبکه و با همان شکل باقی میماند.
ردپا یا footprint یک الگوی کوچکِ صفر و یک است. هر بُعد آن فقط یک یا سه، مرکز فعال و الگو نسبت به مرکز متقارن است؛ برای هر جایگاه فعال، جایگاهِ قرینه نیز فعال است. اینها سادهسازیِ قرارداد ما هستند، نه شرط لازمِ همهٔ روشهای ریختشناسی. الگوهای زوج، نامتقارن، مبدأ دلخواه و ردپاهای بزرگتر در این تابع پشتیبانی نمیشوند. نپذیرفتنشان به معنای نامعتبر بودنِ آنها در کتابخانههای عمومی نیست.
بیرون هر دو آرایهٔ مرحلهای را صفر فرض میکنیم. لبهٔ چپ به راست نمیپیوندد و تصویر بازتاب داده نمیشود. مستندات opening در scikit-image حالتهای مرزی و پیشفرض reflect را جدا معرفی میکند؛ همان پیشفرض، قرارداد این برنامه نیست. بدون ثبت ردپا، مبدأ و رفتار بیرونِ تصویر، مقایسهٔ دو خروجی با نام مشترک opening کافی نیست.
۲. فرسایش و اتساع را با ترتیب روشن ترکیب کنیم

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

فایل binary_opening_contract.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. آن را ذخیره و با python binary_opening_contract.py اجرا کنید. SciPy و scikit-image در محیط نصب نیستند؛ مرجع تعریفها هستند و خروجی به اجرای آنها نسبت داده نمیشود. هم فرسایش و هم نتیجهٔ نهایی در JSON ثبت میشوند تا خطای یک مرحله پشت تصویر نهایی پنهان نماند.
"""Synthetic zero-exterior binary opening; no image or PCB inspection claim."""
from dataclasses import dataclass
from itertools import product
import json
import platform
@dataclass(frozen=True)
class OpeningResult:
eroded: tuple[tuple[int, ...], ...]
opened: tuple[tuple[int, ...], ...]
def binary_grid(grid, name, maximum):
if type(grid) is not tuple or not 1 <= len(grid) <= maximum:
raise ValueError(f"{name} needs an actual nonempty bounded tuple")
if type(grid[0]) is not tuple or not 1 <= len(grid[0]) <= maximum:
raise ValueError(f"{name} rows must be actual nonempty bounded tuples")
width = len(grid[0])
if any(type(row) is not tuple or len(row) != width for row in grid):
raise ValueError(f"{name} must be rectangular")
if any(type(value) is not int or value not in (0, 1)
for row in grid for value in row):
raise ValueError(f"{name} values must be actual integers zero or one")
return len(grid), width
def binary_opening(mask, footprint):
"""One erosion then one dilation, zero outside both finite stage arrays."""
height, width = binary_grid(mask, "mask", 12)
fh, fw = binary_grid(footprint, "footprint", 3)
if fh % 2 != 1 or fw % 2 != 1:
raise ValueError("footprint dimensions must be odd")
center_r, center_c = fh // 2, fw // 2
if footprint[center_r][center_c] != 1:
raise ValueError("the footprint center must be present")
if any(footprint[r][c] != footprint[fh - 1 - r][fw - 1 - c]
for r in range(fh) for c in range(fw)):
raise ValueError("need a centrosymmetric footprint")
offsets = tuple((r - center_r, c - center_c)
for r in range(fh) for c in range(fw) if footprint[r][c])
eroded = tuple(tuple(int(all(
0 <= r + dr < height and 0 <= c + dc < width and mask[r + dr][c + dc] == 1
for dr, dc in offsets)) for c in range(width)) for r in range(height))
opened = tuple(tuple(int(any(
0 <= r - dr < height and 0 <= c - dc < width and eroded[r - dr][c - dc] == 1
for dr, dc in offsets)) for c in range(width)) for r in range(height))
return OpeningResult(eroded, opened)
def fitting_union_oracle(mask, footprint):
"""Valid-fixture reference: union translated footprint sets that fit inside A.
No erosion array, dilation array, main helper, centered offsets or main call.
"""
foreground = {(r, c) for r, row in enumerate(mask) for c, value in enumerate(row) if value == 1}
support = {(r, c) for r, row in enumerate(footprint) for c, value in enumerate(row) if value == 1}
height, width = len(mask), len(mask[0])
fh, fw = len(footprint), len(footprint[0])
covered = set()
for top in range(1 - fh, height):
for left in range(1 - fw, width):
translated = {(top + r, left + c) for r, c in support}
if translated <= foreground:
covered.update(translated)
return tuple(tuple(int((r, c) in covered) for c in range(width)) for r in range(height))
def positions(grid):
return {(r, c) for r, row in enumerate(grid) for c, value in enumerate(row) if value == 1}
def transpose(grid):
return tuple(tuple(row[c] for row in grid) for c in range(len(grid[0])))
def rejects(call):
try:
call()
except ValueError:
return True
return False
def run():
mask = tuple(tuple(int(x) for x in row) for row in (
"00000000010", "01111000000", "01111000000", "01111000000",
"00000000000", "00000111110", "00000000000",
))
square = ((1, 1, 1),) * 3
horizontal = ((1, 1, 1),)
sq = binary_opening(mask, square)
hz = binary_opening(mask, horizontal)
block = {(r, c) for r in (1, 2, 3) for c in (1, 2, 3, 4)}
trace = {(5, c) for c in (5, 6, 7, 8, 9)}
checks = {}
checks["hand_foreground_and_eroded_stage_counts"] = (
len(positions(mask)) == 18 and len(positions(sq.eroded)) == 2
and len(positions(hz.eroded)) == 9
and positions(sq.eroded) == {(2, 2), (2, 3)}
)
checks["hand_square_removes_trace_horizontal_retains_trace"] = (
positions(sq.opened) == block and positions(hz.opened) == block | trace
and positions(mask) - positions(sq.opened) == trace | {(0, 9)}
)
checks["edge_line_zero_exterior_and_oversized_footprint"] = (
binary_opening(((1, 1, 1),), horizontal).opened == ((1, 1, 1),)
and binary_opening(((1, 1, 1),), square).opened == ((0, 0, 0),)
and binary_opening(((1,),), square).opened == ((0,),)
)
checks["single_center_footprint_is_identity_even_with_zero_frame"] = (
binary_opening(mask, ((1,),)).opened == mask
and binary_opening(mask, ((0, 0, 0), (0, 1, 0), (0, 0, 0))).opened == mask
)
oracle_cases = []
anti_idempotent = []
for bits in product((0, 1), repeat=9):
small = tuple(tuple(bits[3 * r + c] for c in range(3)) for r in range(3))
for footprint in (square, horizontal):
got = binary_opening(small, footprint).opened
oracle_cases.append(got == fitting_union_oracle(small, footprint))
anti_idempotent.append(
positions(got) <= positions(small)
and binary_opening(got, footprint).opened == got
)
checks["all_1024_fitting_union_cases_one_group"] = all(oracle_cases)
checks["anti_extensive_and_idempotent_on_all_small_fixtures_one_group"] = all(anti_idempotent)
empty = ((0,) * 5,) * 5
full = ((1,) * 5,) * 5
checks["all_zero_and_full_rectangle_hand_answers"] = (
binary_opening(empty, square).opened == empty
and binary_opening(full, square).opened == full
and binary_opening(((1, 1),), horizontal).opened == ((0, 0),)
)
checks["paired_transpose_preserves_geometry_and_shape"] = (
binary_opening(transpose(mask), transpose(horizontal)).opened == transpose(hz.opened)
and binary_opening(transpose(mask), square).opened == transpose(sq.opened)
)
checks["reflection_does_not_wrap_edges"] = (
binary_opening(tuple(row[::-1] for row in mask), horizontal).opened
== tuple(row[::-1] for row in hz.opened)
and binary_opening(((1, 0, 1),), horizontal).opened == ((0, 0, 0),)
)
additions = []
for r in range(len(mask)):
for c in range(len(mask[0])):
enlarged = tuple(tuple(1 if (rr, cc) == (r, c) else mask[rr][cc]
for cc in range(len(mask[0]))) for rr in range(len(mask)))
additions.append(positions(sq.opened) <= positions(binary_opening(enlarged, square).opened))
checks["adding_foreground_does_not_remove_opened_pixels_on_fixture"] = all(additions)
checks["reject_mask_type_shape_values_and_bounds"] = all(
rejects(lambda bad=bad: binary_opening(bad, square))
for bad in ([], (), ((),), ((0,), (0, 1)), ([1],), ((True,),),
((1.0,),), ((2,),), (("1",),), ((0,),) * 13, ((0,) * 13,))
)
checks["reject_even_asymmetric_absent_center_or_invalid_footprints"] = all(
rejects(lambda bad=bad: binary_opening(mask, bad))
for bad in ((), ((1, 1),), ((1,), (1,)), ((0,),),
((0, 0, 0), (0, 1, 1), (0, 0, 0)),
((0, 0, 0), (0, True, 0), (0, 0, 0)),
((1,),) * 5, ((1,), (1, 0)), [[1]])
)
checks["input_mask_and_footprints_unchanged"] = (
len(positions(mask)) == 18 and square == ((1, 1, 1),) * 3
and horizontal == ((1, 1, 1),)
)
if not all(type(v) is bool and v for v in checks.values()):
raise AssertionError(checks)
return {
"python": platform.python_version(), "dependencies": "standard library only",
"case_kind": "synthetic binary geometry, not measured PCB or image quality",
"mask": mask, "outside_value_both_stages": 0,
"square_footprint": square, "horizontal_footprint": horizontal,
"square_eroded": sq.eroded, "square_opened": sq.opened,
"horizontal_eroded": hz.eroded, "horizontal_opened": hz.opened,
"foreground_pixel_counts": {"input": 18, "square_eroded": 2, "square_opened": 12,
"horizontal_eroded": 9, "horizontal_opened": 17},
"square_removed_coordinates_rc": sorted(positions(mask) - positions(sq.opened)),
"oracle_cases_inside_one_group": len(oracle_cases),
"checks": checks, "check_count": len(checks), "all_passed": True,
"limitations": ["No threshold selection, connected-component labeling or physical pixel scale",
"Opening may erase useful thin features; retained pixels do not prove quality",
"Only odd centered centrosymmetric footprints of shape1or3 per axis are accepted",
"No SciPy/skimage execution, general API parity, actual camera or board test"],
}
if __name__ == "__main__":
print(json.dumps(run(), ensure_ascii=False, indent=2, allow_nan=False))
اعتبارسنجیِ شکل و نوع پیش از محاسبه انجام میشود. آرایهٔ ناهموار، سطر خالی، مقدار دو، bool، طول بیش از حد و ردپای فاقد مرکز خطا میدهند. تابع هیچ مقداری را اصلاح، برش یا نرمال نمیکند. خروجیِ frozen شامل تاپلهای تازه است؛ ادعای تخصیصِ بدون کپی، اندازهٔ بایتیِ RAM یا سرعتِ یک روش بهینه نداریم. ورودیِ فراخواننده در اجرای نمونه تغییر نمیکند.
مرکزِ فعال برای قراردادِ محدود ما اهمیت دیگری هم دارد: اگر ردپا کاملاً در پیشزمینه جا شود، مرکز آن نیز داخل تصویر است. بنابراین پیمایش مرکزهای داخل شبکه، جایگذاریِ معتبری را به علت بیرون بودنِ مرکز کنار نمیگذارد. برای ردپاهای دلخواهی که مرکزِ فعال ندارند، نمیتوان این استدلال را بیبررسی تکرار کرد؛ آن حالت عمداً پذیرفته نشده است.
۴. مرجع جایگذاری و شاهد حذف مسیر باریک

مرجع مستقل، آرایهٔ فرسایش یا اتساع نمیسازد. پیشزمینه را مجموعهٔ مختصات میگیرد، همهٔ جایگذاریهای ردپا را بررسی میکند و هر مجموعهٔ انتقالیافتهای را که کاملاً داخل پیشزمینه جا شده باشد، به اجتماع میافزاید. مختصاتِ مرجع از گوشهٔ ردپا نوشته میشوند؛ فهرست offsetهای مرکز یا تابعِ مسیر اصلی دوباره استفاده نمیشود. جواب نهاییِ دو مسیر برای دادههای معتبر مقایسه میشود.
همهٔ ۵۱۲ ماسک سه در سه، با دو ردپای مربع و افقی، ۱۰۲۴ مورد در یک گروهِ مرجع میسازند. ویژگیِ بیرون نزدن نتیجه از پیشزمینه و تغییر نکردن با اجرای دوباره نیز روی همین مجموعه در یک گروه جدا بررسی میشود. اینها نوعِ دیگری از شرطاند، نه دادهٔ تجربی تازه. تمام سیزده گروهِ نامدار گذشتند؛ شمار حالتهای داخل یک گروه را تعداد گروههای مستقل نمینامیم.
{
"python": "3.14.5",
"dependencies": "standard library only",
"case_kind": "synthetic binary geometry, not measured PCB or image quality",
"mask": [
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
],
"outside_value_both_stages": 0,
"square_footprint": [
[
1,
1,
1
],
[
1,
1,
1
],
[
1,
1,
1
]
],
"horizontal_footprint": [
[
1,
1,
1
]
],
"square_eroded": [
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
],
"square_opened": [
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
],
"horizontal_eroded": [
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
1,
1,
1,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
],
"horizontal_opened": [
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
],
"foreground_pixel_counts": {
"input": 18,
"square_eroded": 2,
"square_opened": 12,
"horizontal_eroded": 9,
"horizontal_opened": 17
},
"square_removed_coordinates_rc": [
[
0,
9
],
[
5,
5
],
[
5,
6
],
[
5,
7
],
[
5,
8
],
[
5,
9
]
],
"oracle_cases_inside_one_group": 1024,
"checks": {
"hand_foreground_and_eroded_stage_counts": true,
"hand_square_removes_trace_horizontal_retains_trace": true,
"edge_line_zero_exterior_and_oversized_footprint": true,
"single_center_footprint_is_identity_even_with_zero_frame": true,
"all_1024_fitting_union_cases_one_group": true,
"anti_extensive_and_idempotent_on_all_small_fixtures_one_group": true,
"all_zero_and_full_rectangle_hand_answers": true,
"paired_transpose_preserves_geometry_and_shape": true,
"reflection_does_not_wrap_edges": true,
"adding_foreground_does_not_remove_opened_pixels_on_fixture": true,
"reject_mask_type_shape_values_and_bounds": true,
"reject_even_asymmetric_absent_center_or_invalid_footprints": true,
"input_mask_and_footprints_unchanged": true
},
"check_count": 13,
"all_passed": true,
"limitations": [
"No threshold selection, connected-component labeling or physical pixel scale",
"Opening may erase useful thin features; retained pixels do not prove quality",
"Only odd centered centrosymmetric footprints of shape1or3 per axis are accepted",
"No SciPy/skimage execution, general API parity, actual camera or board test"
]
}
ترانهاده کردن هم ماسک و هم ردپای افقی، هندسه را با ردپای عمودی مقایسه میکند؛ عوض کردن فقط یکی از آنها همان آزمایش نیست. حالتِ لبه، تمامصفر، مستطیلِ پُر، ردپای تکمرکز و افزودنِ پیشزمینه نیز آزموده شدهاند. بررسی IoU و Dice ماسک نشان میدهد چرا یک خلاصهٔ عددی، همهٔ ویژگیهای شکل را پوشش نمیدهد؛ اینجا هم حذف شش پیکسل را بهبود کیفیت نمینامیم.
۵. چهار پرسش پیش از پاکسازی واقعی تصویر

پرسش اول: چرا خروجیِ مربع دوازده است؟ پاسخ: فقط مستطیلِ دوازدهپیکسلی از اجتماعِ دو جایگذاریِ کامل بازسازی میشود؛ خطِ پنجپیکسلی و نقطهٔ جدا جا برای مربع ندارند. تعداد حذفشده شش است، ولی بدون مرجعِ وظیفه نمیدانیم این حذف مطلوب است یا نه.
پرسش دوم: چرا ردپای افقی خط را نگه داشت؟ پاسخ: در خطِ پنجپیکسلی، سه جایگذاریِ افقیِ کامل داریم. اجتماع آنها همان پنج جایگاه را میپوشاند. این حفظ شدن، صحتِ الکتریکی یا سالم بودنِ مسیر را ثابت نمیکند.
پرسش سوم: روی سطرِ یک، یک، یک چه میشود؟ پاسخ: ردپای افقی خروجی را همان سطر میگذارد؛ ردپای مربع، به علت همسایههای عمودیِ بیرونِ صفر، همه را حذف میکند. رفتار لبه و شکل ردپا هر دو در جواب نقش دارند.
پرسش چهارم: پایداریِ اجرای دوم یعنی انتخاب درست بوده است؟ پاسخ: خیر؛ حذف مسیر باریک میتواند پایدار و همچنان برای کاربرد نامناسب باشد. قرارداد مرز برش تصویر نیز مرحلهٔ جداگانهای از هندسهٔ ورودی است. منابع در ۲۶ مهر ۱۴۰۵ خوانده شدند؛ مستندات SciPy نسخهٔ 1.18.0 و scikit-image نسخهٔ 0.26.0، با تاریخ اصلی انتشار نامعلوم، مرجع این آموزش پایهاند. نتیجهٔ تجهیزات یا خبر تازهای گزارش نمیشود.
منابع و مطالعهٔ بیشتر
- SciPy 1.18؛ تعریف binary_opening، ترتیب مرحلهها و رفتار مرز
- scikit-image 0.26؛ opening و قرارداد مستقل حالتهای مرزی
بررسی منابع: ۲۶ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

