Практикум-чекпоинт: Автоматическая классификация брака на конвейере

Практический курс по объединению классических методов OpenCV, устойчивых пайплайнов данных и сверточных нейросетей PyTorch в единый промышленный сервис оптической инспекции. Рассматриваются вопросы сквозной архитектуры, извлечения канонических областей интереса (ROI), калибровки вероятностей моделей, проектирования матриц решений и сборки отказоустойчивого сервиса отбраковки деталей.

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

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

Конвейер штамповочного цеха движется со скоростью 120 деталей в минуту. На анализ каждого изделия у системы технического зрения есть ровно 500 миллисекунд, включая захват кадра с промышленной камеры, поиск дефектов, логику арбитража и передачу аппаратного сигнала на пневматический толкатель отбраковщика. Попытка подать несжатый кадр разрешением 2448×20482448 \times 2048 пикселей напрямую в глубокую сверточную сеть приводит либо к нехватке видеопамяти и нарушению такта конвейера, либо к тому, что при масштабировании кадра до стандартных 224×224224 \times 224 микротрещина шириной в 3 пикселя бесследно растворяется в процессе билинейной интерполяции.

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


Проблема монолитного подхода: почему одного метода недостаточно

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

                  ┌──────────────────────────────────────────────────┐
                  │          Входной сырой кадр (4K / 5MP)           │
                  └─────────┬──────────────────────────────┬─────────┘
                            │                              │
             [ Попытка классического CV ]        [ Попытка Pure Deep Learning ]
                            │                              │
                            ▼                              ▼
                 Сложные фотометрические          Масштабирование до 224x224
                 текстуры, блики, шум             уничтожает микродефекты;
                 порождают ложные тревоги;        инференс полного кадра не
                 хрупкость к вариациям            укладывается в такт конвейера

Оба подхода в изолированном виде не выдерживают требований реального производства:

Параметр Изолированный классический CV Изолированный Deep Learning Гибридный пайплайн (CV + DL)
Устойчивость к вариациям текстуры Низкая (требует постоянной подстройки порогов) Высокая (обобщает семантические паттерны) Высокая (DL классифицирует текстуры)
Локализация и ориентация объекта Экстремально быстрая и субпиксельно точная Медленная, требует избыточных слоев внимания Высокая (OpenCV быстро центрирует ROI)
Сохранение мелких дефектов Сохраняет 100% исходного разрешения Теряет детали при глобальном ресайзе кадра Сохраняет (DL анализирует локальные ROI)
Вычислительная сложность Минимальная (<15< 15 мс на CPU) Высокая (5015050 - 150 мс на GPU) Оптимальная (153515 - 35 мс суммарно)
Детерминированность геометрии Абсолютная (строгие формулы моментов) Стохастическая Абсолютная

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


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

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

Архитектурный конвейер разделен на пять строго изолированных стадий:

  1. Ingestion & Validation (Захват и защитная валидация): прием кадра из буфера камеры, проверка целостности метаданных, валидация гистограммы на отсутствие критической засветки или затемнения.
  2. Spatial Standardization (Геометрическая стандартизация): поиск детали методами пороговой сегментации и контурного анализа OpenCV, устранение угла наклона и вырезка канонических областей интереса (Oriented ROI) в исходном оптическом разрешении.
  3. Fail-Fast Heuristics (Каскадный отсев грубого брака): быстрая проверка базовых геометрических допусков (габариты, целостность кромки, отсутствие критических пробоин) детерминированными алгоритмами. Если деталь имеет грубое нарушение геометрии, она немедленно бракуется без вызова нейросети.
  4. CNN Defect Inference (Сверточный анализ микродефектов): передача стандартизированных локальных паттернов в дообученную CNN для классификации сложных текстурных дефектов.
  5. Decision Engine & Arbitration (Движок принятия решений): объединение результатов геометрической инспекции и нейросетевых предсказаний, применение порогов уверенности и формирование управляющего сигнала для контроллера (PLC).

Бюджет задержки (Latency Budget) и расчет пропускной способности

В инженерной практике проектирование системы начинается не с выбора архитектуры CNN, а с составления бюджета задержки (Latency Budget).

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

Если конвейер производит NN изделий в минуту, полный период одного производственного такта TcycleT_{\text{cycle}} в секундах рассчитывается как:

Tcycle=60NT_{\text{cycle}} = \frac{60}{N}

где NN — число деталей, проходящих через зону контроля за одну минуту. Например, при скорости N=120N = 120 деталей в минуту период такта составляет:

Tcycle=60120=0.5 с=500 мсT_{\text{cycle}} = \frac{60}{120} = 0.5\text{ с} = 500\text{ мс}

Однако рабочий бюджет программного сервиса TbudgetT_{\text{budget}} составляет лишь часть этого интервала: необходимо вычесть время экспозиции и передачи кадра по интерфейсу GigE/USB3 (Ttransfer80 мсT_{\text{transfer}} \approx 80\text{ мс}), а также время механического срабатывания пневматического клапана (Tactuator120 мсT_{\text{actuator}} \approx 120\text{ мс}).

В итоге на выполнение программного конвейера остается:

Tbudget=TcycleTtransferTactuator=50080120=300 мсT_{\text{budget}} = T_{\text{cycle}} - T_{\text{transfer}} - T_{\text{actuator}} = 500 - 80 - 120 = 300\text{ мс}

Суммарное время выполнения алгоритмов рассчитывается как сумма задержек всех этапов:

Tpipeline=Tvalidation+Tgeometry+Theuristics+i=1kTcnn(i)+TdecisionT_{\text{pipeline}} = T_{\text{validation}} + T_{\text{geometry}} + T_{\text{heuristics}} + \sum_{i=1}^{k} T_{\text{cnn}}^{(i)} + T_{\text{decision}}

где:

  • TvalidationT_{\text{validation}} — время проверки целостности кадра (~2 мс2\text{ мс});
  • TgeometryT_{\text{geometry}} — сегментация, моментный анализ и аффинное выравнивание деталей в OpenCV (~12 мс12\text{ мс});
  • TheuristicsT_{\text{heuristics}} — проверка контурных допусков (~4 мс4\text{ мс});
  • Tcnn(i)T_{\text{cnn}}^{(i)} — время прямого прохода нейросети для ii-го подозрительного региона интереса (~8 мс8\text{ мс} на батч из kk фрагментов на GPU);
  • TdecisionT_{\text{decision}} — агрегация признаков в движке принятия решений (~1 мс1\text{ мс}).

При каскадной архитектуре тяжелый блок TcnnT_{\text{cnn}} вызывается не для всех 100%100\% кадров: детали с явными дефектами формы отсеиваются на этапе TheuristicsT_{\text{heuristics}}, высвобождая аппаратные ресурсы для поддержания высокой пропускной способности.


Контракты данных и изоляция слоев

Чтобы инспекционный сервис оставался масштабируемым, поддерживаемым и тестируемым, этапы конвейера связываются между собой через неизменяемые структуры данных — контракты (Data Transfer Objects). Ни один модуль не должен напрямую зависеть от внутренней логики другого.

from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional, Tuple
import numpy as np

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

@dataclass(frozen=True)
class NormalizedROI:
    """Контракт стандартизированной области интереса, подготовленной для CNN."""
    roi_id: int
    patch: np.ndarray             # Выровненный тензор/матрица uint8
    bounding_box: Tuple[int, int, int, int]  # (x, y, w, h) на исходном кадре
    feature_type: str             # Тип зоны: 'weld_seam', 'surface', 'edge'

@dataclass(frozen=True)
class GeometricInspectionResult:
    """Результат детерминированного анализа формы и допусков."""
    is_valid_shape: bool
    dimensions_mm: Tuple[float, float]
    extent: float
    solidity: float
    gross_defects_found: List[str] = field(default_factory=list)

@dataclass(frozen=True)
class DefectPrediction:
    """Результат работы сверточного классификатора."""
    roi_id: int
    predicted_class: str
    confidence: float
    raw_probabilities: Tuple[float, ...]

@dataclass(frozen=True)
class FinalInspectionReport:
    """Итоговый вердикт системы контроля для передачи в PLC и базу данных."""
    part_id: str
    timestamp: float
    verdict: QualityVerdict
    geometric_result: GeometricInspectionResult
    defect_predictions: List[DefectPrediction]
    total_latency_ms: float

Использование таких типизированных структур гарантирует:

  • Раздельное тестирование: классический модуль геометрической нормализации можно тестировать на синтетических матрицах без инициализации PyTorch и GPU.
  • Изоляцию ошибок: сбой на этапе предсказания нейросети не приводит к падению сервиса захвата кадров — система безопасно переводит деталь в категорию UNCERTAIN или SCRAP.

Принцип защитного отказа (Fail-Safe Defaults)

В промышленном машинном зрении стоимость ошибки асимметрична. Пропуск бракованной детали потребителю (False Positive для статуса Pass) несет катастрофические финансовые и репутационные риски, тогда как ложная отбраковка годной детали (False Negative для статуса Pass) стоит предприятию лишь затрат на повторный осмотр.

Принцип Fail-Safe: Любой системный сбой, деградация входного сигнала, нарушение контракта данных или выход времени вычислений за рамки TbudgetT_{\text{budget}} должен приводить к автоматическому присвоению детали статуса брака (SCRAP или UNCERTAIN), но никогда — статуса PASS.

