Практикум: Система оптического контроля качества деталей

Сквозной практический курс по разработке промышленной системы машинного зрения для бесконтактного контроля качества. Вы объедините методы калибровки, геометрического выравнивания, субпиксельной метрологии и контурного анализа в единый инспекционный конвейер для автоматической отбраковки изделий.

Архитектура системы оптического контроля: калибровка масштаба и модель инспекции

Архитектура системы оптического контроля: калибровка масштаба и модель инспекции

На высокоскоростной сборочной линии погрешность в 55 пикселей при измерении диаметра прецизионного подшипника может означать разницу между исправным узлом и заклиниванием механизма. Камера машинного зрения оперирует исключительно безразмерными дискретными массивами яркостей, где нет понятий «миллиметр», «шероховатость» или «технологический допуск». Превращение матрицы чисел в метрологически достоверный вердикт — задачу систем автоматической оптической инспекции (Automated Optical Inspection, AOI) — обеспечивает математически выверенная архитектура калибровки и формальная модель инспекции.

Любая промышленная AOI-система решает две фундаментальные задачи: переводит пиксельные координаты в физические единицы измерений рабочего поля и сопоставляет извлеченные геометрические характеристики с эталонной технологической картой.

Архитектура промышленного пайплайна инспекции

В отличие от исследовательских скриптов обработки изображений, промышленная система оптического контроля работает в жестких временных рамках конвейера. Каждый этап пайплайна выполняет изолированную функцию преобразования данных: от захвата фотонов до отправки сигнала в промышленный контроллер (PLC).

Поток данных внутри инспекционного модуля организован в виде последовательного детерминированного графа:

[Захват кадра]
       │
       ▼
[Предобработка] ──────────► Шумоподавление, нормализация освещения
       │
       ▼
[Калибровка и масштабирование] ──► Перевод координат px -> мм
       │
       ▼
[Локализация и сегментация] ───► Выделение ROI и контуров детали
       │
       ▼
[Измерительный блок] ────► Расчет метрик (габариты, моменты, дефекты)
       │
       ▼
[Модель валидации (Decision Engine)] ──► Проверка допусков (PASS / FAIL)
       │
       ▼
[Телеметрия и визуализация] ──► Вывод оверлея, логирование брака

Качественная реализация требует отделения этапа калибровки пространственного масштаба от самого процесса контроля: коэффициент пересчета должен быть вычислен заранее по стабильному калибровочному эталону.

Калибровка масштаба: переход от пикселей к миллиметрам

Матрица камеры фиксирует проекцию трехмерного объекта на двухмерную плоскость светочувствительных ячеек. Если оптическая ось камеры строго перпендикулярна рабочей плоскости конвейера (телецентрическая схема или съемка с фиксированной дистанции без перспективного скоса), соотношение между размерами в пикселях и реальными миллиметрами описывается линейным коэффициентом масштабирования.

Коэффициент пространственного разрешения (Scale Factor, kscalek_{\text{scale}}) — физическая величина, показывающая, сколько миллиметров реального пространства приходится на один пиксель дискретной матрицы в плоскости фокусировки:

kscale=DrealDpxk_{\text{scale}} = \frac{D_{\text{real}}}{D_{\text{px}}}

где DrealD_{\text{real}} — известный физический размер калибровочного маркера в миллиметрах, а DpxD_{\text{px}} — измеренный размер маркера на матрице в пикселях.

Обратная величина называется плотностью пикселей на единицу длины (Pixels Per Metric, PPM\text{PPM}):

PPM=1kscale=DpxDreal\text{PPM} = \frac{1}{k_{\text{scale}}} = \frac{D_{\text{px}}}{D_{\text{real}}}

Практический пример калибровки

Представим калибровочную мишень — прецизионный керамический диск с сертифицированным диаметром Dreal=25.00 ммD_{\text{real}} = 25.00 \text{ мм}. При съемке на инспекционной позиции контур диска после бинаризации и поиска минимальной описанной окружности дает диаметр Dpx=500.0 pxD_{\text{px}} = 500.0 \text{ px}.

  1. Масштабный коэффициент:

    kscale=25.00500.0=0.05 мм/pxk_{\text{scale}} = \frac{25.00}{500.0} = 0.05 \text{ мм/px}

  2. Плотность пикселей:

    PPM=500.025.00=20.0 px/мм\text{PPM} = \frac{500.0}{25.00} = 20.0 \text{ px/мм}

Если на проверяемой детали алгоритм измерил длину стороны Lpx=342.0 pxL_{\text{px}} = 342.0 \text{ px}, ее физический размер рассчитывается простым произведением:

Lreal=Lpx×kscale=342.0×0.05=17.10 ммL_{\text{real}} = L_{\text{px}} \times k_{\text{scale}} = 342.0 \times 0.05 = 17.10 \text{ мм}

Для двумерных характеристик (площадь пятна дефекта, общая площадь детали) коэффициент масштабируется квадратично: Sreal=Spx×(kscale)2S_{\text{real}} = S_{\text{px}} \times (k_{\text{scale}})^2.

Ниже представлен модуль автоматической калибровки по круглому эталону:

from dataclasses import dataclass
import cv2
import numpy as np

@dataclass(frozen=True)
class CalibrationResult:
    scale_factor_mm_per_px: float
    pixels_per_mm: float
    reference_center_px: tuple[float, float]
    reference_radius_px: float

class ScaleCalibrator:
    """Модуль вычисления пространственного разрешения по круглому калибровочному маркеру."""

    def __init__(self, target_diameter_mm: float):
        if target_diameter_mm <= 0:
            raise ValueError("Диаметр эталона должен быть положительным числом.")
        self.target_diameter_mm = target_diameter_mm

    def calibrate(self, calibration_frame: np.ndarray) -> CalibrationResult:
        """Находит эталонный круг на кадре и рассчитывает масштабный коэффициент."""
        if calibration_frame is None or calibration_frame.size == 0:
            raise ValueError("Получен пустой кадр калибровки.")

        # Приведение к полутоновому виду и подавление шумов
        if len(calibration_frame.shape) == 3:
            gray = cv2.cvtColor(calibration_frame, cv2.COLOR_BGR2GRAY)
        else:
            gray = calibration_frame

        blurred = cv2.GaussianBlur(gray, (7, 7), 1.5)

        # Бинаризация методом Оцу для уверенного отделения маркера от фона
        _, binary = cv2.threshold(
            blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
        )

        # Морфологическая очистка мелких артефактов
        kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
        cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)

        # Поиск внешних контуров
        contours, _ = cv2.findContours(
            cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
        )
        if not contours:
            raise RuntimeError("Калибровочный маркер не обнаружен на изображении.")

        # Выбираем наибольший контур как целевую мишень
        target_cnt = max(contours, key=cv2.contourArea)

        # Оценка круглости мишени для исключения бликов и частичных перекрытий
        area = cv2.contourArea(target_cnt)
        perimeter = cv2.arcLength(target_cnt, True)
        if perimeter == 0:
            raise RuntimeError("Контур эталона имеет нулевой периметр.")

        circularity = 4 * np.pi * (area / (perimeter * perimeter))
        if circularity < 0.85:
            raise RuntimeError(
                f"Маркер деформирован или перекрыт (circularity = {circularity:.2f} < 0.85)"
            )

        # Определение параметров минимальной описанной окружности
        (cx, cy), radius_px = cv2.minEnclosingCircle(target_cnt)
        diameter_px = radius_px * 2.0

        if diameter_px <= 0:
            raise RuntimeError("Некорректный диаметр описанной окружности.")

        scale_factor = self.target_diameter_mm / diameter_px
        ppm = diameter_px / self.target_diameter_mm

        return CalibrationResult(
            scale_factor_mm_per_px=scale_factor,
            pixels_per_mm=ppm,
            reference_center_px=(float(cx), float(cy)),
            reference_radius_px=float(radius_px),
        )

Модель инспекции и допусковый контроль

Получение физических размеров детали — только половина процесса. Система контроля качества должна сопоставить измеренные величины с допусками конструкторской документации.

Поля допусков: номинал, USL и LSL

Каждый контролируемый параметр XX (длина, ширина, диаметр отверстия, округлость) характеризуется технологическим коридором допустимых значений:

Параметр допуска Обозначение Описание
Номинальное значение Nominal\text{Nominal} Идеальный расчетный размер по чертежу
Нижний предел допуска LSL\text{LSL} (Lower Spec Limit) Минимально допустимый размер детали (Nominalδ\text{Nominal} - \delta_-)
Верхний предел допуска USL\text{USL} (Upper Spec Limit) Максимально допустимый размер детали (Nominal+δ+\text{Nominal} + \delta_+)

Решение о статусе параметра формируется по строгому условию:

LSLXmeasuredUSL    PASS\text{LSL} \leq X_{\text{measured}} \leq \text{USL} \implies \text{PASS}

Если Xmeasured<LSLX_{\text{measured}} < \text{LSL} или Xmeasured>USLX_{\text{measured}} > \text{USL}, изделие признается браком (FAIL\text{FAIL}) с фиксацией конкретного нарушения.

Структурирование спецификации изделия

Чтобы инспекционный конвейер оставался гибким и мог переключаться между разными типами деталей без правки кода алгоритмов, параметры контроля инкапсулируются в декларативные структуры данных.

