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

۱. تصویر خاکستری و هستهٔ محدود را دقیق تعریف کنیم

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

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

شعاع هسته یک پیکسل و پارامتر سیگما یک پیکسل است. برای جابه‌جایی‌های منفی یک، صفر و مثبت یک، وزن خام از نماییِ منفیِ مربع جابه‌جایی تقسیم بر دو مربع سیگما به دست می‌آید. سپس هر وزن بر جمع همان سه وزن تقسیم می‌شود. نتیجه تقریباً ۰٫۲۷۴۰۶۹، ۰٫۴۵۱۸۶۳ و ۰٫۲۷۴۰۶۹ است.

تعریف getGaussianKernel در OpenCV فرمول و نرمال‌سازیِ مجموع وزن‌ها را توضیح می‌دهد. این هسته گسسته، بریده و دوباره نرمال‌شده است؛ انتگرال پیوستهٔ Gaussian را محاسبه نمی‌کنیم. سیگما پارامتر فرمول است و پس از بریدن، الزاماً برابر انحراف معیارِ محاسبه‌شده از همین وزن‌های محدود نیست. OpenCV نیز در این مثال اجرا نشده است.

۲. حاصل‌ضرب دو هسته را به دو گذر تبدیل کنیم

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

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

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

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

۳. مرز صفر و تکرار لبه خروجی متفاوت دارند

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

سیاست zero مقدارهای بیرون تصویر را صفر می‌گیرد. سیاست edge نزدیک‌ترین پیکسل لبه را ادامه می‌دهد. مستندات pad در NumPy این دو گسترش را جدا تعریف می‌کند. در هر دو گذر باید سیاست یکسان اعمال شود؛ وگرنه قرارداد مرجعِ دوبعدیِ ما دیگر همان مسئله نیست.

برای تصویر ثابتِ سه در سه با شدت یک، تکرار لبه همه‌جا یک را حفظ می‌کند. در مرز صفر، گوشه فقط وزن‌های مربوط به داخل تصویر را روی مقدار یک می‌بیند. اگر وزن کناری را a و مرکزی را b بنامیم، جواب گوشه (a+b)^2، تقریباً ۰٫۵۲۶۹۷۶ است؛ مرکز همان یک می‌ماند.

وزن‌های گم‌شده را در لبه دوباره نرمال نمی‌کنیم؛ آن کار الگوریتم دیگری می‌سازد. برای تصویر تک‌پیکسلیِ یک، مرز صفر فقط مربع وزن مرکزی را می‌دهد، حدود ۰٫۲۰۴۱۸۰؛ تکرار لبه همچنان یک می‌دهد. این اختلاف، خطای حل‌نشده یا کاهش کیفیت اندازه‌گیری‌شده نیست؛ حاصلِ دو فرض متفاوت دربارهٔ بیرونِ داده است.

۴. کد و تصویر ساختگی را با جمع دوبعدی مستقل بسنجیم

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

فایل separable_image_smoothing.py با Python 3.14.5 و NumPy 2.4.6 اجرا شده است. اندازهٔ هر محور یک تا شانزده است؛ مقدار نامتناهی، شدتِ خارج از بازه، شکل رنگی یا نوعِ صحیح و بولی رد می‌شوند. شعاع عدد صحیحِ یک تا سه و سیگما عدد متناهیِ یک‌چهارم تا چهار است؛ بولی جای پارامتر عددی پذیرفته نیست.

"""Finite normalized Gaussian kernel; own two-pass NumPy image filtering."""
import itertools
import json
import math
import platform
import numpy as np


def gaussian_kernel(radius, sigma):
    if type(radius) is not int or not 1 <= radius <= 3:
        raise ValueError("radius must be an actual integer in 1..3 pixels")
    # Check bounds before isfinite: a huge exact int must not overflow conversion.
    if type(sigma) not in (int, float) or not 0.25 <= sigma <= 4 or not math.isfinite(sigma):
        raise ValueError("sigma must be finite in 0.25..4 pixels, not bool")
    offsets = np.arange(-radius, radius + 1, dtype=np.float64)
    weights = np.exp(-(offsets * offsets) / (2 * sigma * sigma))
    return weights / np.sum(weights, dtype=np.float64)


def _image(image):
    if type(image) is not np.ndarray or image.dtype != np.dtype("float64"):
        raise ValueError("actual float64 ndarray required")
    if image.ndim != 2 or not all(1 <= size <= 16 for size in image.shape):
        raise ValueError("bounded two-dimensional grayscale array")
    if not np.isfinite(image).all() or np.any(image < 0) or np.any(image > 1):
        raise ValueError("finite synthetic intensities in [0,1]")