Архитектура системы реализует защитную изоляцию через три рубежа:

  • Таймаут-стражи (Watchdogs): если инференс CNN или аффинная трансформация превышает выделенный квант времени, выполнение принудительно прерывается, а деталь сбрасывается в буфер ручной перепроверки.
  • Валидация распределения вероятностей: если максимальная вероятность класса после Softmax ниже порога уверенности (например, pmax<0.85p_{\max} < 0.85), классификатор возвращает флаг неопределенности, запрещая автоматический пропуск изделия.
  • Аудит освещения: резкое изменение средней яркости кадра (например, перегорание одного из светодиодных сегментов подсветки) блокирует весь конвейер и подает тревожный сигнал оператору.

Резюме

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

  • Классический OpenCV отвечает за пространственную инвариантность, локализацию объектов, устранение геометрических искажений и отсев грубых аномалий формы за единицы миллисекунд.
  • Глубокое обучение вступает в работу на предварительно изолированных и стандартизированных областях интереса (ROI), решая задачу тонкой классификации текстур дефектов.
  • Архитектурная целостность обеспечивается жестким соблюдением бюджета времени (TpipelineTbudgetT_{\text{pipeline}} \leq T_{\text{budget}}), контрактами данных на основе неизменяемых структур и принципом защитного отказа (Fail-Safe).

В следующей главе мы детально реализуем этап Spatial Standardization: разработаем устойчивый алгоритм сегментации, моментного выравнивания и извлечения стандартизированных областей интереса средствами OpenCV.

Локализация и геометрическая стандартизация областей интереса (ROI) средствами OpenCV перед подачей в нейросеть

Локализация и геометрическая стандартизация областей интереса (ROI) средствами OpenCV перед подачей в нейросеть

Если подать сырой кадр с промышленной камеры 4096×30004096 \times 3000 пикселей напрямую в глубокую сверточную сеть, система мгновенно выйдет за пределы допустимого бюджета задержки. Простое масштабирование (resize) такого кадра до стандартного входа CNN 224×224224 \times 224 сжимает изображение более чем в 18 раз, превращая поверхностную трещину шириной в 2 пикселя в неразличимый шум субпиксельного уровня. Классификатор брака теряет дефект еще до первого сверточного слоя.

Решение заключается в разделении ответственности: детерминированные алгоритмы OpenCV берут на себя поиск объекта, компенсацию его смещения, поворот и вырезание целевых областей интереса (Region of Interest, ROI) в исходном разрешении, а нейросеть анализирует уже нормализованный фрагмент фиксированного размера.

Устойчивая сегментация объекта на конвейерной ленте

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

Для компенсации неравномерного светового поля применяется комбинация морфологического фильтра White Top-Hat с ядром, превышающим размер локальных дефектов, но меньшим габаритов самой детали, и адаптивной или глобальной бинаризации по методу Оцу:

import cv2
import numpy as np

def segment_workpiece(gray_frame: np.ndarray) -> np.ndarray:
    # 1. Сглаживание высокочастотного сенсорного шума
    blurred = cv2.GaussianBlur(gray_frame, (5, 5), sigmaX=1.2)

    # 2. Подавление низкочастотных градиентов освещения
    se_dim = 31
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (se_dim, se_dim))
    tophat = cv2.morphologyEx(blurred, cv2.MORPH_TOPHAT, kernel)

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

    # 4. Морфологическое замыкание для устранения дыр от бликов
    close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
    clean_mask = cv2.morphologyEx(binary_mask, cv2.MORPH_CLOSE, close_kernel)

    return clean_mask

Сформированная маска содержит не только контур искомой детали, но и сор на конвейере, а также краевые тени. Передавать все найденные объекты дальше по конвейеру нельзя — это вызовет лавину лишних запусков нейросети.

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

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

Параметр фильтрации Назначение Допустимый диапазон (пример) Типичный отсекаемый мусор
Площадь контура (M00M_{00}) Проверка минимального и максимального размера заготовки 15000S4500015\,000 \leq S \leq 45\,000 px Пыль, капли СОЖ, посторонние предметы
Выпуклость (Solidity) Отношение площади контура к площади его Convex Hull Solidity0.88\text{Solidity} \geq 0.88 Разорванные контуры, тени сложной формы
Aspect Ratio Отношение ширины к высоте описанного прямоугольника 1.2AR1.81.2 \leq AR \leq 1.8 Длинные продольные царапины на ленте, блики

Контуры, не прошедшие проверку, немедленно отбрасываются модулем Fail-Fast, экономя процессорное время.

def find_valid_component(binary_mask: np.ndarray) -> np.ndarray | None:
    contours, _ = cv2.findContours(
        binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    for cnt in contours:
        area = cv2.contourArea(cnt)
        if not (15000 <= area <= 45000):
            continue

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

        solidity = area / hull_area
        if solidity < 0.88:
            continue

        rect = cv2.minAreaRect(cnt)
        w, h = rect[1]
        if w == 0 or h == 0:
            continue

        ar = max(w, h) / min(w, h)
        if not (1.2 <= ar <= 1.8):
            continue

        return cnt

    return None

Оценка позы и компенсация пространственного поворота

Детали движутся по ленте с произвольным смещением по осям X,YX, Y и произвольным углом наклона θ\theta. Если подавать повернутые заготовки в классификатор без выравнивания, нейросеть будет вынуждена тратить свою емкость на запоминание всех возможных углов поворота, что резко снижает точность при малых объемах обучающей выборки.

Для приведения детали в каноническую систему координат вычисляется повернутый прямоугольник minAreaRect, после чего формируется матрица аффинного преобразования 2×32 \times 3.

Центрирование и поворот осуществляются функцией cv2.getRotationMatrix2D, в которой координаты центра вращения совпадают с центроидом детали (cx,cy)(c_x, c_y), а масштаб равен единице:

M=[αβ(1α)cxβcyβαβcx+(1α)cy]M = \begin{bmatrix} \alpha & \beta & (1 - \alpha) \cdot c_x - \beta \cdot c_y \\ -\beta & \alpha & \beta \cdot c_x + (1 - \alpha) \cdot c_y \end{bmatrix}

Здесь α=cos(θ)\alpha = \cos(\theta), β=sin(θ)\beta = \sin(\theta), а (cx,cy)(c_x, c_y) — центр масс детали, вычисленный через центральные моменты контура.

def calculate_canonical_transform(
    cnt: np.ndarray, target_size: tuple[int, int]
) -> tuple[np.ndarray, tuple[int, int]]:
    rect = cv2.minAreaRect(cnt)
    (cx, cy), (w, h), angle = rect

    # Нормализация угла: приведение длинной стороны к горизонтали
    if w < h:
        w, h = h, w
        angle += 90.0

    target_w, target_h = target_size

    # Построение матрицы поворота вокруг центра детали
    rot_mat = cv2.getRotationMatrix2D((cx, cy), angle, scale=1.0)

    # Корректировка смещения: совмещение центроида с центром целевого кадра
    rot_mat[0, 2] += (target_w / 2.0) - cx
    rot_mat[1, 2] += (target_h / 2.0) - cy

    return rot_mat, (target_w, target_h)

Важный инженерный инсайт: Матрица аффинной трансформации MM должна сохраняться в метаданных результата инспекции. Если нейросеть обнаружит дефект в каноническом ROI с локальными координатами (u,v)(u, v), оператору или манипулятору отбраковщика потребуются координаты (x,y)(x, y) в глобальной системе координат конвейера. Они восстанавливаются через обратное преобразование cv2.invertAffineTransform(M).

Извлечение канонического патча и субпиксельное кадрирование

Финальный шаг — трансформация исходного многоканального BGR-кадра в стандартизированный растр 224×224224 \times 224 (или иной фиксированный формат классификатора) с помощью cv2.warpAffine.

При интерполяции пикселей критически важно выбрать правильный флаг:

  • cv2.INTER_LINEAR (билинейная) — оптимальный баланс между скоростью (менее 1 мс на ROI) и сохранением контраста тонких дефектов.
  • cv2.INTER_CUBIC (бикубическая) — обеспечивает чуть более четкие границы, но требует на 40–60% больше вычислительных ресурсов CPU.
  • cv2.INTER_NEAREST — категорически запрещена для задач классификации дефектов, так как порождает ступенчатые артефакты и искажает текстуру материала.

Граничные условия задаются через borderMode=cv2.BORDER_CONSTANT со значением borderValue=(0, 0, 0) (черный фон) или cv2.BORDER_REFLECT_101, если границы детали не должны содержать искусственных перепадов контраста.

def extract_normalized_patch(
    image: np.ndarray, transform_matrix: np.ndarray, target_size: tuple[int, int]
) -> np.ndarray:
    return cv2.warpAffine(
        image,
        transform_matrix,
        target_size,
        flags=cv2.INTER_LINEAR,
        borderMode=cv2.BORDER_CONSTANT,
        borderValue=(0, 0, 0)
    )

Промышленная сборка: класс ROIExtractor

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

from dataclasses import dataclass
import cv2
import numpy as np

@dataclass(frozen=True)
class NormalizedROI:
    patch: np.ndarray              # Тензор HxWxC (готов к подаче в PyTorch transforms)
    transform_matrix: np.ndarray   # Матрица 2x3 для обратной проекции координат
    center_xy: tuple[float, float] # Координаты детали на ленте
    angle: float                   # Исходный угол наклона
    is_valid: bool                 # Флаг успешного извлечения

class ROIExtractor:
    def __init__(self, target_size: tuple[int, int] = (224, 224)) -> None:
        self.target_size = target_size

    def process(self, frame_bgr: np.ndarray) -> NormalizedROI | None:
        if frame_bgr is None or frame_bgr.size == 0:
            return None

        # 1. Перевод в градации серого для сегментации
        gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)

        # 2. Сегментация переднего плана
        mask = segment_workpiece(gray)

        # 3. Фильтрация контуров
        cnt = find_valid_component(mask)
        if cnt is None:
            return None

        # 4. Расчет матрицы преобразования
        rect = cv2.minAreaRect(cnt)
        (cx, cy), _, angle = rect
        transform_matrix, out_size = calculate_canonical_transform(cnt, self.target_size)

        # 5. Аффинное извлечение канонического патча в исходном качестве
        patch = extract_normalized_patch(frame_bgr, transform_matrix, out_size)

        return NormalizedROI(
            patch=patch,
            transform_matrix=transform_matrix,
            center_xy=(cx, cy),
            angle=angle,
            is_valid=True
        )

