در بازرسی نوری، پیش از انتخاب مدل پیچیده می‌توان یک خط پایهٔ روشن و قابل‌آزمون ساخت. این درس روش Otsu را روی شبکه‌ای کوچک از شدت‌های خاکستری پیاده می‌کند: آستانه‌ای پیدا می‌کنیم که دو گروه شدت را از نظر عددی جدا کند. تمام پیکسل‌ها ساختگی‌اند؛ هیچ دوربین، برد معیوب، مدل یادگیری یا سامانهٔ کنترل کیفی واقعی آزمایش نشده است.

۱. قرارداد ورودی خاکستری را پیش از آستانه مشخص کنیم

قرارداد ورودی خاکستری و دامنهٔ شدت
تصویر مفهومی: قرارداد ورودی خاکستری و دامنهٔ شدت؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

ورودی، فهرستی از سطرهای هم‌اندازه است؛ حداکثر شصت‌وچهار سطر و شصت‌وچهار ستون داریم. هر پیکسل دقیقاً عدد صحیح صفر تا ۲۵۵ است. مقدار bool، عدد شناور، رشته، سطر خالی و شبکهٔ نامنظم رد می‌شوند. این‌ها کران‌های همین پیاده‌سازی آموزشی‌اند و محدودیت عمومی روش Otsu نیستند. هیچ تصویر رنگی به‌صورت ضمنی خاکستری نمی‌شود.

شدت در اینجا کد دیجیتال است، نه ولتاژ حسگر یا کمیت فیزیکیِ کالیبره‌شده. اگر ورودی از دوربین واقعی بیاید، نور، نوردهی، تبدیل رنگ و دامنهٔ شدت باید جدا ثبت شوند. مقدار عددی مشابه از دو زنجیرهٔ تصویربرداری الزاماً معنای اندازه‌گیری یکسان ندارد. برای شروع آن مسیر، راهنمای مهندسی پیش‌پردازش و ارزیابی تصویر چارچوب کلی را توضیح می‌دهد.

نمونهٔ ما دو سطر دارد: سطر اول یک، یک، دو، دو و سطر دوم هشت، هشت، نه، نه. دامنهٔ استفاده‌شده عمداً کوچک است تا پاسخ دستی قابل‌دیدن باشد؛ داده همچنان در قرارداد هشت‌بیتی قرار دارد. قرار نیست با ضرب یا کشیدن خودکار شدت‌ها تا ۲۵۵، مسئلهٔ دیگری بسازیم یا تصویر جذاب‌تری به‌عنوان نتیجه نشان دهیم.

۲. هیستوگرام تعداد شدت‌ها را نگه می‌دارد، نه مکان آن‌ها را

شمارش شدت‌های یکسان مستقل از جای پیکسل
تصویر مفهومی: شمارش شدت‌های یکسان مستقل از جای پیکسل؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

هیستوگرام شامل ۲۵۶ شمارنده است؛ شمارندهٔ هر شدت می‌گوید چند پیکسل آن مقدار را دارند. در مثال، شدت‌های یک، دو، هشت و نه هرکدام دو بار آمده‌اند و بقیهٔ شمارنده‌ها صفرند. جمع شمارنده‌ها هشت و جمعِ شدت ضرب‌در تعداد، چهل است. بنابراین میانگین کل، چهل تقسیم بر هشت، یعنی پنج می‌شود.

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

در مستندات threshold_otsu در scikit-image، ورودی خاکستری و امکان دادن هیستوگرام توضیح داده شده است. ما آن کتابخانه را اجرا نکرده‌ایم؛ هیستوگرام صحیحِ ثابت و کد مستقل خودمان را داریم. انتخاب تعداد بازه برای تصویر شناور یا تبدیل دامنه، مسئلهٔ دیگری است که این قرارداد به‌صورت پنهان انجام نمی‌دهد.

۳. جدایی دو گروه و قاعدهٔ تساوی را دقیق بنویسیم

جداسازی دو گروه شدت با مرز روشن
تصویر مفهومی: جداسازی دو گروه شدت با مرز روشن؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