def _axis_pass(image, kernel, axis, border):
    radius = len(kernel) // 2
    padding = [(0, 0), (0, 0)]
    padding[axis] = (radius, radius)
    if border == "zero":
        padded = np.pad(image, padding, mode="constant", constant_values=0)
    else:
        padded = np.pad(image, padding, mode="edge")
    out = np.zeros_like(image, dtype=np.float64)
    for offset, weight in enumerate(kernel):
        window = [slice(None), slice(None)]
        window[axis] = slice(offset, offset + image.shape[axis])
        out += weight * padded[tuple(window)]
    return out


def gaussian_separable(image, radius=1, sigma=1.0, border="edge"):
    _image(image)
    kernel = gaussian_kernel(radius, sigma)
    if type(border) is not str or border not in ("zero", "edge"):
        raise ValueError("explicit zero or edge border only")
    horizontal = _axis_pass(image, kernel, 1, border)
    return _axis_pass(horizontal, kernel, 0, border)


def direct_scalar_reference(image, radius, sigma, border):
    """Independent direct two-dimensional sum, math kernel and index rules.

    No np.pad, np.exp, NumPy reduction or separable-filter helper is used.
    """
    offsets = list(range(-radius, radius + 1))
    weights = [math.exp(-(k * k) / (2 * sigma * sigma)) for k in offsets]
    scale = math.fsum(weights)
    weights = [w / scale for w in weights]
    values = image.tolist()
    height, width = len(values), len(values[0])
    output = []
    for row in range(height):
        line = []
        for col in range(width):
            terms = []
            for i, dy in enumerate(offsets):
                for j, dx in enumerate(offsets):
                    y, x = row + dy, col + dx
                    if border == "edge":
                        y, x = min(max(y, 0), height - 1), min(max(x, 0), width - 1)
                    value = values[y][x] if 0 <= y < height and 0 <= x < width else 0.0
                    terms.append(weights[i] * weights[j] * value)
            line.append(math.fsum(terms))
        output.append(line)
    return np.array(output, dtype=np.float64)


def _rejects(function):
    try:
        function()
    except ValueError:
        return True
    return False


def _close(a, b):
    return bool(np.allclose(a, b, rtol=1e-12, atol=1e-12))