from dataclasses import dataclass
from enum import Enum
from typing import Optional

class InspectionStatus(Enum):
    PASS = "PASS"
    FAIL = "FAIL"
    UNCERTAIN = "UNCERTAIN"

@dataclass(frozen=True)
class ToleranceSpec:
    """Числовой интервал допуска для одного геометрического параметра."""

    name: str
    nominal: float
    lower_tolerance: float  # delta отрицательная или нулевая (величина смещения вниз)
    upper_tolerance: float  # delta положительная или нулевая (величина смещения вверх)
    unit: str = "мм"

    @property
    def lsl(self) -> float:
        return self.nominal - abs(self.lower_tolerance)

    @property
    def usl(self) -> float:
        return self.nominal + abs(self.upper_tolerance)

    def validate(self, value: float) -> tuple[bool, float]:
        """Возвращает флаг соответствия допуску и величину отклонения от номинала."""
        deviation = value - self.nominal
        is_valid = self.lsl <= value <= self.usl
        return is_valid, deviation

@dataclass
class ParameterEvaluation:
    spec: ToleranceSpec
    measured_value: float
    deviation: float
    is_valid: bool

@dataclass
class InspectionReport:
    status: InspectionStatus
    details: list[ParameterEvaluation]
    summary_message: str

class InspectionEngine:
    """Исполнительный модуль сопоставления измерений с технологической картой."""

    def __init__(self, specs: list[ToleranceSpec]):
        self.specs = {spec.name: spec for spec in specs}

    def evaluate(self, measurements: dict[str, float]) -> InspectionReport:
        evaluations: list[ParameterEvaluation] = []
        overall_pass = True
        failed_params: list[str] = []

        for param_name, spec in self.specs.items():
            if param_name not in measurements:
                overall_pass = False
                failed_params.append(f"{param_name} (не измерен)")
                continue

            val = measurements[param_name]
            is_valid, deviation = spec.validate(val)
            evaluations.append(
                ParameterEvaluation(
                    spec=spec,
                    measured_value=val,
                    deviation=deviation,
                    is_valid=is_valid,
                )
            )

            if not is_valid:
                overall_pass = False
                failed_params.append(
                    f"{param_name}: {val:.3f}{spec.unit} вне [{spec.lsl:.3f}, {spec.usl:.3f}]"
                )

        if overall_pass:
            status = InspectionStatus.PASS
            summary = "Все параметры в пределах технологического допуска."
        else:
            status = InspectionStatus.FAIL
            summary = "Брак: " + "; ".join(failed_params)

        return InspectionReport(
            status=status, details=evaluations, summary_message=summary
        )

Сквозной пример: интеграция калибратора и модели валидации

Объединим калибровку масштаба и допусковый контроль в единый метрологический модуль. Он принимает сырые измерения в пикселях, преобразует их в физические величины с использованием масштабного коэффициента и формирует структурированный отчет.

# 1. Задаем чертежные требования к детали (например, стальной фланец)
flange_specs = [
    ToleranceSpec(
        name="outer_diameter",
        nominal=80.0,
        lower_tolerance=0.2,
        upper_tolerance=0.2,
    ),
    ToleranceSpec(
        name="inner_hole_diameter",
        nominal=25.0,
        lower_tolerance=0.1,
        upper_tolerance=0.1,
    ),
    ToleranceSpec(
        name="wall_thickness",
        nominal=27.5,
        lower_tolerance=0.25,
        upper_tolerance=0.25,
    ),
]

engine = InspectionEngine(flange_specs)

# 2. Результат предварительной калибровки по эталону
# Допустим, масштабный коэффициент k_scale = 0.08 мм/px
scale_factor = 0.08

# 3. Необработанные измерения с контурного детектора в пикселях
raw_measurements_px = {
    "outer_diameter": 1003.5,  # 1003.5 * 0.08 = 80.28 мм (номинал 80.0 +- 0.2 -> FAIL)
    "inner_hole_diameter": 312.0,  # 312.0 * 0.08 = 24.96 мм (номинал 25.0 +- 0.1 -> PASS)
    "wall_thickness": 344.0,  # 344.0 * 0.08 = 27.52 мм (номинал 27.5 +- 0.25 -> PASS)
}

# 4. Пересчет в физические величины
physical_measurements = {
    name: px_val * scale_factor for name, px_val in raw_measurements_px.items()
}

# 5. Вынесение метрологического вердикта
report = engine.evaluate(physical_measurements)

print(f"Итоговый вердикт: {report.status.value}")
print(f"Отчет: {report.summary_message}")
for item in report.details:
    status_str = "OK" if item.is_valid else "FAIL"
    print(
        f" - {item.spec.name}: факт={item.measured_value:.3f} {item.spec.unit} "
        f"(отклонение {item.deviation:+.3f} {item.spec.unit}) -> {status_str}"
    )

Вывод программы наглядно демонстрирует причину отбраковки:

Итоговый вердикт: FAIL
Отчет: Брак: outer_diameter: 80.280мм вне [79.800, 80.200]
 - outer_diameter: факт=80.280 мм (отклонение +0.280 мм) -> FAIL
 - inner_hole_diameter: факт=24.960 мм (отклонение -0.040 мм) -> OK
 - wall_thickness: факт=27.520 мм (отклонение +0.020 мм) -> OK

Такая структура изолирует логику принятия решений от алгоритмов обработки изображений. В следующих главах мы наполним этот каркас конкретными методами компьютерного зрения: алгоритмами локализации детали на конвейере, субпиксельным измерением контуров и выявлением микродефектов поверхности.

Локализация и геометрическая нормализация детали на конвейере

Локализация и геометрическая нормализация детали на конвейере

На реальной производственной линии детали никогда не перемещаются в идеальном положении: вибрация конвейера, зазоры направляющих и инерция приводят к тому, что каждое изделие поступает в зону видимости камеры со случайным смещением по осям X,YX, Y и произвольным углом разворота θ\theta. Если попытаться измерить деталь с допуском ±0.05\pm 0.05 мм непосредственно на исходном кадре, даже незначительный наклон в 33^\circ превратит прямолинейный край в ступенчатую диагональ, исказит проекцию габаритов и сделает шаблонное сравнение невозможным.

Задача этапа локализации и нормализации — обнаружить изделие на сыром изображении, точно оценить параметры его пространственной позы (xc,yc,θ)(x_c, y_c, \theta) и перенести объект в каноническую систему координат. В каноническом представлении деталь всегда отцентрирована, ориентирована вдоль главных осей и обрезана до стандартизированного размера.

Устойчивая сегментация и фильтрация кандидатов

Прежде чем вычислять геометрические параметры, необходимо отделить металлическую или пластиковую деталь от ленты конвейера, исключив блики, капли смазки и технологическую пыль.

Промышленный алгоритм сегментации состоит из четырёх последовательных шагов:

  1. Фотометрическое выравнивание: устранение неравномерности освещения с помощью морфологического фильтра Top-Hat или Black-Hat (в зависимости от того, светлее деталь фона или темнее).
  2. Адаптивная или глобальная бинаризация по Оцу: получение черновой бинарной маски переднего плана.
  3. Морфологическая санация: операция закрытия (MORPH_CLOSE) крупным ядром для затягивания внутренних бликов на поверхности металла и операция открытия (MORPH_OPEN) для удаления мелкого сора на ленте.
  4. Топологическая фильтрация контуров: поиск связных компонент через cv2.findContours с отсевом по критериям площади (SminAreaSmaxS_{\min} \leq \text{Area} \leq S_{\max}) и плотности заполнения выпуклой оболочки (Solidity0.85\text{Solidity} \geq 0.85).
import cv2
import numpy as np

def segment_part(
    gray_frame: np.ndarray,
    min_area: float = 15000.0,
    max_area: float = 500000.0,
    min_solidity: float = 0.88,
) -> np.ndarray:
    """Выполняет устойчивую бинаризацию и топологическую фильтрацию детали."""
    # 1. Сглаживание и выравнивание освещения
    blurred = cv2.GaussianBlur(gray_frame, (5, 5), 0)
    kernel_bg = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 25))
    tophat = cv2.morphologyEx(blurred, cv2.MORPH_TOPHAT, kernel_bg)

    # 2. Бинаризация по алгоритму Оцу
    _, binary = cv2.threshold(
        tophat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
    )

    # 3. Морфологическая очистка маски
    kernel_clean = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
    clean_mask = cv2.morphologyEx(
        binary, cv2.MORPH_CLOSE, kernel_clean, iterations=2
    )
    clean_mask = cv2.morphologyEx(
        clean_mask, cv2.MORPH_OPEN, kernel_clean, iterations=1
    )

    # 4. Поиск и многокритериальный отбор целевого контура
    contours, _ = cv2.findContours(
        clean_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )
    for cnt in contours:
        area = cv2.contourArea(cnt)
        if not (min_area <= area <= max_area):
            continue

        hull = cv2.convexHull(cnt)
        hull_area = cv2.contourArea(hull)
        if hull_area == 0:
            continue

        solidity = area / hull_area
        if solidity >= min_solidity:
            return cnt

    return None

Если функция вернула валидный контур, система переходит к расчету параметров позы.


Оценка позы изделия: центроид, главные оси и угловая неоднозначность