برای هر آستانهٔ صحیح از صفر تا ۲۵۴، گروه پایین شامل شدت‌های کمتر یا مساوی آستانه و گروه بالا شامل شدت‌های بزرگ‌تر است. نامزد با گروه خالی کنار گذاشته می‌شود. هدف، بیشینه‌کردن واریانس بین‌کلاسی است: حاصل‌ضرب سهم دو گروه در مربع اختلاف میانگین‌هایشان. این واریانس جمعیتی است و تصحیح نمونه‌ایِ تقسیم بر تعداد منهای یک ندارد.

در آستانهٔ دو، هر گروه چهار پیکسل دارد. میانگین پایین یک‌ونیم و میانگین بالا هشت‌ونیم است؛ اختلاف هفت می‌شود. پس امتیاز بین‌کلاسی برابر یک‌دوم ضرب‌در یک‌دوم ضرب‌در چهل‌ونه، یعنی چهل‌ونه‌چهارم است. واریانس کل بیست‌وپنج‌دوم و بخش درون‌کلاسی یک‌چهارم است؛ جمع دو بخش با واریانس کل برابر می‌شود.

آموزش رسمی آستانه‌گذاری OpenCV هدف کمینه‌کردن واریانس درون‌کلاسیِ وزن‌دار را توضیح می‌دهد. برای دادهٔ ثابت، کمینه‌کردن آن با بیشینه‌کردن بخش بین‌کلاسی هم‌ارز است. پیاده‌سازی ما کسرهای دقیق می‌سازد؛ مقایسهٔ تساوی، متکی به خطای ممیز شناور یا تلورانس پنهان نیست.

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

۴. کد کامل را با مرجع مستقل مقایسه کنیم

بازبینی مستقل محاسبهٔ آستانه و ماسک
تصویر مفهومی: بازبینی مستقل محاسبهٔ آستانه و ماسک؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

کد otsu_threshold.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. مسیر اصلی از تعدادها و جمع‌های تجمعیِ هیستوگرام استفاده می‌کند. مرجع مستقل، پیکسل‌های خام را برای هر آستانه واقعاً به دو فهرست تقسیم و مجموع مربع فاصله از میانگین هر گروه را حساب می‌کند. این مرجع تابع امتیاز یا هیستوگرام مسیر اصلی را صدا نمی‌زند.

"""Exact two-class Otsu objective for bounded synthetic 8-bit grayscale grids.

No camera, OpenCV/scikit-image execution, trained model or defect detection.
"""
from fractions import Fraction
from itertools import product
import json
import platform


def validated_grid(image):
    if type(image) is not list or not image or len(image) > 64:
        raise ValueError('grid_shape')
    if type(image[0]) is not list or not 1 <= len(image[0]) <= 64:
        raise ValueError('grid_shape')
    width = len(image[0])
    for row in image:
        if type(row) is not list or len(row) != width:
            raise ValueError('grid_shape')
        if any(type(value) is not int or not 0 <= value <= 255 for value in row):
            raise ValueError('gray8_pixel')
    return [row[:] for row in image]


def otsu_exact(image):
    grid = validated_grid(image)
    histogram = [0] * 256
    for row in grid:
        for value in row:
            histogram[value] += 1
    total = sum(histogram)
    total_sum = sum(value * count for value, count in enumerate(histogram))
    left_count = left_sum = 0
    best_score = None
    best_thresholds = []
    for threshold in range(255):
        left_count += histogram[threshold]
        left_sum += threshold * histogram[threshold]
        right_count = total - left_count
        if not left_count or not right_count:
            continue
        # p_left*p_right*(mean_left-mean_right)**2, exactly rational.
        score = Fraction((total * left_sum - total_sum * left_count) ** 2,
                         total ** 2 * left_count * right_count)
        if best_score is None or score > best_score:
            best_score, best_thresholds = score, [threshold]
        elif score == best_score:
            best_thresholds.append(threshold)
    if best_score is None:
        raise ValueError('no_two_nonempty_classes')
    threshold = best_thresholds[0]  # Explicit own tie policy: smallest integer.
    mask = [[int(value > threshold) for value in row] for row in grid]
    return {'threshold': threshold, 'between_variance': str(best_score),
            'tied_thresholds': best_thresholds, 'histogram': histogram,
            'shape': [len(grid), len(grid[0])], 'mask': mask}


