۱. شدتِ خاکستری را از مکانِ پیکسل جدا کنیم

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

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

تصویر را یک tuple از سطرهای tuple می‌گیریم. تعدادِ سطر و ستون، هرکدام از یک تا سی‌ودو و عرضِ تمامِ سطرها برابر است. تعدادِ سطوح، عددِ صحیحِ واقعی از دو تا ۲۵۶ است؛ هر نمونه نیز دقیقاً int و در بازهٔ صفر تا یک کمتر از تعدادِ سطوح قرار دارد. bool، شناور، فهرست، تصویرِ تهی و سطرِ نامنظم رد می‌شوند.

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

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

۲. جدولِ تجمعی و سیاستِ تصویرِ ثابت را تعریف کنیم

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

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

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

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

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

۳. کد کاملِ نگاشتِ صحیح و گردکردن به پایین

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

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

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

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

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

"""Own bounded global CDF-min equalization; standard library only."""
from fractions import Fraction
from itertools import product
from math import floor
import json
import platform


def equalize(image, levels=256):
    """Return (image, histogram, cumulative_counts, LUT); no input mutation."""
    if type(levels) is not int:
        raise TypeError('levels must be an actual int')
    if not 2 <= levels <= 256:
        raise ValueError('levels must be in 2..256')
    if type(image) is not tuple or any(type(row) is not tuple for row in image):
        raise TypeError('image and rows must be actual tuples')
    if not 1 <= len(image) <= 32 or not 1 <= len(image[0]) <= 32:
        raise ValueError('image dimensions must be in 1..32')
    width = len(image[0])
    if any(len(row) != width for row in image):
        raise ValueError('image must be rectangular')
    if any(type(value) is not int for row in image for value in row):
        raise TypeError('samples must be actual ints, excluding bool')
    if any(not 0 <= value < levels for row in image for value in row):
        raise ValueError('sample outside 0..levels-1')
    histogram = [0] * levels
    for row in image:
        for value in row:
            histogram[value] += 1
    cdf, total = [], 0
    for count in histogram:
        total += count
        cdf.append(total)
    first = next(count for count in cdf if count > 0)
    if first == total:
        lut = tuple(range(levels))  # Own explicit constant-image policy.
    else:
        lut = tuple((levels-1)*max(0, count-first)//(total-first)
                    for count in cdf)
    transformed = tuple(tuple(lut[value] for value in row) for row in image)
    return transformed, tuple(histogram), tuple(cdf), lut


def rank_reference(image, levels):
    """Test-only direct per-pixel rank counts; no histogram/CDF/LUT builder."""
    pixels = tuple(value for row in image for value in row)
    smallest, largest = min(pixels), max(pixels)
    if smallest == largest:
        return image
    baseline = sum(value == smallest for value in pixels)
    def mapped(value):
        rank = sum(other <= value for other in pixels)
        return floor(Fraction((levels-1)*(rank-baseline), len(pixels)-baseline))
    return tuple(tuple(mapped(value) for value in row) for row in image)


def rejected(exception, image, levels=256):
    try:
        equalize(image, levels)
    except exception:
        return True
    return False