Поза объекта на плоскости задаётся вектором (xc,yc,θ)(x_c, y_c, \theta), где (xc,yc)(x_c, y_c) — координаты центра масс, а θ\theta — угол ориентации продольной оси детали относительно горизонтальной оси XX.

Расчет центроида через пространственные моменты

Координаты центра тяжести вычисляются через отношение моментов первого и нулевого порядков:

xc=m10m00,yc=m01m00x_c = \frac{m_{10}}{m_{00}}, \quad y_c = \frac{m_{01}}{m_{00}}

Здесь m00m_{00} — нулевой момент, численно равный площади бинарной фигуры в пикселях, а m10m_{10} и m01m_{01} — суммы координат всех точек объекта по горизонтали и вертикали соответственно.

Определение главной оси инерции

Для поиска угла поворота θ\theta можно использовать cv2.minAreaRect, однако минимальный ограничивающий прямоугольник чувствителен к мелким заусенцам на кромке. Более математически устойчивый подход — метод главных осей инерции на основе центральных моментов второго порядка (μ20,μ02,μ11\mu_{20}, \mu_{02}, \mu_{11}):

θ=12atan2(2μ11,μ20μ02)\theta = \frac{1}{2} \operatorname{atan2}\left(2\mu_{11}, \mu_{20} - \mu_{02}\right)

Пояснение элементов:

  • μ20\mu_{20} и μ02\mu_{02} — центральные моменты второго порядка, характеризующие дисперсию (разброс) пикселей тела детали вдоль горизонтальной и вертикальной осей относительно центра (xc,yc)(x_c, y_c).
  • μ11\mu_{11} — центральный момент смешанного типа (ковариация распределения массы).
  • atan2(y,x)\operatorname{atan2}(y, x) — четырёхквадрантный арктангенс, возвращающий угол вектора в радианах.
  • Деление на 2 возникает из тригонометрического тождества для поворота тензора инерции.

Пример: если пластина вытянута строго под углом 4545^\circ, моменты распределены симметрично: μ20=μ02\mu_{20} = \mu_{02}, а ковариация μ11>0\mu_{11} > 0. Значение atan2(2μ11,0)=90\operatorname{atan2}(2\mu_{11}, 0) = 90^\circ, а итоговый угол θ=90/2=45\theta = 90^\circ / 2 = 45^\circ.

def extract_part_pose(contour: np.ndarray) -> tuple[tuple[float, float], float]:
    """Вычисляет центроид (xc, yc) и угол ориентации theta в градусах."""
    m = cv2.moments(contour)
    if m["m00"] == 0:
        raise ValueError("Площадь контура равна нулю")

    cx = m["m10"] / m["m00"]
    cy = m["m01"] / m["m00"]

    # Центральные моменты второго порядка
    mu20 = m["mu20"] / m["m00"]
    mu02 = m["mu02"] / m["m00"]
    mu11 = m["mu11"] / m["m00"]

    # Угол наклона главной оси инерции
    theta_rad = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    theta_deg = float(np.degrees(theta_rad))

    return (cx, cy), theta_deg

Разрешение проблемы 180180^\circ-неоднозначности

Формула моментов второго порядка и метод cv2.minAreaRect инвариантны к развороту на 180180^\circ (и на 9090^\circ для идеальных квадратов): они находят линию главной оси, но не указывают, где находится «голова», а где «хвост» несимметричной детали.

Если деталь имеет конструктивную асимметрию (например, фаску на одном конце, сдвинутое крепежное отверстие или паз):

  1. Вычисляются центральные моменты третьего порядка μ30\mu_{30} и μ03\mu_{03}, определяющие асимметрию (скос) распределения массы относительно центра.
  2. Либо после предварительного выравнивания на угол θ\theta проверяется положение ключевого элемента: если технологическое отверстие оказывается слева, а по чертежу должно быть справа, к углу принудительно добавляется 180180^\circ.

Геометрическая нормализация: аффинное выравнивание и кадрирование

После нахождения центра (xc,yc)(x_c, y_c) и корректного угла θ\theta необходимо сформировать каноническое изображение детали. Преобразование должно выполнить три действия:

  1. Переместить точку (xc,yc)(x_c, y_c) в центр целевого растра (Wtarget/2,Htarget/2)(W_{\text{target}} / 2, H_{\text{target}} / 2).
  2. Повернуть изображение на угол θ-\theta для компенсации наклона.
  3. Кадрировать область до заданных габаритов канонического шаблона (Wtarget,Htarget)(W_{\text{target}}, H_{\text{target}}).

Все три операции объединяются в единую матрицу аффинного преобразования 2×32 \times 3:

M=[αβ(1α)xdstβydst+(xdstxc)βαβxdst+(1α)ydst+(ydstyc)]M = \begin{bmatrix} \alpha & \beta & (1-\alpha) \cdot x_{\text{dst}} - \beta \cdot y_{\text{dst}} + (x_{\text{dst}} - x_c) \\ -\beta & \alpha & \beta \cdot x_{\text{dst}} + (1-\alpha) \cdot y_{\text{dst}} + (y_{\text{dst}} - y_c) \end{bmatrix}

В OpenCV эта матрица строится стандартной функцией cv2.getRotationMatrix2D с последующей корректировкой вектора смещения:

def compute_canonical_transform(
    center_src: tuple[float, float],
    angle_deg: float,
    target_size: tuple[int, int],
) -> np.ndarray:
    """Строит матрицу аффинного перехода в каноническую систему координат."""
    w_target, h_target = target_size
    center_dst = (w_target / 2.0, h_target / 2.0)

    # 1. Матрица вращения вокруг центра исходной детали
    M = cv2.getRotationMatrix2D(center_src, angle_deg, scale=1.0)

    # 2. Корректировка сдвига: совмещаем центр детали с центром целевого кадра
    M[0, 2] += center_dst[0] - center_src[0]
    M[1, 2] += center_dst[1] - center_src[1]

    return M

Выбор метода интерполяции при передискретизации

При геометрической трансформации через cv2.warpAffine критически важен выбор флага интерполяции:

Метод Флаг OpenCV Особенности и применимость
Ближайший сосед INTER_NEAREST Быстрый, но создает ступенчатые артефакты на границах. Запрещен для метрологии.
Билинейная INTER_LINEAR Оптимальный баланс между скоростью и гладкостью контуров. Подходит для предварительного анализа.
Бикубическая INTER_CUBIC Высокая точность восстановления гладких границ на основе окрестности 4×44 \times 4. Стандарт для измерительных систем.
Ланцоша INTER_LANCZOS4 Интерполяция с sinc-ядром 8×88 \times 8. Наивысшая резкость, но выше вычислительная нагрузка.

Для сохранения точности кромки при последующих измерениях используется cv2.INTER_CUBIC с режимом заполнения фона константным нулевым значением borderMode=cv2.BORDER_CONSTANT.


Архитектура производственного модуля нормализации

Объединим все шаги в отказоустойчивый класс PartNormalizer. Модуль принимает сырой полутоновый кадр, находит деталь, компенсирует разворот и возвращает структурированный объект NormalizedPart с каноническим изображением, матрицей перехода и метаданными позы.

from dataclasses import dataclass
from typing import Optional
import cv2
import numpy as np

@dataclass(frozen=True)
class PartPose:
    """Параметры пространственного положения детали."""

    center_x: float
    center_y: float
    angle_deg: float
    contour_area: float

@dataclass(frozen=True)
class NormalizedPart:
    """Результат геометрической нормализации детали."""

    canonical_image: np.ndarray
    transform_matrix: np.ndarray
    pose: PartPose
    is_valid: bool

class PartNormalizer:
    """Промышленный модуль локализации и нормализации изделий на конвейере."""

    def __init__(
        self,
        target_size: tuple[int, int],
        min_area: float = 20000.0,
        max_area: float = 600000.0,
        min_solidity: float = 0.85,
    ) -> None:
        self.target_size = target_size
        self.min_area = min_area
        self.max_area = max_area
        self.min_solidity = min_solidity

    def process_frame(self, gray_frame: np.ndarray) -> Optional[NormalizedPart]:
        """Выполняет полный цикл локализации, ориентации и выравнивания."""
        # 1. Сегментация
        contour = segment_part(
            gray_frame,
            min_area=self.min_area,
            max_area=self.max_area,
            min_solidity=self.min_solidity,
        )
        if contour is None:
            return None

        # 2. Оценка позы
        (cx, cy), angle = extract_part_pose(contour)
        area = float(cv2.contourArea(contour))
        pose = PartPose(
            center_x=cx,
            center_y=cy,
            angle_deg=angle,
            contour_area=area,
        )

        # 3. Построение аффинной матрицы выравнивания
        # Передаем отрицательный угол для компенсации наклона
        M = compute_canonical_transform(
            center_src=(cx, cy),
            angle_deg=angle,
            target_size=self.target_size,
        )

        # 4. Передискретизация кадра в каноническую систему
        canonical_img = cv2.warpAffine(
            gray_frame,
            M,
            self.target_size,
            flags=cv2.INTER_CUBIC,
            borderMode=cv2.BORDER_CONSTANT,
            borderValue=0,
        )

        return NormalizedPart(
            canonical_image=canonical_img,
            transform_matrix=M,
            pose=pose,
            is_valid=True,
        )