Сформированный NormalizedROI.patch представляет собой стандартизированный массив NumPy формы (224,224,3)(224, 224, 3), в котором изделие строго выровнено по центру и главным осям. На этом этапе классическое зрение полностью выполнило задачу пространственной нормализации: нейросеть получает изображение без мусора, фоновых сдвигов и паразитных углов поворота, что позволяет использовать компактные и быстрые модели классификации дефектов.

Дообучение сверточного классификатора дефектов и калибровка вероятностей при асимметричной цене ошибок

Дообучение сверточного классификатора дефектов и калибровка вероятностей при асимметричной цене ошибок

На конвейере со скоростью 120 деталей в минуту модель машинного обучения рапортует об идеальной метрике Accuracy в 99.2%. Однако финансовый аудит в конце месяца фиксирует колоссальные убытки: партия бракованных металлических муфт прошла на сборочную линию заказчика, что привело к остановке автозавода и миллионным неустойкам. Как модель с точностью выше 99% могла пропустить критический брак?

Причина кроется в фундаментальном несоответствии: стандартные функции потерь оптимизируют симметричную вероятность ошибки, тогда как в реальном производстве цена пропуска дефекта (False Negative) в десятки и сотни раз превышает цену ложной отбраковки годной детали (False Positive). Более того, современные глубокие свёрточные сети страдают от системной переуверенности (overconfidence): если выходной слой выдает 0.900.90, это вовсе не означает, что деталь годна с вероятностью 90%.

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


Архитектура классификатора для стандартизированных ROI

Благодаря предшествующей геометрической нормализации нейросети больше не требуется расходовать выразительную емкость на изучение инвариантности к произвольным поворотам и масштабным сдвигам. На вход классификатора подаются стандартизированные тензоры фиксированного разрешения (например, 224×224×3224 \times 224 \times 3), где деталь всегда центрирована, а ее главная ось выровнена по горизонтали.

Для решения задачи классификации дефектов в условиях жесткого бюджета задержки (до 15–25 мс на инференс) оптимальным выбором бэкбона выступает компактная архитектура с эффективными остаточными блоками — EfficientNet-B0 или ResNet-18/34.

import torch
import torch.nn as nn
from torchvision.models import efficientnet_b0, EfficientNet_B0_Weights

def build_defect_backbone(num_classes: int = 3, dropout_rate: float = 0.3) -> nn.Module:
    weights = EfficientNet_B0_Weights.DEFAULT
    model = efficientnet_b0(weights=weights)

    # Заморозка начальных слоев извлечения низкоуровневых признаков
    for param in model.features[:4].parameters():
        param.requires_grad = False

    # Адаптация классификационной головы под целевые классы дефектов
    in_features = model.classifier[1].in_features
    model.classifier = nn.Sequential(
        nn.Dropout(p=dropout_rate, inplace=True),
        nn.Linear(in_features=in_features, out_features=num_classes)
    )
    return model

В типовом сценарии промышленной инспекции целевое пространство меток включает три взаимно исключающих класса:

  1. Class 0 (Pass): бездефектное изделие, соответствующее допускам.
  2. Class 1 (Scratch/Pore): поверхностный дефект (царапина, пора), потенциально подлежащий механической доработке (Rework).
  3. Class 2 (Crack/Structural): критический дефект структуры (трещина, сквозной раскол), отправляющий деталь в безвозвратный утиль (Scrap).

Дообучение модели выполняется по двухэтапному протоколу Fine-Tuning: сначала прогрев классификационной головы с замороженным бэкбоном на оптимизаторе AdamW с темпом обучения η=103\eta = 10^{-3}, затем разморозка старших сверточных блоков с дифференциальным темпом обучения (ηbackbone=105\eta_{\text{backbone}} = 10^{-5}, ηhead=104\eta_{\text{head}} = 10^{-4}).

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


Проблема некалиброванной уверенности и метрика ECE

Современные глубокие нейросети обладают высокой точностью классификации, но демонстрируют плохую калибровку (calibration).

Калибровка вероятностей — свойство вероятностной модели, при котором предсказанное значение уверенности p^[0,1]\hat{p} \in [0, 1] соответствует истинной статистической частоте наступления события: среди всех объектов, для которых классификатор предсказал уверенность 0.800.80, ровно 80% должны фактически принадлежать к данному классу.

Применение пакетной нормализации BatchNorm, регуляризации Weight Decay и оптимизация функции кросс-энтропии приводят к тому, что сеть минимизирует потери за счет разгона магнитуд выходных логитов zz. В результате функция Softmax сжимает значения к краям распределения (0 или 1), порождая сверхуверенность (overconfidence): даже на сомнительных или искаженных образцах сеть выдает уверенность 0.95+0.95+.

Для визуализации и количественной оценки степени рассогласования используется диаграмма надежности (Reliability Diagram) и скалярная метрика ожидаемой ошибки калибровки (Expected Calibration Error, ECEECE).

Для расчета ECEECE диапазон предсказанных вероятностей [0,1][0, 1] разбивается на MM равных интервалов (бинов) BmB_m. Для каждого бина вычисляются:

  • Средняя точность: acc(Bm)=1BmiBm1(y^i=yi)\text{acc}(B_m) = \frac{1}{|B_m|} \sum_{i \in B_m} \mathbf{1}(\hat{y}_i = y_i)
  • Средняя уверенность: conf(Bm)=1BmiBmp^i\text{conf}(B_m) = \frac{1}{|B_m|} \sum_{i \in B_m} \hat{p}_i

Формула метрики ECEECE агрегирует взвешенную абсолютную разность между уверенностью и точностью по всем бинам:

ECE=m=1MBmNacc(Bm)conf(Bm)ECE = \sum_{m=1}^{M} \frac{|B_m|}{N} |\text{acc}(B_m) - \text{conf}(B_m)|

Здесь:

  • MM — количество бинов дискретизации (обычно M=10M = 10 или M=15M = 15);
  • NN — общий объем валидационной выборки;
  • Bm|B_m| — число сэмплов, попавших в mm-й интервал уверенности;
  • acc(Bm)\text{acc}(B_m) — фактическая точность предсказаний внутри mm-го бина;
  • conf(Bm)\text{conf}(B_m) — средняя уверенность сети внутри mm-го бина.

Практический пример: если в бин уверенности [0.8,0.9][0.8, 0.9] попало 100 деталей, средняя уверенность модели в этом интервале составляет 0.85 (conf=0.85\text{conf} = 0.85), но реальная доля верных ответов среди них составляет лишь 0.65 (acc=0.65\text{acc} = 0.65), то локальный разрыв калибровки для этого бина равен 0.650.85=0.20|0.65 - 0.85| = 0.20.


Температурное масштабирование (Temperature Scaling)

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

Пусть вектор сырых нескорректированных выходов модели (логитов) перед финальной активацией для входного кадра xx равен z(x)RK\mathbf{z}(x) \in \mathbb{R}^K, где KK — число классов.

Калиброванное распределение вероятностей q^\mathbf{\hat{q}} вычисляется путем деления логитов на единственный скалярный параметр — температуру T>0T > 0:

q^k=ezk/Tj=1Kezj/T\hat{q}_k = \frac{e^{z_k / T}}{\sum_{j=1}^{K} e^{z_j / T}}

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

  • При T=1T = 1: сохраняются исходные вероятности p^=Softmax(z)\mathbf{\hat{p}} = \text{Softmax}(\mathbf{z}).
  • При T>1T > 1: распределение становится более мягким (энтропия растет), подавляя избыточную самоуверенность сети на пограничных примерах.
  • При TT \to \infty: вероятности вырождаются в равномерное распределение 1/K1/K.
  • При T0T \to 0: распределение вырождается в жесткий дискретный вектор (one-hot).