def direct_within_class_oracle(image):
    """Independent exhaustive pixel partitions, no histogram/cumulative sums."""
    pixels = [value for row in image for value in row]
    candidates = []
    for threshold in range(255):
        low = [value for value in pixels if value <= threshold]
        high = [value for value in pixels if value > threshold]
        if not low or not high:
            continue
        objective = Fraction(0)
        for group in [low, high]:
            mean = Fraction(sum(group), len(group))
            objective += sum((Fraction(value) - mean) ** 2 for value in group)
        candidates.append((objective / len(pixels), threshold))
    if not candidates:
        raise ValueError('no_two_nonempty_classes')
    minimum = min(value for value, threshold in candidates)
    tied = [threshold for value, threshold in candidates if value == minimum]
    overall_mean = Fraction(sum(pixels), len(pixels))
    total_variance = sum((Fraction(value) - overall_mean) ** 2 for value in pixels) / len(pixels)
    return {'threshold': min(tied), 'tied_thresholds': tied,
            'within_variance': str(minimum), 'total_variance': str(total_variance),
            'between_variance': str(total_variance - minimum)}


def raises(call, message):
    try:
        call()
    except ValueError as error:
        return str(error) == message
    return False


def run_example():
    image = [[1, 1, 2, 2], [8, 8, 9, 9]]
    untouched = [row[:] for row in image]
    result = otsu_exact(image)
    oracle = direct_within_class_oracle(image)
    rearranged = [list(reversed(row)) for row in reversed(image)]
    replicated = otsu_exact(image + image)
    translated = otsu_exact([[value + 10 for value in row] for row in image])
    scaled = otsu_exact([[2 * value for value in row] for row in image])
    inverted = otsu_exact([[255 - value for value in row] for row in image])
    invalid_shapes = [None, (), [], [[]], [[1], [2, 3]], [1, 2], [[1], (2,)]]
    invalid_pixels = [True, False, 1.0, '1', None, -1, 256]
    large = [[0] * 32 + [255] * 32 for _ in range(64)]
    large_result = otsu_exact(large)
    cases = 0
    oracle_pass = True
    for counts in product(range(4), repeat=3):
        if sum(count > 0 for count in counts) < 2:
            continue
        fixture = [[value for value, count in enumerate(counts) for _ in range(count)]]
        actual, reference = otsu_exact(fixture), direct_within_class_oracle(fixture)
        oracle_pass &= all(actual[key] == reference[key]
                           for key in ['threshold', 'tied_thresholds', 'between_variance'])
        cases += 1
    exposed = otsu_exact(image)
    exposed['mask'][0][0] = 99
    exposed['histogram'][1] = 99
    fresh = otsu_exact(image)
    checks = {
        'hand_histogram_counts_and_mass': [(i, n) for i, n in enumerate(result['histogram']) if n] == [(1, 2), (2, 2), (8, 2), (9, 2)] and sum(result['histogram']) == 8,
        'hand_smallest_threshold_and_all_ties': result['threshold'] == 2 and result['tied_thresholds'] == list(range(2, 8)),
        'hand_between_and_within_variances': result['between_variance'] == '49/4' and oracle['within_variance'] == '1/4' and oracle['total_variance'] == '25/2',
        'hand_binary_mask_and_shape': result['mask'] == [[0, 0, 0, 0], [1, 1, 1, 1]] and result['shape'] == [2, 4],
        'pixels_equal_to_threshold_are_background': result['threshold'] == 2 and result['mask'][0][2:] == [0, 0],
        'one_level_and_single_pixel_explicitly_reject': raises(lambda: otsu_exact([[7]]), 'no_two_nonempty_classes') and raises(lambda: otsu_exact([[7, 7], [7, 7]]), 'no_two_nonempty_classes'),
        'empty_ragged_and_nonlist_grid_reject': all(raises(lambda value=value: otsu_exact(value), 'grid_shape') for value in invalid_shapes),
        'pixel_type_bool_and_range_reject': all(raises(lambda value=value: otsu_exact([[0, value]]), 'gray8_pixel') for value in invalid_pixels),
        'histogram_weighted_sum_matches_pixels': sum(i * n for i, n in enumerate(result['histogram'])) == 40 == sum(map(sum, image)),
        'pixel_rearrangement_keeps_global_threshold': otsu_exact(rearranged)['histogram'] == result['histogram'] and otsu_exact(rearranged)['threshold'] == 2 and otsu_exact(rearranged)['mask'] == [[1, 1, 1, 1], [0, 0, 0, 0]],
        'replicating_counts_keeps_normalized_objective': replicated['threshold'] == 2 and replicated['between_variance'] == '49/4' and replicated['mask'] == result['mask'] * 2,
        'integer_intensity_translation_preserves_partition': translated['threshold'] == 12 and translated['between_variance'] == '49/4' and translated['mask'] == result['mask'],
        'positive_intensity_scaling_changes_squared_units': scaled['threshold'] == 4 and scaled['between_variance'] == '49' and scaled['mask'] == result['mask'],
        'intensity_inversion_complements_fixture_mask': inverted['threshold'] == 247 and inverted['between_variance'] == '49/4' and inverted['mask'] == [[1 - value for value in row] for row in result['mask']],
        'adjacent_extreme_levels_have_valid_zero_threshold': otsu_exact([[0, 1]])['threshold'] == 0 and otsu_exact([[0, 255]])['between_variance'] == '65025/4',
        'bounded_shape_includes_64_rejects_65': large_result['shape'] == [64, 64] and sum(large_result['histogram']) == 4096 and raises(lambda: otsu_exact([[0, 1]] * 65), 'grid_shape') and raises(lambda: otsu_exact([[0] * 64 + [1]]), 'grid_shape'),
        'independent_direct_partition_oracle': cases == 54 and bool(oracle_pass) and all(result[key] == oracle[key] for key in ['threshold', 'tied_thresholds', 'between_variance']),
        'inputs_and_new_outputs_are_independent': image == untouched and fresh == result,
    }
    assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
            'scope': 'Synthetic bounded integer grayscale, exact Otsu objective; not OpenCV/skimage execution, model, camera or defect detection',
            'results': {'input_gray8': image, 'threshold': result['threshold'],
                        'all_tied_thresholds': result['tied_thresholds'],
                        'nonzero_histogram': [{'intensity': i, 'count': n} for i, n in enumerate(result['histogram']) if n],
                        'between_variance': result['between_variance'],
                        'within_variance_independent': oracle['within_variance'],
                        'total_variance_independent': oracle['total_variance'],
                        'binary_mask': result['mask'], 'foreground_pixels': sum(map(sum, result['mask'])),
                        'oracle_scenarios_inside_one_group': cases,
                        'limits': {'max_rows': 64, 'max_columns': 64, 'pixels': 'exact int0..255; bool excluded', 'tie_policy': 'smallest integer threshold', 'constant_grid': 'reject'}},
            'checks': checks}