Сформированное каноническое изображение гарантирует пространственную повторяемость: на всех последующих этапах контроля края детали, крепежные пазы и контрольные точки будут располагаться в фиксированных пиксельных координатах независимо от того, как деталь двигалась по конвейеру.

Бесконтактная метрология: высокоточные измерения габаритов и допусков

Бесконтактная метрология: высокоточные измерения габаритов и допусков

Если камера системы технического зрения откалибрована с масштабом 0.050.05 мм/px, стандартная процедура выделения контуров неизбежно округляет координаты границы до ближайшего узла растровой сетки. Это создает квантовую неопределенность минимум в ±0.5\pm 0.5 px, что на реальной детали дает инструментальную погрешность в 0.050.05 мм. Для деталей с допусками уровня ±0.02\pm 0.02 мм (например, посадочные шейки валов или прецизионные отверстия) обычная попиксельная сегментация физически не способна отличить годное изделие от брака.

Чтобы преодолеть аппаратный барьер дискретизации матрицы, бесконтактная оптическая метрология переходит от работы с бинарными масками к субпиксельному анализу распределения непрерывной функции освещенности.

Предел целочисленного растра и физика размытия кромки

В реальной оптической системе граница объекта никогда не проецируется на матрицу в виде бесконечно резкого скачка между черным и белым. Из-за дифракции света, аберраций объектива и антиалиасинговых фильтров оптический переход «деталь — фон» формирует плавный градиент яркости шириной в 252\text{--}5 пикселей. Этот эффект описывается функцией рассеяния точки (Point Spread Function, PSF).

Вместо того чтобы бороться с оптическим размытием, прецизионные алгоритмы используют его как источник информации: промежуточные значения яркости между 00 и 255255 несут в себе точные сведения о том, где именно внутри пикселя прошла физическая граница материала.

Яркость пикселей:  [ 255 | 240 | 175 |  60  |  15  |  0  ]
                         \     \     /     /
                     Истинная кромка где-то здесь

Виртуальные измерительные щупы (1D Measurement Rakes)

Вместо обхода всех граничных точек канонического растра детали метрологический пайплайн использует направленные измерительные линии — щупы (Measurement Rakes / Calipers). Щуп представляет собой отрезок заданной длины, ориентированный строго перпендикулярно ожидаемому положению кромки.

Работа виртуального щупа разбивается на три этапа:

  1. Трассировка луча: формирование нормализованного одномерного вектора координат с шагом в 11 пиксель (или субпиксельным шагом) вдоль линии измерения.
  2. Билинейная интерполяция профиля яркости: сэмплирование значений освещенности I(s)I(s) вдоль луча с подавлением локального шума усреднением по нормали к траектории.
  3. Численное дифференцирование: расчет первой производной профиля I(s)I'(s) для выявления зоны максимального градиента.
import numpy as np
import cv2

def sample_profile_1d(image: np.ndarray,
                      p_start: tuple[float, float],
                      p_end: tuple[float, float],
                      num_samples: int = 100) -> tuple[np.ndarray, np.ndarray]:
    """
    Извлекает 1D профиль яркости вдоль отрезка с помощью билинейной интерполяции.
    """
    x_coords = np.linspace(p_start[0], p_end[0], num_samples, dtype=np.float32)
    y_coords = np.linspace(p_start[1], p_end[1], num_samples, dtype=np.float32)

    # Сэмплирование с интерполяцией через remap
    map_x = x_coords.reshape(1, -1)
    map_y = y_coords.reshape(1, -1)
    profile = cv2.remap(image, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)

    return np.column_stack((x_coords, y_coords)), profile.flatten().astype(np.float64)

Субпиксельная локализация границы: параболический экстремум

Пусть вдоль траектории щупа получен дискретный вектор градиента яркости gi=I(si+1)I(si1)/2g_i = |I(s_{i+1}) - I(s_{i-1})| / 2. Вектор gg достигает дискретного максимума в целочисленном индексе kk. Для нахождения истинного положения пика с разрешением до 0.050.10.05\text{--}0.1 пикселя окрестность точки kk аппроксимируется непрерывной параболой второго порядка по трем смежным отсчетам: (k1,gk1)(k-1, g_{k-1}), (k,gk)(k, g_k) и (k+1,gk+1)(k+1, g_{k+1}).

Положение вершины параболы xx^*, соответствующее точке истинного экстремума, вычисляется через аналитическое приравнивание производной к нулю:

x=k+gk1gk+12(gk12gk+gk+1)x^* = k + \frac{g_{k-1} - g_{k+1}}{2(g_{k-1} - 2g_k + g_{k+1})}

Где:

  • kk — целочисленный индекс пикселя с максимальным градиентом в дискретном профиле.
  • gk1,gk,gk+1g_{k-1}, g_k, g_{k+1} — значения первой производной яркости в точках k1k-1, kk и k+1k+1.
  • xx^* — непрерывная субпиксельная координата кромки вдоль измерительного луча.

Пример: если в точках k=40k=40, k=41k=41, k=42k=42 градиент составил соответственно g39=12.0g_{39} = 12.0, g40=48.0g_{40} = 48.0, g41=28.0g_{41} = 28.0, то смещение экстремума равно δ=(1228)/(2(1296+28))=16/112+0.143\delta = (12 - 28) / (2 \cdot (12 - 96 + 28)) = -16 / -112 \approx +0.143 px. Истинная кромка находится в позиции 40.14340.143 px.

def find_subpixel_edge_1d(profile: np.ndarray, coords: np.ndarray) -> tuple[float, float] | None:
    """
    Находит субпиксельную границу методом параболической аппроксимации градиента.
    """
    if len(profile) < 5:
        return None

    # 1. Численный градиент (центральные разности)
    grad = np.abs(np.gradient(profile))
    k = int(np.argmax(grad))

    # Исключаем краевые индексы
    if k == 0 or k == len(grad) - 1:
        return None

    g_prev, g_curr, g_next = grad[k - 1], grad[k], grad[k + 1]
    denom = 2.0 * (g_prev - 2.0 * g_curr + g_next)

    if abs(denom) < 1e-6:
        delta = 0.0
    else:
        delta = (g_prev - g_next) / denom
        delta = np.clip(delta, -1.0, 1.0)

    # Линейная интерполяция реальных пространственных координат (x, y)
    subpixel_idx = k + delta
    idx_floor = int(np.floor(subpixel_idx))
    idx_ceil = min(idx_floor + 1, len(coords) - 1)
    weight_ceil = subpixel_idx - idx_floor

    subpixel_pt = (1.0 - weight_ceil) * coords[idx_floor] + weight_ceil * coords[idx_ceil]
    return float(subpixel_pt[0]), float(subpixel_pt[1])

Робастная аппроксимация геометрических примитивов

Одиночный субпиксельный замер подвержен влиянию локальных дефектов (пыль, заусенец, микроцарапина). Поэтому геометрия детали восстанавливается по ансамблю из десятков щупов, распределенных по целевому контуру, с последующим вписанием идеальных аналитических примитивов: прямых и окружностей.

1. Робастное вписание прямой (Robust Line Fitting)

Прямая в нормальной форме задается уравнением:

xcosθ+ysinθd=0x \cos\theta + y \sin\theta - d = 0

Где:

  • θ\theta — угол наклона нормали к прямой.
  • dd — кратчайшее расстояние от начала координат до прямой.
  • x,yx, y — декартовы координаты точек границы.

Для подавления влияния механических заусенцев и шума применяется алгоритм M-оценок (M-estimators, cv2.fitLine с флагом cv2.DIST_HUBER или cv2.DIST_L1), который динамически снижает веса точек-выбросов.

def fit_robust_line(points: np.ndarray) -> tuple[float, float, float, float]:
    """
    Вписывает прямую в массив субпиксельных точек с подавлением выбросов (Huber loss).
    Возвращает (vx, vy, x0, y0) — направляющий вектор и опорную точку.
    """
    assert len(points) >= 2, "Необходимо минимум 2 точки для вписания прямой"
    line_params = cv2.fitLine(points.astype(np.float32), cv2.DIST_HUBER, 0, 0.01, 0.01)
    vx, vy, x0, y0 = line_params.flatten()
    return float(vx), float(vy), float(x0), float(y0)

2. Алгебраическое и геометрическое вписание окружностей

Для измерения диаметров валов и центров отверстий массив точек (xi,yi)i=1N(x_i, y_i)_{i=1}^N аппроксимируется окружностью (xxc)2+(yyc)2=R2(x - x_c)^2 + (y - y_c)^2 = R^2.

Прямое алгебраическое решение Таубина (Taubin Circle Fit) минимизирует функционал погрешности без итерационного градиентного спуска и устойчиво к случаям, когда измеряется лишь неполная дуга окружности:

def fit_circle_taubin(points: np.ndarray) -> tuple[float, float, float]:
    """
    Высокоточное алгебраическое вписание окружности методом Таубина.
    Возвращает (xc, yc, radius).
    """
    x = points[:, 0]
    y = points[:, 1]
    n = len(points)

    # Центрирование координат
    x_m = np.mean(x)
    y_m = np.mean(y)
    u = x - x_m
    v = y - y_m

    # Вычисление моментов разброса
    z = u**2 + v**2
    m_zz = np.sum(z**2) / n
    m_xz = np.sum(u * z) / n
    m_yz = np.sum(v * z) / n
    m_xx = np.sum(u**2) / n
    m_yy = np.sum(v**2) / n
    m_xy = np.sum(u * v) / n

    # Коэффициенты характеристического полинома
    c_z = m_xx * m_yy - m_xy**2
    cov_xy = m_xx * m_yy - m_xy**2
    a2 = 4.0 * cov_xy - 3.0 * m_zz
    a1 = m_zz * (m_xx + m_yy) - (m_xz**2 + m_yz**2)
    a0 = m_xz**2 * m_yy + m_yz**2 * m_xx - 2.0 * m_xz * m_yz * m_xy - m_zz * cov_xy

    # Решение системы для нахождения центра (uc, vc)
    det = 2.0 * (m_xx * m_yy - m_xy**2)
    uc = (m_xz * m_yy - m_yz * m_xy) / det
    vc = (m_yz * m_xx - m_xz * m_xy) / det

    xc = uc + x_m
    yc = vc + y_m
    radius = float(np.sqrt(uc**2 + vc**2 + (m_xx + m_yy)))

    return float(xc), float(yc), radius

Программная реализация метрологического инспектора

Объединим щупы, субпиксельную интерполяцию и вписание примитивов в комплексный модуль метрологической оценки детали.

Метрологический инсайт: Погрешность измерения геометрии падает пропорционально N\sqrt{N}, где NN — число независимых субпиксельных щупов, распределенных вдоль кромки. Сэмплирование 6464 щупов вдоль окружности уменьшает случайную дисперсию оценки радиуса в 88 раз по сравнению с единичным замером.

from dataclasses import dataclass

@dataclass(frozen=True)
class FeatureMeasurement:
    name: str
    measured_px: float
    measured_mm: float
    nominal_mm: float
    lsl_mm: float
    usl_mm: float
    is_passed: bool
    deviation_mm: float

class MetrologyEngine:
    def __init__(self, scale_factor: float):
        """
        scale_factor: мм/px, полученный на этапе пространственной калибровки.
        """
        self.scale_factor = scale_factor

    def inspect_linear_width(self,
                            image: np.ndarray,
                            left_rake_start: tuple[float, float],
                            left_rake_end: tuple[float, float],
                            right_rake_start: tuple[float, float],
                            right_rake_end: tuple[float, float],
                            nominal_mm: float,
                            tol_mm: float,
                            num_rays: int = 16) -> FeatureMeasurement:
        """
        Измеряет ширину детали между двумя противоположными кромками ансамблем щупов.
        """
        left_pts = []
        right_pts = []

        # Генерация параллельных измерительных лучей
        for offset in np.linspace(-15.0, 15.0, num_rays):
            # Смещение лучей параллельно базовой линии
            l_start = (left_rake_start[0], left_rake_start[1] + offset)
            l_end = (left_rake_end[0], left_rake_end[1] + offset)
            r_start = (right_rake_start[0], right_rake_start[1] + offset)
            r_end = (right_rake_end[0], right_rake_end[1] + offset)

            # Поиск левой кромки
            c_l, p_l = sample_profile_1d(image, l_start, l_end)
            edge_l = find_subpixel_edge_1d(p_l, c_l)
            if edge_l is not None:
                left_pts.append(edge_l)

            # Поиск правой кромки
            c_r, p_r = sample_profile_1d(image, r_start, r_end)
            edge_r = find_subpixel_edge_1d(p_r, c_r)
            if edge_r is not None:
                right_pts.append(edge_r)

        # Вписание двух параллельных прямых
        vx_l, vy_l, x0_l, y0_l = fit_robust_line(np.array(left_pts))
        vx_r, vy_r, x0_r, y0_r = fit_robust_line(np.array(right_pts))

        # Расстояние между параллельными кромками (по нормали)
        dist_px = abs((x0_r - x0_l) * vy_l - (y0_r - y0_l) * vx_l)
        dist_mm = dist_px * self.scale_factor

        lsl = nominal_mm - tol_mm
        usl = nominal_mm + tol_mm
        is_passed = (lsl <= dist_mm <= usl)

        return FeatureMeasurement(
            name="Linear_Width",
            measured_px=round(dist_px, 3),
            measured_mm=round(dist_mm, 4),
            nominal_mm=nominal_mm,
            lsl_mm=lsl,
            usl_mm=usl,
            is_passed=is_passed,
            deviation_mm=round(dist_mm - nominal_mm, 4)
        )

    def inspect_hole_diameter(self,
                              image: np.ndarray,
                              approx_center: tuple[float, float],
                              nominal_radius_px: float,
                              nominal_dia_mm: float,
                              tol_mm: float,
                              num_spokes: int = 36) -> FeatureMeasurement:
        """
        Радиальный измерительный щуп (Star-rake) для контроля диаметра отверстия.
        """
        edge_points = []
        angles = np.linspace(0, 2 * np.pi, num_spokes, endpoint=False)

        for theta in angles:
            # Луч изнутри наружу через кромку отверстия
            r_in = nominal_radius_px - 15.0
            r_out = nominal_radius_px + 15.0
            p_start = (approx_center[0] + r_in * np.cos(theta), approx_center[1] + r_in * np.sin(theta))
            p_end = (approx_center[0] + r_out * np.cos(theta), approx_center[1] + r_out * np.sin(theta))

            coords, profile = sample_profile_1d(image, p_start, p_end)
            pt = find_subpixel_edge_1d(profile, coords)
            if pt is not None:
                edge_points.append(pt)

        # Вписание окружности Таубина
        _, _, calc_radius_px = fit_circle_taubin(np.array(edge_points))
        dia_px = calc_radius_px * 2.0
        dia_mm = dia_px * self.scale_factor

        lsl = nominal_dia_mm - tol_mm
        usl = nominal_dia_mm + tol_mm
        is_passed = (lsl <= dia_mm <= usl)

        return FeatureMeasurement(
            name="Hole_Diameter",
            measured_px=round(dia_px, 3),
            measured_mm=round(dia_mm, 4),
            nominal_mm=nominal_dia_mm,
            lsl_mm=lsl,
            usl_mm=usl,
            is_passed=is_passed,
            deviation_mm=round(dia_mm - nominal_dia_mm, 4)
        )

Сравнение подходов к измерению геометрии

Метод Точность (в пикселях) Чувствительность к заусенцам Вычислительная сложность Сфера применения
Дискретные контуры (findContours) ±0.51.0 px\pm 0.5\text{--}1.0\text{ px} Высокая Минимальная Грубая отбраковка, сортировка по габаритам
1D Щуп + параболический пик ±0.050.1 px\pm 0.05\text{--}0.1\text{ px} Средняя Низкая Высокоточный контроль линейных размеров
Ансамбль щупов + робастная регрессия (M-оценки) ±0.020.05 px\pm 0.02\text{--}0.05\text{ px} Минимальная (выбросы игнорируются) Средняя Прецизионная метрология посадочных шеек и баз
Круговой щуп (Star-rake) + метод Таубина ±0.010.03 px\pm 0.01\text{--}0.03\text{ px} Низкая Средняя Контроль диаметров отверстий и соосности

Применение субпиксельных 1D щупов на каноническом растре позволяет снизить предел инструментальной погрешности системы технического зрения до значений, существенно меньших физического размера пикселя матрицы, превращая обычную промышленную камеру в метрологический прибор высокой воспроизводимости.

Многокритериальная детекция поверхностных и краевых дефектов

Многокритериальная детекция поверхностных и краевых дефектов

Деталь может идеально укладываться в заданные габаритные допуски по внешнему контуру и диаметрам отверстий, но при этом отправиться в брак из-за микротрещины шириной 0.08 мм или заусенца высотой 150 мкм в посадочном пазе. Классическая метрология по опорным линиям игнорирует локальные аномалии между измерительными щупами. Когда изделие уже приведено в каноническую систему координат, открывается возможность прямого попиксельного анализа: сравнения геометрии и текстуры детали с математически безупречным эталоном.

Главный вызов оптической дефектоскопии — отделить критический производственный брак от допустимой вариации формы и естественного оптического шума. Для этого алгоритм разделяет плоскость анализа на две независимые зоны контроля: краевую границу (периметр и кромки отверстий) и внутреннее тело детали (поверхность).


Архитектура сравнения с золотым шаблоном (Golden Template)

Метод прямого вычитания изображений (Golden Template Comparison, GTC) в каноническом растре строится на сопоставлении проверяемого кадра Itest(x,y)I_{\text{test}}(x, y) с эталонным изображением Iref(x,y)I_{\text{ref}}(x, y) либо бинарной маской номинальной геометрии Mref(x,y)M_{\text{ref}}(x, y).

Если выполнить прямое вычитание бинарных масок MtestMref\lvert M_{\text{test}} - M_{\text{ref}} \rvert, на границе детали неизбежно возникнет сплошной контур ложных срабатываний толщиной в 1–2 пикселя. Причина кроется в дискретизации растра, микровибрациях конвейера и допустимом технологическом разбросе размеров (±Δ\pm \Delta). Чтобы устранить краевой шум, формируется динамическая зона допустимой вариации — пояс допусков (Tolerance Band).