Ключевой инсайт: поскольку деление на скаляр T>0T > 0 является строго монотонным преобразованием, температурное масштабирование сохраняет взаимный порядок элементов логитов:

argmaxk(zk/T)=argmaxk(zk)\operatorname{argmax}_k (z_k / T) = \operatorname{argmax}_k (z_k)

Это означает, что калибровка температуры абсолютно не меняет точность классификации (Accuracy) модели по критерию максимума, но идеально корректирует числовые значения вероятностей q^k\hat{q}_k.

Оптимизация параметра TT выполняется на изолированной отложенной валидационной выборке. Веса самой обученной нейросети полностью замораживаются, а значение TT находится путем минимизации стандартной кросс-энтропии (Negative Log-Likelihood, NLLNLL) методом градиентного спуска:

import torch
import torch.nn as nn
import torch.optim as optim

class TemperatureScaler(nn.Module):
    def __init__(self):
        super().__init__()
        # Инициализация температуры единицей
        self.temperature = nn.Parameter(torch.ones(1) * 1.0)

    def forward(self, logits: torch.Tensor) -> torch.Tensor:
        # Расширение размерности для поканального деления логитов
        temperature = self.temperature.unsqueeze(1).expand_as(logits)
        return logits / temperature

    def fit(self, val_logits: torch.Tensor, val_labels: torch.Tensor, max_iters: int = 100) -> float:
        """
        Оптимизация температуры на валидационном наборе логитов.
        """
        self.to(val_logits.device)
        nll_criterion = nn.CrossEntropyLoss()

        # Оптимизируется только один скалярный параметр
        optimizer = optim.LBFGS([self.temperature], lr=0.01, max_iter=max_iters)

        def eval_step():
            optimizer.zero_grad()
            scaled_logits = self.forward(val_logits)
            loss = nll_criterion(scaled_logits, val_labels)
            loss.backward()
            return loss

        optimizer.step(eval_step)

        # Гарантируем строгую положительность температуры
        with torch.no_grad():
            self.temperature.clamp_(min=0.01)

        return self.temperature.item()

Матрица стоимости ошибок и асимметричные риски

Получив калиброванные вероятности q=[q0,q1,q2]\mathbf{q} = [q_0, q_1, q_2], система инспекции должна принять окончательное бинарное или тернарное действие. В классическом машинном обучении деталь относят к дефектной, если qdefect>0.5q_{\text{defect}} > 0.5. В промышленном производстве такой наивный подход приводит к экономическому коллапсу.

Рассмотрим два типа ошибок при контроле ответственного узла:

  • Ложная тревога (False Positive, FP): бездефектная деталь признана бракованной. Изделие отводится в буфер повторной ручной проверки оператором. Убыток равен стоимости 20 секунд работы контролера (CFP=0.50C_{\text{FP}} = 0.50 EUR).
  • Пропуск дефекта (False Negative, FN): деталь с микротрещиной признана годной и ушла на финальную сборку редуктора. Разрушение узла на стенде испытаний или у клиента влечет гарантийный возврат, разборку агрегата и штрафы (CFN=50.00C_{\text{FN}} = 50.00 EUR).

Отношение стоимостей ошибок в данном примере составляет CFN/CFP=100:1C_{\text{FN}} / C_{\text{FP}} = 100 : 1.

Для формализации задачи строится матрица затрат (Cost Matrix) C(ai,yj)C(a_i, y_j), определяющая финансовый штраф при принятии решения aia_i при истинном состоянии детали yjy_j:

Действие системы (aa) \ Факт (yy) y=Passy = \text{Pass} (Годная) y=Defecty = \text{Defect} (Брак)
a=Accepta = \text{Accept} (Принять) C(Accept,Pass)=0C(\text{Accept}, \text{Pass}) = 0 C(Accept,Defect)=CFNC(\text{Accept}, \text{Defect}) = C_{\text{FN}}
a=Rejecta = \text{Reject} (Отбраковать) C(Reject,Pass)=CFPC(\text{Reject}, \text{Pass}) = C_{\text{FP}} C(Reject,Defect)=0C(\text{Reject}, \text{Defect}) = 0

Согласно байесовской теории принятия решений, оптимальное действие a^\hat{a} минимизирует условный математический риск (Expected Loss):

R(ax)=jC(a,yj)P(yjx)R(a \mid x) = \sum_{j} C(a, y_j) \cdot P(y_j \mid x)

Для бинарного выбора между приемом детали (a=0a = 0) и ее отбраковкой (a=1a = 1) при калиброванной вероятности дефекта qdef=P(y=Defectx)q_{\text{def}} = P(y = \text{Defect} \mid x):

  • Ожидаемый ущерб при пропуске: R(Acceptx)=CFNqdefR(\text{Accept} \mid x) = C_{\text{FN}} \cdot q_{\text{def}}
  • Ожидаемый ущерб при отбраковке: R(Rejectx)=CFP(1qdef)R(\text{Reject} \mid x) = C_{\text{FP}} \cdot (1 - q_{\text{def}})

Система должна отбраковывать деталь (a=Rejecta = \text{Reject}), когда риск пропуска превышает риск ложной тревоги:

CFNqdef>CFP(1qdef)C_{\text{FN}} \cdot q_{\text{def}} > C_{\text{FP}} \cdot (1 - q_{\text{def}})

Отсюда аналитически выводится оптимальный операционный порог классификации θ\theta^*:

θ=CFPCFP+CFN=11+CFNCFP\theta^* = \frac{C_{\text{FP}}}{C_{\text{FP}} + C_{\text{FN}}} = \frac{1}{1 + \frac{C_{\text{FN}}}{C_{\text{FP}}}}

Практический расчет: при CFP=0.50C_{\text{FP}} = 0.50 EUR и CFN=50.00C_{\text{FN}} = 50.00 EUR:

θ=0.500.50+50.00=0.5050.500.0099(1.0%)\theta^* = \frac{0.50}{0.50 + 50.00} = \frac{0.50}{50.50} \approx 0.0099 \quad (\sim 1.0\%)

Если калиброванная вероятность наличия микротрещины на детали превышает всего 1.0%, деталь экономически обязана быть направлена в брак. Использование некалиброванной сети или стандартного порога 0.500.50 привело бы к пропуску сотен опасных дефектов, чья предсказанная уверенность лежала в диапазоне [0.05,0.45][0.05, 0.45].


Программная реализация калиброванного классификатора

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

Компонент принимает стандартизированный DTO-объект NormalizedROI, выполняет тензорную предобработку, прогоняет через калиброванную нейросеть в высокопроизводительном контексте torch.inference_mode() и возвращает строго типизированный вердикт.

import numpy as np
import torch
import torch.nn as nn
from dataclasses import dataclass
from typing import Tuple

@dataclass(frozen=True)
class CalibratedPrediction:
    predicted_class: int
    is_defective: bool
    confidence: float
    calibrated_probabilities: np.ndarray
    raw_logits: np.ndarray

class CalibratedDefectClassifier:
    def __init__(
        self,
        model: nn.Module,
        temperature: float,
        defect_threshold: float,
        device: str = "cuda" if torch.cuda.is_available() else "cpu"
    ):
        self.device = torch.device(device)
        self.model = model.to(self.device)
        self.model.eval()

        self.temperature = max(float(temperature), 0.01)
        self.defect_threshold = float(defect_threshold)

        # Статистики нормализации ImageNet
        self.mean = torch.tensor([0.485, 0.456, 0.406], device=self.device).view(1, 3, 1, 1)
        self.std = torch.tensor([0.229, 0.224, 0.225], device=self.device).view(1, 3, 1, 1)

    def _preprocess(self, roi_image: np.ndarray) -> torch.Tensor:
        """
        Преобразование стандартизированного кадра (H, W, C) uint8 BGR
        в канонический тензор PyTorch (1, C, H, W) float32 RGB.
        """
        # Преобразование BGR -> RGB через срезы NumPy
        rgb_image = roi_image[:, :, ::-1].copy()

        # Конвертация в тензор (H, W, C) -> (C, H, W)
        tensor = torch.from_numpy(rgb_image).permute(2, 0, 1).float() / 255.0
        tensor = tensor.unsqueeze(0).to(self.device)

        # Поканальная Z-score стандартизация
        tensor = (tensor - self.mean) / self.std
        return tensor

    @torch.inference_mode()
    def predict(self, roi_image: np.ndarray) -> CalibratedPrediction:
        """
        Сквозной инференс с калибровкой логитов и cost-sensitive отсечением.
        """
        input_tensor = self._preprocess(roi_image)

        # Прямой проход
        raw_logits = self.model(input_tensor) # Форма: (1, K)

        # Температурное масштабирование
        scaled_logits = raw_logits / self.temperature
        calibrated_probs = torch.softmax(scaled_logits, dim=-1).squeeze(0)

        probs_np = calibrated_probs.cpu().numpy()
        logits_np = raw_logits.squeeze(0).cpu().numpy()

        # Суммарная апостериорная вероятность дефекта (Class 1 + Class 2)
        defect_probability = float(np.sum(probs_np[1:]))

        # Принятие решения по операционному порогу стоимости
        is_defective = defect_probability >= self.defect_threshold

        if is_defective:
            # Выбор конкретного типа дефекта среди дефектных классов
            predicted_class = int(np.argmax(probs_np[1:]) + 1)
            confidence = float(probs_np[predicted_class])
        else:
            predicted_class = 0
            confidence = float(probs_np[0])

        return CalibratedPrediction(
            predicted_class=predicted_class,
            is_defective=is_defective,
            confidence=confidence,
            calibrated_probabilities=probs_np,
            raw_logits=logits_np
        )