def run_example():
    image = np.array([[0, .25, .5], [.75, 1, .5], [.25, 0, .75]], dtype=np.float64)
    kernel = gaussian_kernel(1, 1.0)
    edge = gaussian_separable(image, border="edge")
    zero = gaussian_separable(image, border="zero")
    checks = {}
    checks["finite_kernel_is_positive_symmetric_and_normalized"] = bool(np.all(kernel > 0) and np.array_equal(kernel, kernel[::-1]) and abs(float(kernel.sum()) - 1) < 1e-15)
    checks["hand_radius_one_sigma_one_coefficients"] = _close(kernel, [.274068619061197, .451862761877606, .274068619061197])
    impulse = np.zeros((3, 3), dtype=np.float64); impulse[1, 1] = 1.0
    checks["central_impulse_is_outer_product_of_hand_weights"] = _close(gaussian_separable(impulse, border="zero"), np.outer(kernel, kernel))
    constant = np.ones((3, 3), dtype=np.float64)
    c_edge = gaussian_separable(constant, border="edge")
    c_zero = gaussian_separable(constant, border="zero")
    checks["replicated_edge_preserves_constant_field"] = _close(c_edge, constant)
    expected_corner = float((kernel[0] + kernel[1]) ** 2)
    checks["zero_border_darkens_corner_without_renormalizing"] = math.isclose(float(c_zero[0, 0]), expected_corner, abs_tol=1e-14) and c_zero[0, 0] < 1 and math.isclose(float(c_zero[1, 1]), 1, abs_tol=1e-14)
    single = np.ones((1, 1), dtype=np.float64)
    checks["single_pixel_uses_declared_border_in_both_axes"] = _close(gaussian_separable(single, border="edge"), single) and math.isclose(float(gaussian_separable(single, border="zero")[0, 0]), float(kernel[1] ** 2), abs_tol=1e-14)
    checks["example_direct_scalar_matches_both_borders"] = all(_close(gaussian_separable(image, border=mode), direct_scalar_reference(image, 1, 1.0, mode)) for mode in ("zero", "edge"))
    checks["isotropic_filter_commutes_with_transpose"] = _close(gaussian_separable(image.T), edge.T)
    other = image[::-1, ::-1].copy()
    checks["linear_mixture_within_valid_intensity_domain"] = _close(gaussian_separable((image + other) / 2), (gaussian_separable(image) + gaussian_separable(other)) / 2)
    checks["positive_weights_keep_finite_output_range"] = bool(np.isfinite(edge).all() and np.isfinite(zero).all() and edge.min() >= -1e-15 and edge.max() <= 1 + 1e-15 and zero.min() >= -1e-15 and zero.max() <= 1 + 1e-15)
    checks["image_dtype_shape_and_size_contract"] = all(_rejects(lambda bad=bad: gaussian_separable(bad)) for bad in [image.tolist(), image.astype(np.float32), image.astype(np.uint8), image.astype(bool), image.ravel(), image[:, :, None], np.empty((0, 3)), np.zeros((17, 3))])
    checks["nonfinite_or_out_of_range_intensities_rejected"] = all(_rejects(lambda bad=bad: gaussian_separable(np.array([[bad]], dtype=np.float64))) for bad in [float("nan"), float("inf"), -0.001, 1.001])
    checks["radius_sigma_actual_types_and_bounds"] = all(_rejects(lambda r=r: gaussian_separable(image, radius=r)) for r in [True, 0, 4, 1.0]) and all(_rejects(lambda s=s: gaussian_separable(image, sigma=s)) for s in [True, 0, .249, 4.001, 10 ** 400, -(10 ** 400), float("nan"), float("inf"), "1"]) and not _rejects(lambda: gaussian_separable(np.ones((16, 16), dtype=np.float64), radius=3, sigma=4.0)) and not _rejects(lambda: gaussian_separable(image, radius=3, sigma=.25))
    checks["border_name_requires_explicit_supported_policy"] = all(_rejects(lambda mode=mode: gaussian_separable(image, border=mode)) for mode in [True, "reflect", "wrap", None])
    before = image.copy()
    out = gaussian_separable(image)
    out[0, 0] = 0
    checks["caller_image_unchanged_and_output_private"] = bool(np.array_equal(before, image) and not np.shares_memory(out, image))
    oracle_cases = 0
    oracle_ok = True
    max_error = 0.0
    for height, width, radius, sigma, mode in itertools.product(range(1, 5), range(1, 5), [1, 2], [.5, 1.0, 2.0], ["zero", "edge"]):
        fixture = ((np.arange(height * width, dtype=np.float64).reshape(height, width) * 7 + 3) % 17) / 16
        actual = gaussian_separable(fixture, radius, sigma, mode)
        reference = direct_scalar_reference(fixture, radius, sigma, mode)
        max_error = max(max_error, float(np.max(np.abs(actual - reference))))
        oracle_ok &= _close(actual, reference)
        oracle_cases += 1
    checks["independent_direct_two_dimensional_reference_grid"] = bool(oracle_ok and oracle_cases == 192)
    assert all(type(ok) is bool and ok for ok in checks.values()), checks
    return {"python": platform.python_version(), "dependencies": {"numpy": np.__version__, "opencv_executed": False, "scipy_executed": False},
            "example": {"synthetic_dimensionless_grayscale": image.tolist(), "sigma_pixels": 1.0, "radius_pixels": 1,
                        "normalized_finite_kernel": kernel.tolist(), "edge_output": edge.tolist(), "zero_output": zero.tolist(),
                        "constant_field_zero_corner": float(c_zero[0, 0]), "constant_field_edge_corner": float(c_edge[0, 0]),
                        "single_pixel_zero_value": float(gaussian_separable(single, border="zero")[0, 0]),
                        "theoretical_multiplications_per_pixel": {"direct": 9, "separable": 6, "direct_assumes_precomputed_outer_kernel": True, "measured_runtime": False}},
            "oracle_cases_inside_one_group": oracle_cases, "max_reference_abs_error": max_error, "checks": checks,
            "limitations": ["Finite sampled/truncated/discretely normalized Gaussian, not continuous integral or default library parameters",
                "Own NumPy two-pass implementation, no OpenCV/SciPy execution or API conformance claim",
                "Theoretical multiplication count excludes padding, allocations, memory and overhead; no speed/image-quality/OCR/medical result claim"]}


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

شانزده گروه بررسی اجرا شده‌اند. مرجع مستقل با math.exp و جمع دقیق‌ترِ math.fsum وزن‌ها را می‌سازد و هر همسایگی دوبعدی را مستقیماً می‌پیماید. بیرون تصویر را با صفر یا محدودکردن مختصات به لبه می‌خواند؛ تابع‌های padding و گذرهای مسیر اصلی را صدا نمی‌زند.

۱۹۲ ترکیبِ شکل کوچک، شعاع، سیگما و مرز در یک گروه با مرجع مقایسه شده‌اند. تلورانس مطلق و نسبی یک‌تریلیونیم است و بیشترین اختلافِ ثبت‌شده حدود ۳٫۳۳ ضرب‌در ده به توان منفی شانزده بود. این تلورانس، معیار کیفیت تصویر نیست. پاسخ ضربه، تصویر ثابت، تک‌پیکسل، جابه‌جایی سطر و ستون و دست‌نخوردن ورودی نیز جدا بررسی می‌شوند.

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