Пояс допусков делит изображение на три пространственные зоны:

  1. Зона гарантированного тела детали (Core Zone): Minner=MrefKtolM_{\text{inner}} = M_{\text{ref}} \ominus K_{\text{tol}}, где \ominus — операция морфологической эрозии со структурирующим элементом KtolK_{\text{tol}} радиуса rtolr_{\text{tol}}, соответствующего максимальному допустимому технологическому отклонению размера.
  2. Зона гарантированного фона (Background Zone): Mouter=¬(MrefKtol)M_{\text{outer}} = \neg (M_{\text{ref}} \oplus K_{\text{tol}}), где \oplus — морфологическая дилатация, а ¬\neg — инверсия.
  3. Зона игнорирования нормальных колебаний кромки (Don't Care Zone): область между внутренней и внешней границами: Mdont_care=(MrefKtol)(MrefKtol)M_{\text{dont\_care}} = (M_{\text{ref}} \oplus K_{\text{tol}}) \setminus (M_{\text{ref}} \ominus K_{\text{tol}}).

Любой пиксель переднего плана, попавший в зону MouterM_{\text{outer}}, классифицируется как избыточный материал на кромке (выступ или заусенец). Любой пиксель фона внутри MinnerM_{\text{inner}} указывает на недостаток материала (скол, раковина или непролив).


Дифференциальный анализ краевых дефектов

Краевые дефекты искажают гладкость сопряжения кромок. В зависимости от направления отклонения они делятся на два типа:

  • Заусенцы (Burrs / Protrusions) — фрагменты материала, выступающие наружу за номинальный профиль.
  • Сколы и выкрашивания (Chips / Indentations) — локальная потеря материала, углубляющаяся внутрь контура.

Для быстрого разделения этих дефектов без построения сложных полигональных деревьев применяется дифференциальная морфология над выровненной бинарной маской детали M(x,y)M(x, y).

import cv2
import numpy as np

def detect_edge_defects(mask: np.ndarray, nominal_mask: np.ndarray, tol_radius: int = 3):
    """
    Раздельная детекция заусенцев (выступов) и сколов (впадин)
    относительно номинальной маски детали с учетом радиуса допуска.
    """
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * tol_radius + 1, 2 * tol_radius + 1))

    # 1. Построение внутренней и внешней границ допуска
    inner_bound = cv2.erode(nominal_mask, kernel)
    outer_bound = cv2.dilate(nominal_mask, kernel)

    # 2. Выступы/заусенцы: материал маски, вышедший за внешний допуск
    burrs_raw = cv2.bitwise_and(mask, cv2.bitwise_not(outer_bound))

    # 3. Сколы/впадины: пустоты внутри гарантированного тела детали
    chips_raw = cv2.bitwise_and(inner_bound, cv2.bitwise_not(mask))

    # 4. Морфологическая очистка единичного шума дискретизации
    clean_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    burrs = cv2.morphologyEx(burrs_raw, cv2.MORPH_OPEN, clean_kernel)
    chips = cv2.morphologyEx(chips_raw, cv2.MORPH_OPEN, clean_kernel)

    return burrs, chips

Если номинальная векторная CAD-модель недоступна, морфологический анализ контура можно провести автономно. Для этого используется фильтрация локальной кривизны: операция морфологического замыкания MKM \bullet K затягивает узкие краевые вырезы (сколы), а размыкание MKM \circ K срезает острые выступы (заусенцы). Разностные маски сразу изолируют краевой брак:

Defectsburrs=M(MK)\text{Defects}_{\text{burrs}} = M \setminus (M \circ K)

Defectschips=(MK)M\text{Defects}_{\text{chips}} = (M \bullet K) \setminus M

где KK — калибровочное ядро, размер которого выбирается равным удвоенной ширине предельно допустимого технологического закругления кромки.


Фотометрическая дефектоскопия поверхности

Поверхностные дефекты (царапины, раковины, масляные пятна, задиры) не изменяют наружную геометрию детали, но проявляются как локальные градиенты яркости на полутоновом каноническом изображении I(x,y)I(x, y).

Прямое пороговое отсечение здесь не работает: металлическая поверхность дает плавный оптический градиент отражения (виньетирование осветителя и текстуру проката). Для извлечения аномалий применяется двухполосная пространственная фильтрация:

  1. Выделение локальных темных контрастов (Black-Hat): обнаруживает глубокие царапины, поры и трещины.
  2. Выделение локальных светлых контрастов (White Top-Hat): обнаруживает бликующие выступы и задиры.
  3. Локальная нормализация текстуры: вычитание низкочастотного гауссова фона Ibg=IGσlargeI_{\text{bg}} = I * G_{\sigma_{\text{large}}}.

После выравнивания освещенности формируется карта остаточных отклонений (residual map):

ΔI(x,y)=I(x,y)Ibg(x,y)\Delta I(x, y) = \lvert I(x, y) - I_{\text{bg}}(x, y) \rvert

Поскольку дефекты поверхности могут быть как темнее, так и светлее основного тела изделия, сегментация остаточного поля ΔI\Delta I выполняется локально-адаптивным порогом или статистическим порогом на основе стандартного отклонения σsurface\sigma_{\text{surface}} бездефектной области:

Tsurface=μresidual+kσresidualT_{\text{surface}} = \mu_{\text{residual}} + k \cdot \sigma_{\text{residual}}

Коэффициент kk (обычно в диапазоне от 3.0 до 5.0) задает чувствительность детектора согласно правилу трех сигм.

def extract_surface_anomalies(gray_roi: np.ndarray, body_mask: np.ndarray, k_sigma: float = 3.5):
    """
    Выявление фотометрических дефектов поверхности с компенсацией низкочастотного тренда.
    """
    # 1. Оценка низкочастотной фоновой освещенности
    background = cv2.GaussianBlur(gray_roi, (31, 31), 0)

    # 2. Расчет абсолютной разностной карты
    diff = cv2.absdiff(gray_roi, background)

    # 3. Расчет статистик только по телу детали (исключая фон конвейера)
    valid_pixels = diff[body_mask > 0]
    if len(valid_pixels) == 0:
        return np.zeros_like(gray_roi)

    mean_val = np.mean(valid_pixels)
    std_val = np.std(valid_pixels)
    threshold_val = mean_val + k_sigma * std_val

    # 4. Бинаризация карты аномалий
    _, anomaly_mask = cv2.threshold(diff, threshold_val, 255, cv2.THRESH_BINARY)

    # 5. Ограничение маской внутреннего тела (с отступом от кромки)
    inner_body = cv2.erode(body_mask, cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)))
    anomaly_mask = cv2.bitwise_and(anomaly_mask, anomaly_mask, mask=inner_body)

    return anomaly_mask

Многокритериальная классификация дефектов

После бинаризации краевых и поверхностных аномалий система получает бинарную маску MdefectsM_{\text{defects}}. На ней присутствуют как критические дефекты, так и пыль, единичные дефектные пиксели матрицы или допустимая микрошероховатость.

Для каждого связного компонента (Connected Component) вычисляется набор морфометрических дескрипторов:

Тип дефекта Локализация Площадь (SS) Удлиненность (Aspect Ratio\text{Aspect Ratio}) Заполненность (Solidity\text{Solidity})
Царапина (Scratch) Поверхность Средняя (>Smin> S_{\min}) Высокая (4.0\geq 4.0) Низкая / средняя (<0.6< 0.6)
Пора / Раковина (Pit) Поверхность Малая / средняя Близка к единице (0.81.20.8 - 1.2) Высокая (0.85\geq 0.85)
Заусенец (Burr) Наружная кромка Произвольная Умеренная / высокая Низкая (<0.7< 0.7)
Скол (Chip) Внутренняя кромка Произвольная Умеренная Средняя / высокая
Пыль / Шум матрицы Любая Микро (<Snoise< S_{\text{noise}}) Любая Любая

Математический расчет морфометрических параметров выполняется через связные компоненты с помощью cv2.connectedComponentsWithStats:

from dataclasses import dataclass
from typing import List

@dataclass
class DefectItem:
    defect_type: str
    centroid: tuple
    area_px: int
    area_mm2: float
    bbox: tuple
    severity: float  # Условный балл критичности

def classify_and_filter_defects(
    defect_mask: np.ndarray,
    scale_factor: float,
    min_area_px: int = 5,
    scratch_aspect_ratio: float = 3.5
) -> List[DefectItem]:
    """
    Сегментация связных компонент дефектов и их классификация по морфометрии.
    """
    num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
        defect_mask, connectivity=8
    )

    classified_defects = []

    for i in range(1, num_labels):  # 0 - фоновый класс
        area = stats[i, cv2.CC_STAT_AREA]
        if area < min_area_px:
            continue

        x = stats[i, cv2.CC_STAT_LEFT]
        y = stats[i, cv2.CC_STAT_TOP]
        w = stats[i, cv2.CC_STAT_WIDTH]
        h = stats[i, cv2.CC_STAT_HEIGHT]
        cx, cy = centroids[i]

        # Расчет геометрических метрик
        aspect_ratio = max(w, h) / (min(w, h) + 1e-5)
        area_mm2 = area * (scale_factor ** 2)

        # Классификация по форме компонента
        if aspect_ratio >= scratch_aspect_ratio:
            d_type = "SCRATCH"
            severity = area_mm2 * 1.5  # Царапины критичнее по концентрации напряжений
        else:
            d_type = "VOID_OR_STAIN"
            severity = area_mm2 * 1.0

        classified_defects.append(DefectItem(
            defect_type=d_type,
            centroid=(float(cx), float(cy)),
            area_px=int(area),
            area_mm2=float(area_mm2),
            bbox=(x, y, w, h),
            severity=float(severity)
        ))

    return classified_defects