def main():
    image = ((1, 1, 3, 4), (4, 4, 6, 6))
    output, histogram, cdf, lut = equalize(image, 8)
    checks = {}
    checks['hand_histogram_and_cumulative_counts'] = (
        histogram == (0, 2, 0, 1, 3, 0, 2, 0) and cdf == (0, 2, 2, 3, 6, 6, 8, 8))
    checks['hand_lut_and_output'] = (
        lut == (0, 0, 0, 1, 4, 4, 7, 7) and output == ((0, 0, 1, 4), (4, 4, 7, 7)))
    checks['constant_identity_including_unused_levels'] = all(
        equalize(((value, value),), 8)[0] == ((value, value),)
        and equalize(((value,),), 8)[3] == tuple(range(8)) for value in range(8))
    rounding_image = ((0, 1, 2, 3, 3),)
    rounded = equalize(rounding_image, 4)[0]
    checks['floor_is_not_nearest_rounding'] = (
        rounded == ((0, 0, 1, 3, 3),) and floor(Fraction(3, 4)) == 0
        and round(Fraction(3, 4)) == 1)
    checks['nonconstant_observed_endpoints'] = (
        output[0][0] == 0 and output[-1][-1] == 7)
    checks['lut_nondecreasing_and_bounded'] = (
        all(0 <= value < 8 for value in lut) and all(a <= b for a, b in zip(lut, lut[1:])))
    reversed_image = tuple(tuple(reversed(row)) for row in reversed(image))
    checks['spatial_permutation_equivariance'] = (
        equalize(reversed_image, 8)[0] == tuple(tuple(reversed(row)) for row in reversed(output)))
    checks['uniform_pixel_replication_preserves_lut'] = equalize(image+image, 8)[3] == lut
    checks['unobserved_lower_and_upper_bins_have_explicit_values'] = (
        equalize(((2, 5),), 8)[3] == (0, 0, 0, 0, 0, 7, 7, 7))
    checks['quantized_mapping_can_merge_distinct_values'] = (
        rounding_image[0][0] != rounding_image[0][1] and rounded[0][0] == rounded[0][1])
    checks['immutable_input_and_output_shape'] = (
        image == ((1, 1, 3, 4), (4, 4, 6, 6)) and type(output) is tuple
        and all(type(row) is tuple and len(row) == 4 for row in output) and len(output) == 2)
    checks['exact_type_rejections'] = all([
        rejected(TypeError, [[1]]), rejected(TypeError, ([1],)),
        rejected(TypeError, ((True,),)), rejected(TypeError, ((1.0,),)),
        rejected(TypeError, ((1,),), True), rejected(TypeError, ((1,),), 8.0)])
    checks['shape_value_and_inclusive_cap_boundaries'] = all([
        rejected(ValueError, ()), rejected(ValueError, ((),)),
        rejected(ValueError, ((1,), (1, 2))), rejected(ValueError, ((-1,),)),
        rejected(ValueError, ((256,),)), rejected(ValueError, ((1,),)*33),
        rejected(ValueError, ((1,)*33,)), rejected(ValueError, ((1,),), 1),
        rejected(ValueError, ((1,),), 257), equalize(((255,)*32,)*32)[0] == ((255,)*32,)*32])
    cases, oracle_pass = 0, True
    for levels in (2, 3, 4):
        for length in range(1, 6):
            for values in product(range(levels), repeat=length):
                candidate = (values,)
                oracle_pass &= equalize(candidate, levels)[0] == rank_reference(candidate, levels)
                cases += 1
    for values in product(range(2), repeat=6):
        candidate = (values[:3], values[3:])
        oracle_pass &= equalize(candidate, 2)[0] == rank_reference(candidate, 2)
        cases += 1
    checks['independent_direct_rank_fraction_oracle'] = bool(oracle_pass and cases == 1853)
    assert len(checks) == 14 and all(type(v) is bool and v for v in checks.values()), checks
    print(json.dumps(dict(python=platform.python_version(),checks=checks,
        example=dict(levels=8,input=image,histogram=histogram,cumulative_counts=cdf,cdf_min=2,
                     lut=lut,output=output),floor_witness=dict(input=rounding_image,output=rounded),
        constant_policy='identity LUT; image unchanged',independent_oracle_cases_in_one_group=cases,
        libraries_executed='Python standard library only; no OpenCV or scikit-image'),indent=2))


if __name__ == '__main__':
    main()

۴. خروجی را با شمارشِ رتبهٔ مستقل بسنجیم

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

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

تمامِ دنباله‌های طولِ یک تا پنج برای دو، سه و چهار سطح، همراهِ تمامِ تصاویرِ دودوییِ دو در سه، با این مرجع مقایسه شدند: ۱۸۵۳ ورودی در یک گروهِ آزمونِ مستقل. گروه‌های دیگر، شاهدِ دستی، تصویرِ ثابت، تفاوتِ گردکردن، کرانِ نوع و شکل، تکرارِ یکنواختِ پیکسل‌ها و حفظِ ترتیب را می‌سنجند. تعدادِ گروه‌های نام‌دارِ برنامه چهارده است؛ تعدادِ ورودی‌ها گروهِ تازه محسوب نمی‌شود.