۵. تعداد ضرب نظری را زمان پاسخ یا کیفیت ننامیم

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

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

{
  "python": "3.14.5",
  "dependencies": {
    "numpy": "2.4.6",
    "opencv_executed": false,
    "scipy_executed": false
  },
  "example": {
    "synthetic_dimensionless_grayscale": [
      [
        0.0,
        0.25,
        0.5
      ],
      [
        0.75,
        1.0,
        0.5
      ],
      [
        0.25,
        0.0,
        0.75
      ]
    ],
    "sigma_pixels": 1.0,
    "radius_pixels": 1,
    "normalized_finite_kernel": [
      0.274068619061197,
      0.45186276187760605,
      0.274068619061197
    ],
    "edge_output": [
      [
        0.274068619061197,
        0.39921625833031416,
        0.48781805120028443
      ],
      [
        0.43837457696174725,
        0.5026124722322863,
        0.5553242483876748
      ],
      [
        0.356073958742355,
        0.4166884242026989,
        0.5698233908814423
      ]
    ],
    "zero_output": [
      [
        0.1989550111070855,
        0.3306991035650149,
        0.2700846381046711
      ],
      [
        0.3267151226084889,
        0.5026124722322863,
        0.3995115368685484
      ],
      [
        0.21903964921175653,
        0.3415748162485874,
        0.2901692762093421
      ]
    ],
    "constant_field_zero_corner": 0.5269763698317176,
    "constant_field_edge_corner": 1.0,
    "single_pixel_zero_value": 0.2041799555716581,
    "theoretical_multiplications_per_pixel": {
      "direct": 9,
      "separable": 6,
      "direct_assumes_precomputed_outer_kernel": true,
      "measured_runtime": false
    }
  },
  "oracle_cases_inside_one_group": 192,
  "max_reference_abs_error": 3.3306690738754696e-16,
  "checks": {
    "finite_kernel_is_positive_symmetric_and_normalized": true,
    "hand_radius_one_sigma_one_coefficients": true,
    "central_impulse_is_outer_product_of_hand_weights": true,
    "replicated_edge_preserves_constant_field": true,
    "zero_border_darkens_corner_without_renormalizing": true,
    "single_pixel_uses_declared_border_in_both_axes": true,
    "example_direct_scalar_matches_both_borders": true,
    "isotropic_filter_commutes_with_transpose": true,
    "linear_mixture_within_valid_intensity_domain": true,
    "positive_weights_keep_finite_output_range": true,
    "image_dtype_shape_and_size_contract": true,
    "nonfinite_or_out_of_range_intensities_rejected": true,
    "radius_sigma_actual_types_and_bounds": true,
    "border_name_requires_explicit_supported_policy": true,
    "caller_image_unchanged_and_output_private": true,
    "independent_direct_two_dimensional_reference_grid": true
  },
  "limitations": [
    "Finite sampled/truncated/discretely normalized Gaussian, not continuous integral or default library parameters",
    "Own NumPy two-pass implementation, no OpenCV/SciPy execution or API conformance claim",
    "Theoretical multiplication count excludes padding, allocations, memory and overhead; no speed/image-quality/OCR/medical result claim"
  ]
}

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

آستانه‌گذاری Otsu و ناحیه‌های متصل ماسک مراحل متفاوت‌اند. این فیلتر آستانه، برچسب عیب یا ناحیهٔ جسم تولید نمی‌کند. منابع در ۲۵ مهر ۱۴۰۵ خوانده شدند؛ نسخه‌های مستندات OpenCV 4.13.0، NumPy 2.4 و SciPy 1.18.0 از کتابخانهٔ واقعاً اجراشده جدا ثبت شده‌اند.

پرسش اول: چرا تصویر ثابت با مرز صفر گوشهٔ تیره‌تر می‌دهد؟ پاسخ: بخشی از وزن‌ها روی مقدار صفرِ بیرون تصویر می‌افتد و وزنِ داخل دوباره نرمال نمی‌شود؛ گوشه تقریباً ۰٫۵۲۶۹۷۶ است.

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

پرسش سوم: آیا شش ضرب حتماً زمان کمتر از نه ضرب می‌دهد؟ پاسخ: از این مدل نظری نمی‌توان تضمین کرد؛ هزینه‌های حافظه و آماده‌سازی حذف شده‌اند و آزمون زمان واقعی نداریم.

پرسش چهارم: آیا تطابق دو روش، بهترشدن OCR یا تشخیص عیب را ثابت می‌کند؟ پاسخ: خیر؛ هیچ متن، دوربین یا برچسب عیب نداریم. فقط یک محاسبهٔ عددی با فرض مرزی یکسان بررسی شده است.

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

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