Архитектурная сборка дефектоскопического модуля

Для интеграции в производственный конвейер создается модуль дефектоскопии DefectInspectionEngine, инкапсулирующий работу с масками допусков, расчет дифференциальных карт и сбор классифицированных дефектов в единый структурированный отчет.

from dataclasses import dataclass, field

@dataclass
class InspectionReport:
    is_surface_valid: bool
    is_edge_valid: bool
    total_defects_count: int
    defects: List[DefectItem] = field(default_factory=list)

class DefectInspectionEngine:
    def __init__(self, nominal_mask: np.ndarray, scale_factor: float, tol_px: int = 4):
        self.nominal_mask = nominal_mask
        self.scale_factor = scale_factor
        self.tol_px = tol_px

        # Предварительный расчет пояса допусков (выполняется один раз при инициализации)
        kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * tol_px + 1, 2 * tol_px + 1))
        self.inner_zone = cv2.erode(nominal_mask, kernel)
        self.outer_zone = cv2.dilate(nominal_mask, kernel)

    def inspect(self, canonical_gray: np.ndarray, canonical_mask: np.ndarray) -> InspectionReport:
        # 1. Поиск краевых дефектов
        burrs_raw = cv2.bitwise_and(canonical_mask, cv2.bitwise_not(self.outer_zone))
        chips_raw = cv2.bitwise_and(self.inner_zone, cv2.bitwise_not(canonical_mask))

        clean_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
        burrs = cv2.morphologyEx(burrs_raw, cv2.MORPH_OPEN, clean_kernel)
        chips = cv2.morphologyEx(chips_raw, cv2.MORPH_OPEN, clean_kernel)

        # 2. Поиск поверхностных дефектов
        surface_anomalies = extract_surface_anomalies(canonical_gray, self.inner_zone)

        # 3. Классификация
        edge_burrs = classify_and_filter_defects(burrs, self.scale_factor, min_area_px=6)
        for b in edge_burrs:
            b.defect_type = "BURR"

        edge_chips = classify_and_filter_defects(chips, self.scale_factor, min_area_px=6)
        for c in edge_chips:
            c.defect_type = "CHIP"

        surface_defects = classify_and_filter_defects(surface_anomalies, self.scale_factor, min_area_px=8)

        all_defects = edge_burrs + edge_chips + surface_defects

        edge_valid = (len(edge_burrs) == 0) and (len(edge_chips) == 0)
        surface_valid = len(surface_defects) == 0

        return InspectionReport(
            is_surface_valid=surface_valid,
            is_edge_valid=edge_valid,
            total_defects_count=len(all_defects),
            defects=all_defects
        )

Такая многокритериальная схема исключает пропуск локального брака: метрологический модуль проверяет макроразмеры по субпиксельным траекториям, а дефектоскопический модуль сканирует растр целиком на наличие точечных, протяженных и краевых аномалий.

Сборка инспекционного пайплайна: классификация брака, телеметрия и визуализация

Сборка инспекционного пайплайна: классификация брака, телеметрия и визуализация

На промышленном конвейере с тактом в 200 миллисекунд разрозненные алгоритмы выравнивания, субпиксельной метрологии и фотометрической дефектоскопии бесполезны сами по себе. Если система машинного зрения тратит драгоценное время на несогласованную передачу данных между модулями или выдает оператору лишь бинарный флаг «дефект обнаружен», автоматизированная линия останавливается. Производству требуется детерминированный конвейер: он принимает сырой кадр с камеры, а на выходе синхронно генерирует управляющий сигнал для промышленного контроллера (PLC), структурированный отчет для базы данных и наглядный графический оверлей для оператора с точной локализацией проблемы.

Архитектура сквозного конвейера инспекции

Инспекционный пайплайн объединяет разработанные компоненты в единую цепочку обработки с жестким контролем времени выполнения и строгой изоляцией исключений.

Поток данных строится по строго последовательному принципу:

  1. Захват и нормализация: сырой BGR-кадр проходит первичную сегментацию и аффинное выравнивание, формируя канонический растр детали.
  2. Параллельная инспекция: канонический растр одновременно поступает в метрологический модуль (для измерения линейных размеров и диаметров субпиксельными щупами) и в модуль дефектоскопии (для дифференциального анализа кромок и residual-карты поверхности).
  3. Агрегация и классификация: сводный арбитр сопоставляет геометрические размеры с полями допусков, а дефекты — с критериями допустимости, вынося итоговый вердикт.
  4. Рендеринг телеметрии и экспорт: на исходный или канонический кадр наносится графический интерфейс (HUD), а метаданные упаковываются в структурированный отчет.

Сквозной пайплайн инспекции — это программный автомат, преобразующий входной растровый массив в структурированный объект решения (InspectionVerdict), гарантируя детерминированное время обработки каждого кадра.

Реализуем единый класс InspectionPipeline, связывающий все этапы воедино:

from dataclasses import dataclass, field
from enum import Enum
from typing import List, Dict, Any, Optional
import time
import cv2
import numpy as np

class QualityGrade(Enum):
    PASS = "PASS"          # Годное изделие
    REWORK = "REWORK"      # Исправимый брак (доработка)
    SCRAP = "SCRAP"        # Неисправимый брак (утиль)

@dataclass
class DefectRecord:
    defect_type: str       # 'burr', 'chip', 'scratch', 'pore'
    contour: np.ndarray    # Контур дефекта в канонических координатах
    area_mm2: float
    centroid: tuple
    is_critical: bool

@dataclass
class InspectionResult:
    part_id: str
    timestamp: float
    grade: QualityGrade
    processing_time_ms: float
    measurements: Dict[str, Dict[str, Any]]
    defects: List[DefectRecord] = field(default_factory=list)
    reasons: List[str] = field(default_factory=list)

Трёхуровневая модель решений: Pass, Rework, Scrap

В реальном производстве разделения деталей на «годные» и «бракованные» недостаточно. Выбрасывать деталь с небольшим заусенцем на внешней кромке экономически невыгодно — ее можно отправить на станцию вторичной механической зачистки. Напротив, деталь со сквозной раковиной или заниженным внутренним диаметром восстановить невозможно.

Логика арбитража строится на физической природе несоответствий:

Категория решения Геометрические отклонения Дефекты формы и поверхности Действие исполнительного механизма
PASS (Годно) Все размеры строго внутри [LSL,USL][LSL, USL] Отсутствуют дефекты с площадью >Scrit> S_{crit} Пропуск по основному конвейеру
REWORK (Доработка) Избыток материала: размер >USL> USL (для внешних контуров) Внешние заусенцы (Burrs), допустимые царапины Пневмотолкатель №1 (накопитель доработки)
SCRAP (Утиль) Недостаток материала: размер <LSL< LSL (для внешних контуров) Сколы (Chips), внутренние поры, трещины Пневмотолкатель №2 (бункер отходов)

Правило арбитража формализуется деревом условий. Если хотя бы один параметр квалифицируется как SCRAP, общее решение детали немедленно становится SCRAP. Если критических дефектов нет, но зафиксированы исправимые отклонения, выставляется статус REWORK.

class DecisionEngine:
    def __init__(self, tolerance_rules: Dict[str, Dict[str, float]], max_rework_burr_area: float = 2.5):
        self.tolerance_rules = tolerance_rules
        self.max_rework_burr_area = max_rework_burr_area

    def evaluate(self, measurements: Dict[str, float], defects: List[DefectRecord]) -> tuple[QualityGrade, List[str]]:
        reasons = []
        is_rework = False

        # 1. Анализ геометрических размеров
        for name, value in measurements.items():
            rule = self.tolerance_rules.get(name)
            if not rule:
                continue

            lsl, usl, nom = rule["LSL"], rule["USL"], rule["nominal"]

            if value < lsl:
                reasons.append(f"Размер {name} ({value:.3f} мм) ниже LSL ({lsl:.3f} мм) — недостаток тела")
                return QualityGrade.SCRAP, reasons
            elif value > usl:
                reasons.append(f"Размер {name} ({value:.3f} мм) выше USL ({usl:.3f} мм) — избыток тела")
                is_rework = True

        # 2. Анализ дефектоскопии
        for d in defects:
            if d.is_critical:
                reasons.append(f"Критический дефект: {d.defect_type} (S = {d.area_mm2:.2f} мм²)")
                return QualityGrade.SCRAP, reasons

            if d.defect_type == "burr":
                if d.area_mm2 > self.max_rework_burr_area:
                    reasons.append(f"Крупный заусенец (S = {d.area_mm2:.2f} мм²) превышает лимит доработки")
                    return QualityGrade.SCRAP, reasons
                else:
                    reasons.append(f"Заусенец под зачистку (S = {d.area_mm2:.2f} мм²)")
                    is_rework = True
            elif d.defect_type == "chip":
                reasons.append(f"Краевой скол материала (S = {d.area_mm2:.2f} мм²)")
                return QualityGrade.SCRAP, reasons

        if is_rework:
            return QualityGrade.REWORK, reasons

        return QualityGrade.PASS, ["Все параметры в пределах спецификации"]