Калиброванный классификатор гарантирует, что система оптического контроля оперирует истинными вероятностями и предотвращает пропуск дорогостоящего брака. Однако единичный прогноз нейросети — это лишь часть промышленного контроля. На следующем этапе предстоит объединить выходы классификатора, геометрические измерения OpenCV и строгие метрологические допуски в едином многоуровневом движке принятия решений (Decision Engine).

Проектирование движка принятия решений (Decision Engine) и многоуровневый арбитраж вердиктов качества

Проектирование движка принятия решений (Decision Engine) и многоуровневый арбитраж вердиктов качества

Что произойдет, если сверточная нейросеть предсказывает класс «Норма» с вероятностью 91%91\%, но классический алгоритм OpenCV фиксирует смещение центрального крепежного отверстия детали на 0.45 мм0.45\text{ мм} при технологическом допуске в 0.10 мм0.10\text{ мм}? В наивной системе без четко выстроенной логики арбитража деталь отправится заказчику и вызовет остановку сборочного конвейера. Обратная ситуация ничуть не лучше: алгоритм видит микроскопическую царапину с калиброванной вероятностью 12%12\%, которая при операционном пороге брака в 10%10\% классифицируется как дефект, хотя деталь идеально укладывается во все геометрические допуски и подлежит не утилизации, а легкой полировке.

Нейросетевые вероятности и геометрические измерения — это разнородные информационные сигналы с принципиально разной природой ошибок. Превращение этого набора чисел в однозначное, юридически значимое и экономически оптимальное производственное действие выполняет движок принятия решений (Decision Engine).

Расширенная модель производственных вердиктов

В базовом сценарии оптический контроль часто сводят к бинарной классификации «годен / негоден». На реальном производстве бинарная логика приводит либо к перерасходу материалов (любое отклонение уходит в мусор), либо к пропуску брака. Устойчивый производственный процесс опирается на четырехпозиционную модель вердиктов качества:

                  ┌───────────────┐
                  │ Сырые сигналы │
                  └───────┬───────┘
                          │
         ┌────────────────┼────────────────┐
         ▼                ▼                ▼
   ┌───────────┐    ┌───────────┐    ┌───────────┐
   │   PASS    │    │  REWORK   │    │   SCRAP   │
   │  (Годен)  │    │(Доработка)│    │  (Утиль)  │
   └───────────┘    └───────────┘    └───────────┘
                          │
                          ▼ (низкая уверенность / конфликт)
                    ┌───────────┐
                    │  REVIEW   │
                    │ (Контроль)│
                    └───────────┘

Каждая категория имеет строгий экономический и физический смысл:

  • PASS (Годен): изделие полностью соответствует всем геометрическим допускам, а калиброванные вероятности дефектов строго ниже соответствующих операционных порогов. Деталь направляется на следующий технологический этап без задержки.
  • REWORK (Исправимый брак / Доработка): деталь имеет поверхностные дефекты (недолив компаунда, прилив металла, избыток припоя, некритичные потертости) или геометрические отклонения в сторону «избытка тела», которые могут быть устранены повторной механической, термической или ручной обработкой. Стоимость исправления существенно ниже себестоимости заготовки.
  • SCRAP (Неисправимый брак / Утиль): критические структурные нарушения (сквозные трещины, раковины в теле заготовки, недостаток материала ниже нижнего предела допуска LSL). Дальнейшая обработка невозможна; деталь немедленно сбрасывается пневмотолкателем в бункер утилизации.
  • MANUAL_REVIEW (Зона неопределенности / Ручной контроль): ситуация, при которой входные сигналы противоречивы, сенсорный шум превышает допустимые нормы, либо калиброванная уверенность классификатора попала в «серую зону» вблизи границы решений. Деталь отводится на буферный накопитель для инспекции оператором ОТК.

Дерево арбитража сигналов (Decision DAG)

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

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

Уровень 0: Валидация кадра и Fail-Fast фильтрация

Если модуль первичной сегментации не смог локализовать контур заготовки, зафиксировал наложение нескольких деталей друг на друга или обнаружил размытие кадра (дисперсия Лапласиана ниже критической границы), Decision Engine немедленно генерирует вердикт SCRAP или MANUAL_REVIEW с кодом причины ERR_INVALID_FRAME или ERR_BLUR_DETECTED. Ресурсоемкий инференс нейросети для таких кадров полностью блокируется.

Уровень 1: Геометрическая метрология (Hard Constraints)

Измеренные габариты, радиусы фасок, межцентровые расстояния и площади отверстий сопоставляются с технологическими границами поля допуска [LSL,USL][LSL, USL]:

  • Если хотя бы один критический размер меньше нижнего предела LSLLSL (недостаток материала, деталь сточена больше нормы) \rightarrow вердикт SCRAP (ERR_GEO_UNDERSIZED).
  • Если критический размер превышает верхний предел USLUSL (избыток материала под зачистку) \rightarrow вердикт REWORK (ERR_GEO_OVERSIZED).
  • Если нарушена базовая топология (например, отсутствует технологическое отверстие) \rightarrow SCRAP (ERR_TOPOLOGY_MISSING_HOLE).

Уровень 2: Доменная нейросетевая классификация дефектов

Только если геометрический контроль пройден успешно (GEO_PASS), вступают в силу калиброванные вероятности P(y=cX)P(y = c \mid X) от CNN. Для каждого класса дефекта c{Crack,Pore,Scratch,}c \in \{\text{Crack}, \text{Pore}, \text{Scratch}, \dots\} применяется индивидуальный операционный порог θc\theta_c, вычисленный на основе матрицы затрат:

  • Критический дефект (например, Crack\text{Crack}): при P(Crack)θcrackP(\text{Crack}) \geq \theta_{\text{crack}} выносится вердикт SCRAP (ERR_CNN_CRITICAL_DEFECT).
  • Устранимый дефект (например, Scratch\text{Scratch} или Burr\text{Burr}): при P(Burr)θburrP(\text{Burr}) \geq \theta_{\text{burr}} выносится вердикт REWORK (ERR_CNN_REWORKABLE_DEFECT).

Уровень 3: Арбитраж взаимно неисключающих дефектов

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

SCRAPMANUAL_REVIEWREWORKPASS\mathbf{SCRAP} \succ \mathbf{MANUAL\_REVIEW} \succ \mathbf{REWORK} \succ \mathbf{PASS}

Любой подтвержденный признак неисправимого брака перекрывает любые исправимые дефекты.

Количественная оценка неопределенности и серая зона

Калибровка температуры согласовывает вероятности модели с реальностью, однако при значениях вероятности в непосредственной близости от операционного порога θ\theta модель остается неустойчивой к микрошумам сенсора. Для выделения зоны MANUAL_REVIEW используются две математические метрики неопределенности.

Метрика 1: Разность уверенности (Top-1 vs Top-2 Margin)

Для вектора калиброванных вероятностей p=[p1,p2,,pK]\mathbf{p} = [p_1, p_2, \dots, p_K], упорядоченного по убыванию (p(1)p(2)p(K)p_{(1)} \geq p_{(2)} \geq \dots \geq p_{(K)}), маржа уверенности определяется как:

M(p)=p(1)p(2)M(\mathbf{p}) = p_{(1)} - p_{(2)}

  • p(1)p_{(1)} — наивысшая вероятность среди всех классов;
  • p(2)p_{(2)} — вторая по величине вероятность альтернативного класса.

Если M(p)<ΔmarginM(\mathbf{p}) < \Delta_{\text{margin}} (например, p(1)=0.48p_{(1)} = 0.48 для нормы и p(2)=0.45p_{(2)} = 0.45 для скрытой поры при пороге Δmargin=0.15\Delta_{\text{margin}} = 0.15), классификатор колеблется между двумя взаимоисключающими гипотезами. Автоматическое принятие решения в такой точке несет высокий финансовый риск, поэтому образец перенаправляется на ручной аудит.

Метрика 2: Энтропия предсказания Шеннона

Нормализованная энтропия распределения вероятностей по всем KK классам измеряет общую степень хаоса в предсказании:

H(p)=1lnKk=1Kpkln(pk+ϵ)H(\mathbf{p}) = -\frac{1}{\ln K} \sum_{k=1}^{K} p_k \ln(p_k + \epsilon)

где ϵ=107\epsilon = 10^{-7} предотвращает вычисление логарифма от нуля, а нормирующий множитель 1lnK\frac{1}{\ln K} масштабирует значение H(p)H(\mathbf{p}) в строгий отрезок [0.0,1.0][0.0, 1.0]. При H(p)>HmaxH(\mathbf{p}) > H_{\text{max}} (например, Hmax=0.65H_{\text{max}} = 0.65) предсказание признается диффузным, и деталь отправляется в MANUAL_REVIEW.

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

Архитектура промышленного модуля Decision Engine

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

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