اجرای واقعی با Python 3.14.5 انجام شد؛ هر چهارده مقدارِ بررسی درست و خروجیِ خطا تهی بود. نسخهٔ صفحهٔ OpenCV برابرِ 4.13.0 و مستنداتِ CDF و equalize_hist در scikit-image برابرِ 0.26.0 بود؛ دسترسی در ۱۸ اکتبرِ ۲۰۲۶ ثبت شد. آن مستندات خروجیِ شناور و قراردادِ کتابخانهٔ خودشان را دارند؛ نسخهٔ مرجع با محیطِ اجرای ما یکی نیست.

نامِ همسان‌سازی نباید ما را به انتظارِ هیستوگرامِ کاملاً یکنواخت برساند. تعدادها گسسته‌اند و نگاشت می‌تواند چند شدتِ متفاوت را یکی کند؛ شاهدِ گردکردن دقیقاً چنین رفتاری دارد. این کار جزئیاتِ ازدست‌رفته را بازسازی نمی‌کند و ممکن است تفاوت‌های نامطلوب را هم برجسته کند. روشِ محلیِ محدودکنندهٔ کنتراست، تحلیلِ نور و ارزیابیِ دادهٔ واقعی، مسئله‌های جداگانه‌اند.

{
  "python": "3.14.5",
  "checks": {
    "hand_histogram_and_cumulative_counts": true,
    "hand_lut_and_output": true,
    "constant_identity_including_unused_levels": true,
    "floor_is_not_nearest_rounding": true,
    "nonconstant_observed_endpoints": true,
    "lut_nondecreasing_and_bounded": true,
    "spatial_permutation_equivariance": true,
    "uniform_pixel_replication_preserves_lut": true,
    "unobserved_lower_and_upper_bins_have_explicit_values": true,
    "quantized_mapping_can_merge_distinct_values": true,
    "immutable_input_and_output_shape": true,
    "exact_type_rejections": true,
    "shape_value_and_inclusive_cap_boundaries": true,
    "independent_direct_rank_fraction_oracle": true
  },
  "example": {
    "levels": 8,
    "input": [
      [
        1,
        1,
        3,
        4
      ],
      [
        4,
        4,
        6,
        6
      ]
    ],
    "histogram": [
      0,
      2,
      0,
      1,
      3,
      0,
      2,
      0
    ],
    "cumulative_counts": [
      0,
      2,
      2,
      3,
      6,
      6,
      8,
      8
    ],
    "cdf_min": 2,
    "lut": [
      0,
      0,
      0,
      1,
      4,
      4,
      7,
      7
    ],
    "output": [
      [
        0,
        0,
        1,
        4
      ],
      [
        4,
        4,
        7,
        7
      ]
    ]
  },
  "floor_witness": {
    "input": [
      [
        0,
        1,
        2,
        3,
        3
      ]
    ],
    "output": [
      [
        0,
        0,
        1,
        3,
        3
      ]
    ]
  },
  "constant_policy": "identity LUT; image unchanged",
  "independent_oracle_cases_in_one_group": 1853,
  "libraries_executed": "Python standard library only; no OpenCV or scikit-image"
}

۵. چهار پرسش دربارهٔ کنتراست، ترتیب و نسخهٔ پردازش

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

پرسش اول: اگر تصویرِ ثابت را مستقیم داخلِ فرمولِ غیریکنواخت بگذاریم، چرا داشتنِ هشت یا ۲۵۶ سطح مشکل را حل نمی‌کند؟ آیا می‌توان هر خروجیِ دلخواهی را نتیجهٔ همان فرمول دانست؟

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

پرسش دوم: اگر سطرها و ستون‌ها را با یک جایگشتِ ثابت جابه‌جا کنیم، آیا باید جدولِ تازه‌ای به دست بیاید؟ تفاوتِ جدول با تفاوتِ تصویرِ خروجی چیست؟

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

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

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

پرسش چهارم: اگر نسخهٔ تازهٔ پیش‌پردازش به‌جای کف از نزدیک‌ترین عددِ صحیح استفاده کند، آیا یکسان‌بودنِ ابعاد و درست‌بودنِ نوعِ خروجی برای پذیرشِ تغییر کافی است؟

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

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

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