if __name__ == '__main__':
    print(json.dumps(run_example(), ensure_ascii=False, indent=2))

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

ماسک نمونه، سطر اول چهار صفر و سطر دوم چهار یک دارد؛ چهار پیکسل روشن‌اند. قاعده دقیقاً شدت بزرگ‌تر از دو است؛ پیکسلِ دو در پس‌زمینه می‌ماند. خروجی صفر و یک، برچسب دودویی است و به معنای سالم یا معیوب بودن قطعه نیست. همچنین اجرای صحیح این مثال، انطباق بایت‌به‌بایت با OpenCV یا scikit-image را ثابت نمی‌کند.

۵. محدودیت نور و چهار پرسش پاسخ‌دار را گزارش کنیم

اثر نور ناهمگن و محدودیت آستانهٔ سراسری
تصویر مفهومی: اثر نور ناهمگن و محدودیت آستانهٔ سراسری؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

آستانهٔ سراسری، مسئلهٔ نور ناهمگن را خودکار حل نمی‌کند. بازتاب، سایه، چند نوع ماده و نویز می‌توانند فرض تقسیم شدت را نامناسب کنند. خودِ آموزش OpenCV میان آستانهٔ سراسری و روش محلی تمایز می‌گذارد. انتخاب روش برای بازرسی واقعی به دادهٔ مستقل و تعریف عیب نیاز دارد؛ این درس فقط محاسبهٔ خط پایه را شفاف می‌کند.