class QualityVerdict(str, Enum):
    PASS = "PASS"
    REWORK = "REWORK"
    SCRAP = "SCRAP"
    MANUAL_REVIEW = "MANUAL_REVIEW"

class DefectSeverity(str, Enum):
    CRITICAL = "CRITICAL"      # Ведет к SCRAP
    REPAIRABLE = "REPAIRABLE"  # Ведет к REWORK
    NEGLIGIBLE = "NEGLIGIBLE"  # Допустимо для PASS

@dataclass(frozen=True)
class GeometricTolerance:
    feature_name: str
    nominal: float
    lsl: float
    usl: float
    is_critical: bool = True

@dataclass(frozen=True)
class DecisionEngineConfig:
    # Операционные пороги вероятностей для дефектов
    defect_thresholds: Dict[str, float]
    # Привязка классов дефектов к степени критичности
    defect_severities: Dict[str, DefectSeverity]
    # Геометрические допуски
    geometric_tolerances: Dict[str, GeometricTolerance]
    # Пороги неопределенности
    min_top_margin: float = 0.15
    max_entropy_threshold: float = 0.65

@dataclass(frozen=True)
class GeometricInspectionResult:
    is_valid: bool
    measurements: Dict[str, float]
    fail_fast_triggered: bool = False
    fail_fast_reason: Optional[str] = None

@dataclass(frozen=True)
class DefectPrediction:
    class_name: str
    class_id: int
    confidence: float
    all_probabilities: Dict[str, float]
    calibrated: bool = True

@dataclass(frozen=True)
class FinalInspectionReport:
    verdict: QualityVerdict
    primary_reason_code: str
    reason_description: str
    geometric_passed: bool
    cnn_passed: bool
    uncertainty_score: float
    audit_trail: List[str] = field(default_factory=list)

Реализация логики арбитража в классе IndustrialDecisionEngine гарантирует формирование непрерывного контрольного следа (Audit Trail) для каждого решения:

class IndustrialDecisionEngine:
    """Детерминированный движок многоуровневого арбитража вердиктов качества."""

    def __init__(self, config: DecisionEngineConfig):
        self.config = config

    def evaluate(
        self,
        geo_result: GeometricInspectionResult,
        cnn_result: Optional[DefectPrediction] = None,
    ) -> FinalInspectionReport:
        audit_trail: List[str] = []

        # -------------------------------------------------------------
        # Уровень 0: Fail-Fast проверка входных данных
        # -------------------------------------------------------------
        if geo_result.fail_fast_triggered:
            reason = geo_result.fail_fast_reason or "FAIL_FAST_TRIGGERED"
            audit_trail.append(f"Level 0: Fail-Fast triggered -> {reason}")
            return FinalInspectionReport(
                verdict=QualityVerdict.SCRAP,
                primary_reason_code="ERR_FAIL_FAST",
                reason_description=f"Первичный отказ геометрии: {reason}",
                geometric_passed=False,
                cnn_passed=False,
                uncertainty_score=0.0,
                audit_trail=audit_trail,
            )

        # -------------------------------------------------------------
        # Уровень 1: Проверка технологических допусков геометрии
        # -------------------------------------------------------------
        geo_verdict, geo_reasons = self._evaluate_geometry(
            geo_result.measurements, audit_trail
        )
        if geo_verdict == QualityVerdict.SCRAP:
            audit_trail.append("Level 1: Hard geometric scrap detected")
            return FinalInspectionReport(
                verdict=QualityVerdict.SCRAP,
                primary_reason_code="ERR_GEOMETRY_SCRAP",
                reason_description="; ".join(geo_reasons),
                geometric_passed=False,
                cnn_passed=False,
                uncertainty_score=0.0,
                audit_trail=audit_trail,
            )

        # -------------------------------------------------------------
        # Уровень 2 и 3: Анализ уверенности и вероятностей CNN
        # -------------------------------------------------------------
        if cnn_result is None:
            # Если CNN не запускалась, но геометрия требует доработки
            final_v = geo_verdict
            audit_trail.append(f"Level 2: CNN skipped, verdict based on geometry: {final_v}")
            return FinalInspectionReport(
                verdict=final_v,
                primary_reason_code="GEO_ONLY_EVALUATED",
                reason_description="; ".join(geo_reasons) or "Геометрия в допуске",
                geometric_passed=(geo_verdict == QualityVerdict.PASS),
                cnn_passed=True,
                uncertainty_score=0.0,
                audit_trail=audit_trail,
            )

        # Расчет неопределенности
        entropy, margin = self._calculate_uncertainty(cnn_result.all_probabilities)
        audit_trail.append(f"Level 2: CNN Uncertainty -> Entropy={entropy:.3f}, Margin={margin:.3f}")

        # Проверка попадания в серую зону
        if margin < self.config.min_top_margin or entropy > self.config.max_entropy_threshold:
            audit_trail.append("Level 2: High uncertainty -> Escalate to MANUAL_REVIEW")
            return FinalInspectionReport(
                verdict=QualityVerdict.MANUAL_REVIEW,
                primary_reason_code="WARN_HIGH_UNCERTAINTY",
                reason_description=f"Низкая уверенность CNN: Margin={margin:.3f}, Entropy={entropy:.3f}",
                geometric_passed=(geo_verdict == QualityVerdict.PASS),
                cnn_passed=False,
                uncertainty_score=entropy,
                audit_trail=audit_trail,
            )

        # Оценка вероятностей конкретных дефектов
        cnn_verdict, cnn_reasons = self._evaluate_cnn_defects(
            cnn_result.all_probabilities, audit_trail
        )

        # -------------------------------------------------------------
        # Финальный арбитраж: Слияние геометрии и CNN
        # -------------------------------------------------------------
        final_verdict = self._arbitrate(geo_verdict, cnn_verdict)
        all_reasons = geo_reasons + cnn_reasons
        primary_code = "PASS_ALL_VALID" if final_verdict == QualityVerdict.PASS else "DEFECT_DETECTED"

        audit_trail.append(f"Final Arbitration: Geo={geo_verdict.value}, CNN={cnn_verdict.value} -> Final={final_verdict.value}")

        return FinalInspectionReport(
            verdict=final_verdict,
            primary_reason_code=primary_code,
            reason_description="; ".join(all_reasons) or "Изделие полностью соответствует ТУ",
            geometric_passed=(geo_verdict == QualityVerdict.PASS),
            cnn_passed=(cnn_verdict == QualityVerdict.PASS),
            uncertainty_score=entropy,
            audit_trail=audit_trail,
        )

    def _evaluate_geometry(
        self, measurements: Dict[str, float], audit_trail: List[str]
    ) -> tuple[QualityVerdict, List[str]]:
        reasons = []
        worst_verdict = QualityVerdict.PASS

        for feat_name, value in measurements.items():
            tol = self.config.geometric_tolerances.get(feat_name)
            if not tol:
                continue

            if value < tol.lsl:
                msg = f"{feat_name}={value:.3f} < LSL={tol.lsl:.3f}"
                audit_trail.append(f"Geo Check Fail: {msg}")
                reasons.append(msg)
                return QualityVerdict.SCRAP, reasons  # Недостаток тела — немедленный утиль

            if value > tol.usl:
                msg = f"{feat_name}={value:.3f} > USL={tol.usl:.3f}"
                audit_trail.append(f"Geo Check Fail: {msg}")
                reasons.append(msg)
                if tol.is_critical:
                    worst_verdict = QualityVerdict.REWORK

        return worst_verdict, reasons

    def _calculate_uncertainty(self, probs: Dict[str, float]) -> tuple[float, float]:
        prob_values = np.array(list(probs.values()), dtype=np.float32)
        sorted_probs = np.sort(prob_values)[::-1]

        # Top-1 vs Top-2 margin
        margin = float(sorted_probs[0] - sorted_probs[1]) if len(sorted_probs) > 1 else 1.0

        # Нормализованная энтропия Шеннона
        k = len(prob_values)
        if k <= 1:
            return 0.0, margin
        entropy = -float(np.sum(prob_values * np.log(prob_values + 1e-7))) / np.log(k)
        return entropy, margin

    def _evaluate_cnn_defects(
        self, probs: Dict[str, float], audit_trail: List[str]
    ) -> tuple[QualityVerdict, List[str]]:
        reasons = []
        verdicts = [QualityVerdict.PASS]

        for defect_name, prob in probs.items():
            if defect_name == "Normal" or defect_name not in self.config.defect_thresholds:
                continue

            threshold = self.config.defect_thresholds[defect_name]
            severity = self.config.defect_severities.get(defect_name, DefectSeverity.CRITICAL)

            if prob >= threshold:
                msg = f"Дефект '{defect_name}': P={prob:.3f} >= Thresh={threshold:.3f}"
                audit_trail.append(f"CNN Triggered: {msg}")
                reasons.append(msg)

                if severity == DefectSeverity.CRITICAL:
                    verdicts.append(QualityVerdict.SCRAP)
                elif severity == DefectSeverity.REPAIRABLE:
                    verdicts.append(QualityVerdict.REWORK)

        # Выбираем худший вердикт
        if QualityVerdict.SCRAP in verdicts:
            return QualityVerdict.SCRAP, reasons
        if QualityVerdict.REWORK in verdicts:
            return QualityVerdict.REWORK, reasons
        return QualityVerdict.PASS, reasons

    def _arbitrate(self, v_geo: QualityVerdict, v_cnn: QualityVerdict) -> QualityVerdict:
        priority = {
            QualityVerdict.SCRAP: 3,
            QualityVerdict.MANUAL_REVIEW: 2,
            QualityVerdict.REWORK: 1,
            QualityVerdict.PASS: 0,
        }
        return v_geo if priority[v_geo] >= priority[v_cnn] else v_cnn