Графический оверлей телеметрии (Augmented Inspection Overlay)

Оператор оптического контроля должен за доли секунды понять причину отбраковки изделия. Для этого формируется графический оверлей (HUD), отображающий:

  • Цветовой статус детали: зелёный (PASS), жёлтый (REWORK), красный (SCRAP).
  • Измерительные маркеры и линейки с численными отклонениями от номинала (Δ=xxnom\Delta = x - x_{nom}).
  • Контурную подсветку дефектов с цветовой кодировкой их типа.
  • Информационную панель телеметрии (время цикла в мс, номер детали, сводный вердикт).

Рендеринг оверлея требует строгого разделения слоев: растровая графика высокого разрешения не должна мутировать исходные данные до завершения аналитических расчетов.

class TelemetryRenderer:
    COLOR_PASS = (0, 220, 0)      # Зеленый
    COLOR_REWORK = (0, 215, 255)   # Желто-оранжевый (BGR)
    COLOR_SCRAP = (30, 30, 240)    # Красный
    COLOR_PANEL_BG = (25, 25, 25)  # Темно-серый фон плашки

    @classmethod
    def render(cls, image: np.ndarray, result: InspectionResult) -> np.ndarray:
        display = image.copy()
        if len(display.shape) == 2:
            display = cv2.cvtColor(display, cv2.COLOR_GRAY2BGR)

        # 1. Отрисовка дефектов
        for d in result.defects:
            color = cls.COLOR_SCRAP if d.is_critical else cls.COLOR_REWORK
            cv2.drawContours(display, [d.contour], -1, color, 2)
            cx, cy = d.centroid
            cv2.putText(display, f"{d.defect_type}: {d.area_mm2:.1f}mm2",
                        (cx + 8, cy - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA)

        # 2. Отрисовка статусной плашки HUD
        h, w = display.shape[:2]
        cv2.rectangle(display, (0, 0), (w, 65), cls.COLOR_PANEL_BG, -1)
        cv2.line(display, (0, 65), (w, 65), (80, 80, 80), 1)

        # Цвет статуса
        status_color = cls.COLOR_PASS
        if result.grade == QualityGrade.REWORK:
            status_color = cls.COLOR_REWORK
        elif result.grade == QualityGrade.SCRAP:
            status_color = cls.COLOR_SCRAP

        # Текст телеметрии
        cv2.putText(display, f"GRADE: {result.grade.value}", (15, 42),
                    cv2.FONT_HERSHEY_DUPLEX, 1.1, status_color, 2, cv2.LINE_AA)

        info_text = f"ID: {result.part_id} | Time: {result.processing_time_ms:.1f}ms"
        cv2.putText(display, info_text, (280, 28),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.55, (200, 200, 200), 1, cv2.LINE_AA)

        reasons_text = result.reasons[0] if result.reasons else ""
        cv2.putText(display, f"Status: {reasons_text}", (280, 52),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (160, 160, 160), 1, cv2.LINE_AA)

        return display

Полная сборка инспекционного пайплайна

Соберем законченный конвейер, объединяющий геометрическую нормализацию детали, измерение внешних габаритов субпиксельными щупами, дефектоскопию поверхности и генерацию отчета.

class AOIInspectionSystem:
    def __init__(self, scale_factor: float, tolerance_rules: Dict[str, Dict[str, float]]):
        self.scale_factor = scale_factor
        self.decision_engine = DecisionEngine(tolerance_rules)

    def process_frame(self, raw_frame: np.ndarray, part_id: str) -> tuple[InspectionResult, np.ndarray]:
        t_start = time.perf_counter()

        # 1. Локализация и нормализация детали
        gray = cv2.cvtColor(raw_frame, cv2.COLOR_BGR2GRAY) if len(raw_frame.shape) == 3 else raw_frame
        blurred = cv2.GaussianBlur(gray, (5, 5), 0)
        _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        if not contours:
            duration = (time.perf_counter() - t_start) * 1000.0
            res = InspectionResult(part_id, time.time(), QualityGrade.SCRAP, duration, {}, [], ["Объект не обнаружен"])
            return res, raw_frame

        main_contour = max(contours, key=cv2.contourArea)

        # Оценка позы через центральные моменты
        M = cv2.moments(main_contour)
        if M["m00"] == 0:
            duration = (time.perf_counter() - t_start) * 1000.0
            res = InspectionResult(part_id, time.time(), QualityGrade.SCRAP, duration, {}, [], ["Нулевая площадь"])
            return res, raw_frame

        cx = M["m10"] / M["m00"]
        cy = M["m01"] / M["m00"]
        mu20, mu02, mu11 = M["mu20"], M["mu02"], M["mu11"]
        angle_rad = 0.5 * np.arctan2(2.0 * mu11, mu20 - mu02)
        angle_deg = np.degrees(angle_rad)

        # Каноническое аффинное выравнивание
        target_w, target_h = 400, 300
        rot_mat = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0)
        rot_mat[0, 2] += (target_w / 2.0 - cx)
        rot_mat[1, 2] += (target_h / 2.0 - cy)
        canonical = cv2.warpAffine(gray, rot_mat, (target_w, target_h), flags=cv2.INTER_CUBIC)

        # 2. Метрологический блок (измерение габарита по каноническому растру)
        # Субпиксельное расстояние между левой и правой границами на высоте y=150
        row_profile = canonical[150, :].astype(np.float32)
        grad = np.diff(row_profile)
        left_edge_idx = np.argmax(grad)
        right_edge_idx = np.argmin(grad)

        width_px = float(right_edge_idx - left_edge_idx)
        width_mm = width_px * self.scale_factor
        measurements = {"outer_width": width_mm}

        # 3. Дефектоскопический блок (анализ остаточной карты)
        bg_model = cv2.medianBlur(canonical, 19)
        residual = cv2.absdiff(canonical, bg_model)
        _, defect_mask = cv2.threshold(residual, 35, 255, cv2.THRESH_BINARY)

        # Исключаем края (Don't Care Zone)
        defect_mask[:10, :] = 0
        defect_mask[-10:, :] = 0
        defect_mask[:, :10] = 0
        defect_mask[:, -10:] = 0

        def_contours, _ = cv2.findContours(defect_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        defect_records = []
        for dc in def_contours:
            area_px = cv2.contourArea(dc)
            if area_px > 8.0:
                area_mm2 = area_px * (self.scale_factor ** 2)
                d_moments = cv2.moments(dc)
                dcx = int(d_moments["m10"] / d_moments["m00"]) if d_moments["m00"] > 0 else 0
                dcy = int(d_moments["m01"] / d_moments["m00"]) if d_moments["m00"] > 0 else 0

                # Классификация: если площадь > 1.0 мм² — критический дефект
                is_crit = area_mm2 > 1.0
                defect_records.append(DefectRecord(
                    defect_type="pore" if is_crit else "scratch",
                    contour=dc,
                    area_mm2=area_mm2,
                    centroid=(dcx, dcy),
                    is_critical=is_crit
                ))

        # 4. Принятие решения
        grade, reasons = self.decision_engine.evaluate(measurements, defect_records)

        t_duration_ms = (time.perf_counter() - t_start) * 1000.0

        result = InspectionResult(
            part_id=part_id,
            timestamp=time.time(),
            grade=grade,
            processing_time_ms=t_duration_ms,
            measurements={"outer_width": {"value": width_mm, "unit": "mm"}},
            defects=defect_records,
            reasons=reasons
        )

        # 5. Отрисовка телеметрии на каноническом кадре
        overlay_image = TelemetryRenderer.render(canonical, result)

        return result, overlay_image

Интеграция с производственной инфраструктурой

Результаты инспекции должны синхронно транслироваться двум адресатам: аппаратному контроллеру линии (PLC) и заводской системе управления производством (MES/SCADA).

Для передачи дискретных команд в PLC результат сопоставляется с выходными регистрами:

  • DO_0 (Pass) — логическая единица активирует прямой пропуск изделия;
  • DO_1 (Rework) — импульс на пневмоцилиндр отбраковки в ремонтный лоток;
  • DO_2 (Scrap) — импульс на сброс в контейнер утиля.

Для MES-системы и последующего статистического анализа процессов (SPC — Statistical Process Control) формируется сериализованный JSON-документ:

{
  "part_id": "FLANGE_A104_8921",
  "timestamp": 1711798412.352,
  "grade": "REWORK",
  "processing_time_ms": 14.82,
  "metrics": {
    "outer_width_mm": 50.184,
    "outer_width_delta_mm": 0.184
  },
  "defects_count": 1,
  "defects": [
    {
      "type": "burr",
      "area_mm2": 0.42,
      "location": [152, 298],
      "critical": false
    }
  ],
  "reasons": [
    "Размер outer_width (50.184 мм) выше USL (50.100 мм) — избыток тела"
  ]
}

Сборка законченного пайплайна замыкает сквозную траекторию оптического контроля: от первичного фотона на матрице камеры и субпиксельной математики кромки до надежного управления физическим конвейером.