{
  "python": "3.14.5",
  "dependencies": "Python standard library only",
  "scope": "Synthetic bounded integer grayscale, exact Otsu objective; not OpenCV/skimage execution, model, camera or defect detection",
  "results": {
    "input_gray8": [
      [
        1,
        1,
        2,
        2
      ],
      [
        8,
        8,
        9,
        9
      ]
    ],
    "threshold": 2,
    "all_tied_thresholds": [
      2,
      3,
      4,
      5,
      6,
      7
    ],
    "nonzero_histogram": [
      {
        "intensity": 1,
        "count": 2
      },
      {
        "intensity": 2,
        "count": 2
      },
      {
        "intensity": 8,
        "count": 2
      },
      {
        "intensity": 9,
        "count": 2
      }
    ],
    "between_variance": "49/4",
    "within_variance_independent": "1/4",
    "total_variance_independent": "25/2",
    "binary_mask": [
      [
        0,
        0,
        0,
        0
      ],
      [
        1,
        1,
        1,
        1
      ]
    ],
    "foreground_pixels": 4,
    "oracle_scenarios_inside_one_group": 54,
    "limits": {
      "max_rows": 64,
      "max_columns": 64,
      "pixels": "exact int0..255; bool excluded",
      "tie_policy": "smallest integer threshold",
      "constant_grid": "reject"
    }
  },
  "checks": {
    "hand_histogram_counts_and_mass": true,
    "hand_smallest_threshold_and_all_ties": true,
    "hand_between_and_within_variances": true,
    "hand_binary_mask_and_shape": true,
    "pixels_equal_to_threshold_are_background": true,
    "one_level_and_single_pixel_explicitly_reject": true,
    "empty_ragged_and_nonlist_grid_reject": true,
    "pixel_type_bool_and_range_reject": true,
    "histogram_weighted_sum_matches_pixels": true,
    "pixel_rearrangement_keeps_global_threshold": true,
    "replicating_counts_keeps_normalized_objective": true,
    "integer_intensity_translation_preserves_partition": true,
    "positive_intensity_scaling_changes_squared_units": true,
    "intensity_inversion_complements_fixture_mask": true,
    "adjacent_extreme_levels_have_valid_zero_threshold": true,
    "bounded_shape_includes_64_rejects_65": true,
    "independent_direct_partition_oracle": true,
    "inputs_and_new_outputs_are_independent": true
  }
}

پرسش اول: چرا آستانهٔ پنج انتخاب نشد؟ پاسخ: پنج نیز همان امتیاز را دارد، اما قاعدهٔ تساویِ اعلام‌شده کوچک‌ترین نامزد را انتخاب می‌کند؛ پاسخ دو است و ماسک هر شش نامزد یکسان می‌ماند.

پرسش دوم: اگر همهٔ شدت‌ها ده واحد زیاد شوند چه می‌شود؟ پاسخ: در همین نمونه و بدون خروج از دامنه، آستانه دوازده می‌شود؛ اختلاف میانگین‌ها، امتیاز چهل‌ونه‌چهارم و ماسک حفظ می‌شوند. کد این حالت را جدا بررسی می‌کند.

پرسش سوم: اگر شدت‌ها دو برابر شوند، امتیاز چه می‌شود؟ پاسخ: برای همین داده، آستانه چهار و امتیاز چهل‌ونه است؛ مربع اختلاف میانگین چهار برابر شده و ماسک تغییر نکرده است.

پرسش چهارم: آیا چهار پیکسل روشن، چهار عیب هستند؟ پاسخ: خیر؛ برچسب عیب و شاهد مستقل نداریم. ارزیابی سامانهٔ مهندسی باید با خروجی موردنیاز تنظیم شود. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند؛ نسخه‌های مستندات OpenCV 4.13.0 و scikit-image 0.26.0 ثبت شده‌اند. تاریخ تولید صفحه، تاریخ انتشار پژوهش نیست و این آموزش خبر تازه معرفی نمی‌شود.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.