Контур активного обучения (Active Learning Loop)

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

  1. Изоляция сэмплов: изображения деталей, получивших статус MANUAL_REVIEW, вместе со срезом NormalizedROI и метаданными автоматически сохраняются в кольцевой буфер неразмеченных аномалий.
  2. Экспертная верификация: оператор ОТК в конце смены через специализированный интерфейс подтверждает истинный класс детали.
  3. Целевое дообучение: наиболее информативные сэмплы (с высокой энтропией предсказания модели) направляются в следующий цикл Fine-Tuning классификатора. Это позволяет устранять пробелы в обобщающей способности нейросети без необходимости сплошной разметки сотен тысяч очевидных годных деталей.

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

Сборка инспекционного пайплайна: пакетный инференс, рендеринг телеметрии и стресс-тестирование сервиса

Сборка инспекционного пайплайна: пакетный инференс, рендеринг телеметрии и стресс-тестирование сервиса

Покадровый синхронный запуск алгоритмов машинного зрения в лабораторном Jupyter-ноутбуке скрывает главную проблему промышленной эксплуатации: реальный конвейер не останавливается в ожидании завершения вычислений. Если деталь движется со скоростью 1.2 м/с, а синхронный скрипт тратит 45 мс на геометрическую сегментацию, 15 мс на инференс нейросети и 5 мс на отрисовку графики, последовательное исполнение на одном ядре процессора неминуемо приведет к накоплению очереди кадров и катастрофическому пропуску брака.

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

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

Последовательное выполнение «захват кадра \to OpenCV-предобработка \to инференс \to вердикт» создает жесткую взаимную блокировку вычислительных ресурсов. Пока центральный процессор (CPU) вычисляет моменты инерции контура, графический ускоритель (GPU) простаивает с нулевой утилизацией. Когда управление передается на GPU для прямого прохода нейросети, простаивает подсистема видеозахвата.

Для достижения стабильной пропускной способности сервис проектируется по архитектурному шаблону Producer-Consumer с асинхронными очередями ограниченной емкости (Bounded Queues).

Архитектура разделяет жизненный цикл обработки кадра на три изолированных исполнительных контура:

  1. Контур захвата (Ingestion Worker): опрашивает интерфейс промышленной камеры по аппаратному триггеру, валидирует целостность буфера и помещает сырой кадр с меткой времени tstampt_{\text{stamp}} во входную очередь raw_frame_queue.
  2. Контур геометрической нормализации (CPU Preprocessing Pool): пул рабочих процессов извлекает кадры, выполняет сегментацию, отбраковывает грубую геометрию по правилу Fail-Fast и упаковывает стандартизированные патчи в контракт NormalizedROI.
  3. Контур пакетного анализа и арбитража (GPU Batch Engine & Decision Worker): агрегирует несколько поступивших NormalizedROI в тензорный мини-батч, выполняет инференс на GPU, передает откалиброванные вероятности в Decision Engine и формирует итоговый FinalInspectionReport.

Если скорость поступления деталей временно превышает производительность инспектора, очередь заполняется до предельного размера QmaxQ_{\max}. В соответствии с принципом защитного отказа (Fail-Safe), система не должна молча отбрасывать кадры: при переполнении очереди сервис немедленно генерирует для «потерянного» кадра статус MANUAL_REVIEW или SCRAP, предотвращая попадание непроверенной продукции в партию годных изделий.

Динамический микробатчинг и баланс задержки

Выполнение прямого прохода нейросети для каждого единичного изображения размера 224×224×3224 \times 224 \times 3 с размером пакета B=1B = 1 не позволяет утилизировать массив параллельных ядер CUDA. Графический процессор тратит больше времени на накладные расходы запуска вычислительных ядер (Kernel Launch Overhead) и передачу данных по шине PCIe, чем на сами матричные умножения.

Увеличение размера батча до B=8B = 8 или B=16B = 16 повышает пропускную способность в разы, однако наивное ожидание полного заполнения батча создает недопустимую задержку для первой детали, уже находящейся в очереди.

Решением выступает алгоритм динамического микробатчинга (Dynamic Micro-Batching). Инференс-воркер непрерывно формирует батч по двум взаимодополняющим критериям:

Trigger=(Nitems==Bmax)(ΔtwaitTmax_wait)\text{Trigger} = (N_{\text{items}} == B_{\max}) \lor (\Delta t_{\text{wait}} \geq T_{\text{max\_wait}})

Где:

  • NitemsN_{\text{items}} — текущее число накопленных патчей в буфере;
  • BmaxB_{\max} — максимально допустимый размер батча для модели;
  • Δtwait\Delta t_{\text{wait}} — время ожидания самого старого элемента в буфере;
  • Tmax_waitT_{\text{max\_wait}} — предельный таймаут сбора батча, гарантирующий непревышение бюджета задержки.

Полное время нахождения изделия в программном конвейере складывается из четырех компонент:

Tservice=Tgeom+Twait+Tinfer(B)+TdecisionT_{\text{service}} = T_{\text{geom}} + T_{\text{wait}} + T_{\text{infer}}(B) + T_{\text{decision}}

Где:

  • TgeomT_{\text{geom}} — длительность сегментации и аффинной нормализации ROI на CPU (например, 12 мс12 \text{ мс});
  • TwaitT_{\text{wait}} — время нахождения патча в очереди микробатчера до момента запуска инференса (от 00 до Tmax_waitT_{\text{max\_wait}});
  • Tinfer(B)T_{\text{infer}}(B) — длительность прямого прохода нейросети для батча размера BB на GPU (например, 4 мс4 \text{ мс} при B=1B=1 и 7 мс7 \text{ мс} при B=8B=8);
  • TdecisionT_{\text{decision}} — расчет энтропии, маржи и арбитраж вердикта в Decision Engine (0.5 мс\approx 0.5 \text{ мс}).

Если технологический такт конвейера составляет 500 мс500 \text{ мс}, а допустимый программный бюджет задержки Tbudget=80 мсT_{\text{budget}} = 80 \text{ мс}, то значение Tmax_waitT_{\text{max\_wait}} настраивается в диапазоне 1025 мс10 \text{--} 25 \text{ мс}, обеспечивая устойчивый баланс между параллелизмом GPU и минимальной задержкой одиночной детали.

Обратная проекция дефектов и генерация графического оверлея (HUD)

Для операторов линии и цеховых систем видеомониторинга сервис обязан формировать кадр с наглядным графическим оверлеем (Heads-Up Display, HUD).

Нейросеть классифицирует стандартизированный патч 224×224224 \times 224, где деталь уже повернута и отцентрирована. Чтобы подсветить зону дефекта на исходном несжатом кадре конвейера с сохранением честной пространственной ориентации, применяется обратная аффинная трансформация.

При нормализации ROI модуль ROIExtractor вычисляет прямую матрицу аффинного преобразования MdirectM_{\text{direct}} размера 2×32 \times 3. Любая локальная точка дефекта (xroi,yroi)(x_{\text{roi}}, y_{\text{roi}}), обнаруженная на стандартизированном патче, пересчитывается в глобальные координаты исходного кадра (xorig,yorig)(x_{\text{orig}}, y_{\text{orig}}) с помощью обратной матрицы MinvM_{\text{inv}}:

[xorigyorig]=Minv×[xroiyroi1]\begin{bmatrix} x_{\text{orig}} \\ y_{\text{orig}} \end{bmatrix} = M_{\text{inv}} \times \begin{bmatrix} x_{\text{roi}} \\ y_{\text{roi}} \\ 1 \end{bmatrix}

Где:

  • MinvM_{\text{inv}} — обратная аффинная матрица преобразования 2×32 \times 3, получаемая из прямой матрицы вызовом cv2.invertAffineTransform(M_direct);
  • (xroi,yroi)(x_{\text{roi}}, y_{\text{roi}}) — декартовы координаты подозрительной области на каноническом растре 224×224224 \times 224;
  • (xorig,yorig)(x_{\text{orig}}, y_{\text{orig}}) — результирующие физические координаты пикселя на исходном полноразмерном конвейерном кадре.
import cv2
import numpy as np

def project_roi_points_to_frame(
    points_roi: np.ndarray,
    affine_matrix_direct: np.ndarray
) -> np.ndarray:
    """
    Трансформирует координаты точек или контуров из системы координат
    стандартизированного ROI обратно в глобальные координаты исходного кадра.
    """
    m_inv = cv2.invertAffineTransform(affine_matrix_direct)
    # Преобразуем массив (N, 2) в однородные координаты (N, 3)
    ones = np.ones((points_roi.shape[0], 1), dtype=np.float32)
    homogeneous_pts = np.hstack([points_roi.astype(np.float32), ones])

    # Вычисляем матричное произведение: (N, 3) x (3, 2) -> (N, 2)
    orig_pts = homogeneous_pts @ m_inv.T
    return np.round(orig_pts).astype(np.int32)

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

  • Зеленый (PASS): контур детали в допусках, отображение центроида и метки «Годно»;
  • Желтый (REWORK): выделение зоны наплыва или заусенца с указанием избыточной площади;
  • Красный (SCRAP): подсветка критической трещины, перелома геометрии или глубокого скола;
  • Синий (MANUAL_REVIEW): рамка сомнения классификатора (высокая энтропия / низкая маржа), сигнализирующая о необходимости ручного контроля.

В верхней части кадра формируется плашка телеметрии: время полной обработки TserviceT_{\text{service}}, маржа уверенности, присвоенный QualityGrade и текстовый код дефекта для журнала аудита (Audit Trail).

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

Объединим все разработанные компоненты в единый класс DefectInspectionPipeline. Сервис принимает на вход сырой BGR-кадр, изолирует этапы через контракты DTO, использует модель с калиброванной температурой и генерирует на выходе как бинарный управляющий сигнал для контроллера (PLC), так и графический кадр с телеметрией.

import time
from dataclasses import dataclass
from typing import Tuple, List, Optional
import cv2
import numpy as np
import torch
import torch.nn.functional as F

from core.geometry import ROIExtractor, NormalizedROI, GeometricInspectionResult
from core.decision import (
    IndustrialDecisionEngine,
    DecisionEngineConfig,
    FinalInspectionReport,
    QualityGrade
)

@dataclass(frozen=True)
class PipelineResult:
    report: FinalInspectionReport
    visualized_frame: np.ndarray
    total_latency_ms: float

class DefectInspectionPipeline:
    def __init__(
        self,
        roi_extractor: ROIExtractor,
        classifier_model: torch.nn.Module,
        temperature: float,
        decision_engine: IndustrialDecisionEngine,
        class_names: List[str],
        device: torch.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    ):
        self.roi_extractor = roi_extractor
        self.model = classifier_model.to(device).eval()
        self.temperature = float(temperature)
        self.decision_engine = decision_engine
        self.class_names = class_names
        self.device = device

        # Стандартизированные статистики ImageNet
        self._mean = torch.tensor([0.485, 0.456, 0.406], device=device).view(1, 3, 1, 1)
        self._std = torch.tensor([0.229, 0.224, 0.225], device=device).view(1, 3, 1, 1)

    def _preprocess_roi_tensor(self, roi_bgr: np.ndarray) -> torch.Tensor:
        """Трансформация BGR uint8 массива в стандартизированный тензор NCHW."""
        rgb = cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2RGB)
        tensor = torch.from_numpy(rgb).to(self.device).float() / 255.0
        # HWC -> CHW -> NCHW
        tensor = tensor.permute(2, 0, 1).unsqueeze(0)
        return (tensor - self._mean) / self._std

    @torch.inference_mode()
    def _run_neural_inference(self, roi_tensor: torch.Tensor) -> Tuple[np.ndarray, np.ndarray]:
        """Прямой проход с температурным масштабированием."""
        logits = self.model(roi_tensor)
        calibrated_logits = logits / self.temperature
        probabilities = F.softmax(calibrated_logits, dim=-1)
        return logits.cpu().numpy()[0], probabilities.cpu().numpy()[0]

    def _render_hud(
        self,
        frame: np.ndarray,
        report: FinalInspectionReport,
        norm_roi: Optional[NormalizedROI]
    ) -> np.ndarray:
        """Детерминированная отрисовка графической телеметрии."""
        overlay = frame.copy()
        h, w = overlay.shape[:2]

        # Цветовая схема вердикта BGR
        palette = {
            QualityGrade.PASS: (0, 200, 0),
            QualityGrade.REWORK: (0, 215, 255),
            QualityGrade.SCRAP: (0, 0, 230),
            QualityGrade.MANUAL_REVIEW: (230, 150, 0)
        }
        color = palette.get(report.final_grade, (0, 0, 255))

        # Отрисовка ориентированного бокса детали
        if norm_roi is not None and norm_roi.oriented_box is not None:
            cv2.polylines(overlay, [norm_roi.oriented_box], isClosed=True, color=color, thickness=2)
            cx, cy = int(norm_roi.center_xy[0]), int(norm_roi.center_xy[1])
            cv2.drawMarker(overlay, (cx, cy), color, cv2.MARKER_CROSS, markerSize=16, thickness=2)

        # Верхняя информационная плашка
        cv2.rectangle(overlay, (0, 0), (w, 45), (30, 30, 30), -1)
        header_text = (
            f"GRADE: {report.final_grade.value} | CODE: {report.primary_defect_code} | "
            f"CONF: {report.confidence_margin:.2f} | LATENCY: {report.latency_ms:.1f}ms"
        )
        cv2.putText(overlay, header_text, (15, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
        cv2.line(overlay, (0, 45), (w, 45), color, 3)
        return overlay

    def process_frame(self, frame: np.ndarray, frame_id: str) -> PipelineResult:
        """Сквозной такт инспекции единичного кадра."""
        t_start = time.perf_counter()

        # 1. Геометрическая стандартизация ROI (OpenCV)
        norm_roi, geom_result = self.roi_extractor.extract(frame)

        # 2. Обработка Fail-Fast (грубый геометрический брак)
        if not geom_result.passed_coarse_filter or norm_roi is None:
            report = self.decision_engine.arbitrate(
                geom_result=geom_result,
                probabilities=None,
                logits=None,
                latency_ms=(time.perf_counter() - t_start) * 1000.0,
                frame_id=frame_id
            )
            hud = self._render_hud(frame, report, norm_roi)
            return PipelineResult(report, hud, report.latency_ms)

        # 3. Подготовка тензора и инференс CNN
        roi_tensor = self._preprocess_roi_tensor(norm_roi.roi_patch)
        logits, probabilities = self._run_neural_inference(roi_tensor)

        # 4. Арбитраж в Decision Engine
        t_elapsed = (time.perf_counter() - t_start) * 1000.0
        report = self.decision_engine.arbitrate(
            geom_result=geom_result,
            probabilities=probabilities,
            logits=logits,
            latency_ms=t_elapsed,
            frame_id=frame_id
        )

        # 5. Генерация HUD телеметрии
        hud = self._render_hud(frame, report, norm_roi)
        total_latency = (time.perf_counter() - t_start) * 1000.0

        return PipelineResult(report, hud, total_latency)

Стресс-тестирование и нагрузочный бенчмаркинг

Готовность сервиса к развертыванию на промышленной линии подтверждается не средним временем работы (mean latency), а устойчивостью хвостовых распределений задержек. Единичная задержка в 120 мс120 \text{ мс} на детали при среднем времени в 25 мс25 \text{ мс} приведет к физическому удару пневмотолкателя в корпус следующего изделия.

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

Профиль испытания Моделируемое воздействие Контролируемый показатель устойчивости Критерий прохождения
Peak Load (Максимальный такт) Подача 250 кадров/сек при номинале 120 P95P_{95} и P99P_{99} времени отклика, отсутствие утечек RAM/VRAM P99TbudgetP_{99} \leq T_{\text{budget}}, утечка памяти =0 МБ= 0 \text{ МБ}
Optical Corruption (Сбой света) Снижение экспозиции на 40%, блики, расфокусировка Доля срабатывания MANUAL_REVIEW / Fail-Fast 100% сомнительных кадров уходят в REVIEW / SCRAP
Malformed Inputs (Аномалии захвата) Нулевые матрицы, битые кадры 0×00 \times 0, шумы матрицы Изоляция исключений, непрерывность сервиса 0 аварийных завершений (crash-free), статус SCRAP

Метрики задержки: почему среднее значение обманчиво

В высоконагруженных системах вычисляются перцентили времени выполнения:

  • P50P_{50} (Медиана): типовое время инспекции заготовки без краевых дефектов;
  • P95P_{95}: время обработки сложных контуров с зашумленными границами, требующими глубокой морфологии;
  • P99P_{99}: наихудший сценарий, включающий накладные расходы сборщика мусора Python (Garbage Collection) и переключения контекста потоков ОС.
def benchmark_pipeline_latency(
    pipeline: DefectInspectionPipeline,
    synthetic_frames: List[np.ndarray],
    num_iterations: int = 1000
) -> dict:
    """Измерение перцентилей задержки P50, P95, P99 на синтетическом потоке."""
    latencies = []
    num_samples = len(synthetic_frames)

    # Прогрев GPU (Warm-up)
    for i in range(50):
        pipeline.process_frame(synthetic_frames[i % num_samples], frame_id="warmup")

    for i in range(num_iterations):
        frame = synthetic_frames[i % num_samples]
        t0 = time.perf_counter()
        _ = pipeline.process_frame(frame, frame_id=f"bench_{i}")
        latencies.append((time.perf_counter() - t0) * 1000.0)

    latencies = np.array(latencies)
    return {
        "mean_ms": float(np.mean(latencies)),
        "p50_ms": float(np.percentile(latencies, 50)),
        "p95_ms": float(np.percentile(latencies, 95)),
        "p99_ms": float(np.percentile(latencies, 99)),
        "max_ms": float(np.max(latencies))
    }

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

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