MERCI SPACE
Навчальні матеріали

Лабораторна робота 16

Лабораторна робота №16. Класична обробка зображень і геометричні ознаки

Мета: навчитися створювати відтворювані синтетичні зображення, виконувати перетворення BGR/HSV/grayscale, порогову обробку, фільтрацію, морфологію та аналіз контурів; реалізувати класифікацію навчальних кольорових форм із числовою впевненістю та обов'язковим станом UNKNOWN.

Результати навчання та передумови

Після роботи студент уміє:

  1. пояснити відмінність BGR, RGB, HSV і grayscale;
  2. побудувати маску кольору, очистити її морфологічними операціями й знайти контур;
  3. обчислити площу, периметр, центр, кількість вершин, circularity та solidity;
  4. не плутати двовимірну проєкцію з доведеною тривимірною формою;
  5. повернути UNKNOWN для порожнього, малого, неоднозначного або граничного об'єкта;
  6. виміряти точність на синтетичному наборі без камери.

Передумови: Python, масиви, координати зображення, базова статистика; рекомендовано виконати ЛР-09.

Середовище виконання

СередовищеСтатусПримітка
Google ColabдозволеноCPU достатньо; зберігати результати як файли, не використовувати камеру
Локальний ПКосновнеPython, NumPy, OpenCV; точні версії стенда не затверджені
Raspberry PiдозволеноЛише офлайн на синтетичних кадрах після фіксації середовища
СимуляціяосновнеЗображення генеруються детерміновано кодом
Фізичний комплекс MERC-I5не потрібенКамера, конвеєр і робот не використовуються

Необхідні знання та матеріали

  • Python зі стандартною бібліотекою для safe-smoke;
  • контрольоване середовище з NumPy та OpenCV для основної частини;
  • каталог для результатів без персональних даних;
  • конфігурація порогів lab16-config.json, наведена нижче; її значення призначені лише для згенерованих BGR-кольорів.

Ризики та правила безпеки

Ризики

Алгоритм може помилково класифікувати тінь, відблиск або частково видимий об'єкт. Навіть висока метрика на синтетичних даних не є доказом роботи з реальною камерою. Якщо результат керує механізмом, хибний клас може спричинити неправильний маршрут або небезпечний рух.

Заборонені дії

  • не підключати вихід класифікатора до MG400, конвеєра, подавача або грипера;
  • не подавати зображення людей або персональні дані;
  • не підміняти невизначеність найближчим класом;
  • не оголошувати «куб» чи «циліндр» доведеним лише за одним контуром: у цій роботі це кандидати за проєкцією.

Умови негайної зупинки

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

Безпечний стан

Програма лише створює файли в каталозі результатів. Будь-яка невизначеність повертає UNKNOWN; апаратні виходи відсутні.

Передпусковий чекліст

  • Вхідні кадри синтетичні й не містять персональних даних.
  • Вихідний каталог окремий від images/ і не перезаписує оригінали.
  • Пороги завантажуються з JSON, а не приховані в умовах класифікатора.
  • Передбачено UNKNOWN та числову причину/впевненість.
  • Жоден імпорт не відкриває камеру або апаратний порт.

Хід виконання роботи

  1. Перевірити контракт класифікатора через stdlib mock.
  2. Створити зовнішню конфігурацію.
  3. Згенерувати позитивні, негативні й граничні кадри.
  4. Побудувати HSV-маски та виконати морфологію.
  5. Обчислити геометричні ознаки й класифікувати.
  6. Зберегти анотовані результати й таблицю метрик.

Методичні вказівки й теоретичні відомості

1. Простори кольору

OpenCV читає/створює кольорові масиви у порядку BGR. RGB відрізняється порядком каналів. HSV відокремлює тон H від насиченості S і яскравості V, тому зручний для навчальної сегментації, але пороги залежать від освітлення й камери. У 8-бітному OpenCV канал H має діапазон 0–179; червоний перетинає межу шкали, тому потребує двох інтервалів. Grayscale зберігає інтенсивність, але не колір.

2. Порогова обробка, морфологія й контури

inRange формує бінарну маску. Opening (erosion → dilation) усуває дрібні білі шуми; closing (dilation → erosion) заповнює малі розриви. Надто велике ядро знищує малий об'єкт, тому параметр треба перевіряти граничними тестами.

Для контуру:

[ C = \frac{4\pi A}{P^2}, ]

де A — площа, P — периметр. Для ідеального кола C наближається до 1, але дискретизація й перспектива зменшують значення. Solidity — відношення площі контуру до площі його опуклої оболонки. Центр за моментами визначають лише коли m00 != 0.

3. Політика UNKNOWN

Клас приймається лише якщо водночас підтверджені мінімальна площа, однозначний колір, геометрична ознака та поріг confidence. UNKNOWN є коректним виробничим станом, а не помилкою, яку треба приховати.

Текстовий опис рисунка: синтетичний BGR-кадр послідовно перетворюється у HSV/grayscale, кольорову маску, очищену морфологією маску, контур та набір ознак; пороговий gate веде або до кандидата класу, або до UNKNOWN.

Mermaid
flowchart LR
    GEN["Синтетичний BGR-кадр"] --> HSV["BGR → HSV / gray"]
    HSV --> MASK["Порогові маски кольору"]
    MASK --> MORPH["Opening + closing"]
    MORPH --> CONT["Контур і моменти"]
    CONT --> FEAT["Площа, центр, vertices, circularity, solidity"]
    FEAT --> GATE{"Усі пороги пройдено?"}
    GATE -->|так| CLASS["Кандидат cube/cylinder"]
    GATE -->|ні| UNKNOWN["UNKNOWN"]

Рис. 1. Ілюстративний конвеєр класичної обробки з обов'язковим UNKNOWN

4. Перевірені джерела

Виконання лабораторної роботи

Крок 1. Safe-smoke політики невизначеності

Цей блок не використовує NumPy/OpenCV і перевіряє сам контракт рішення.

Python
# safe-smoke
from dataclasses import dataclass

@dataclass(frozen=True)
class Features:
    area: float
    color_score: float
    shape_score: float

def decide(f: Features) -> str:
    values = (f.area, f.color_score, f.shape_score)
    if any(value != value for value in values):
        return "UNKNOWN"
    if f.area < 500 or f.color_score < 0.70 or f.shape_score < 0.70:
        return "UNKNOWN"
    return "KNOWN"

assert decide(Features(1200, 0.90, 0.85)) == "KNOWN"
assert decide(Features(499, 0.99, 0.99)) == "UNKNOWN"
assert decide(Features(1200, 0.69, 0.99)) == "UNKNOWN"
assert decide(Features(float("nan"), 0.99, 0.99)) == "UNKNOWN"
print("safe-smoke: UNKNOWN policy PASS")

Очікуваний результат: safe-smoke: UNKNOWN policy PASS.

Критерій правильності: усі граничні assertions проходять, код завершується 0.

Якщо результат не отримано: не запускати основний алгоритм; перевірити, що пороги порівнюються включно/виключно саме як описано.

Крок 2. Створення конфігурації

Збережіть як lab16-config.json. Пороги перевіряються тільки для синтетичних кольорів цієї роботи.

JSON
{
  "min_area": 500.0,
  "min_color_score": 0.70,
  "min_shape_score": 0.70,
  "morph_kernel": 5,
  "red_ranges": [[[0, 120, 80], [10, 255, 255]], [[170, 120, 80], [179, 255, 255]]],
  "blue_ranges": [[[100, 120, 80], [130, 255, 255]]]
}

Очікуваний результат: валідний JSON із непарним додатним morph_kernel.

Критерій правильності: python -m json.tool lab16-config.json завершується без помилки.

Якщо результат не отримано: виправити JSON; не переносити пороги до вихідного коду.

Крок 3. Реалізація відтворюваного CV-сценарію

Збережіть як lab16_cv.py. Програма не відкриває камеру.

Python
from __future__ import annotations

import argparse
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path

import cv2
import numpy as np


@dataclass(frozen=True)
class Result:
    sample: str
    label: str
    reason: str
    confidence: float
    area: float
    circularity: float
    solidity: float
    vertices: int
    center_x: int | None
    center_y: int | None


def synthetic_samples() -> dict[str, np.ndarray]:
    def canvas() -> np.ndarray:
        return np.full((240, 320, 3), 245, dtype=np.uint8)

    red_cylinder = canvas()
    cv2.circle(red_cylinder, (160, 120), 48, (0, 0, 255), -1)
    blue_cube = canvas()
    cv2.rectangle(blue_cube, (112, 72), (208, 168), (255, 0, 0), -1)
    empty = canvas()
    tiny = canvas()
    cv2.circle(tiny, (160, 120), 8, (0, 0, 255), -1)
    ambiguous = canvas()
    cv2.rectangle(ambiguous, (105, 70), (160, 170), (0, 0, 255), -1)
    cv2.rectangle(ambiguous, (160, 70), (215, 170), (255, 0, 0), -1)
    border = canvas()
    cv2.circle(border, (8, 120), 48, (0, 0, 255), -1)
    return {
        "red-cylinder": red_cylinder,
        "blue-cube": blue_cube,
        "empty": empty,
        "tiny": tiny,
        "ambiguous": ambiguous,
        "border": border,
    }


def mask_ranges(hsv: np.ndarray, ranges: list[list[list[int]]]) -> np.ndarray:
    result = np.zeros(hsv.shape[:2], dtype=np.uint8)
    for low, high in ranges:
        result = cv2.bitwise_or(
            result,
            cv2.inRange(hsv, np.array(low, np.uint8), np.array(high, np.uint8)),
        )
    return result


def classify(name: str, image: np.ndarray, cfg: dict) -> tuple[Result, np.ndarray, np.ndarray]:
    hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    red = mask_ranges(hsv, cfg["red_ranges"])
    blue = mask_ranges(hsv, cfg["blue_ranges"])
    combined = cv2.bitwise_or(red, blue)
    k = int(cfg["morph_kernel"])
    if k <= 0 or k % 2 == 0:
        raise ValueError("morph_kernel must be a positive odd integer")
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
    clean = cv2.morphologyEx(combined, cv2.MORPH_OPEN, kernel)
    clean = cv2.morphologyEx(clean, cv2.MORPH_CLOSE, kernel)
    contours, _ = cv2.findContours(clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    annotated = image.copy()
    if not contours:
        return Result(name, "UNKNOWN", "no-contour", 0.0, 0.0, 0.0, 0.0, 0, None, None), annotated, gray

    contour = max(contours, key=cv2.contourArea)
    area = float(cv2.contourArea(contour))
    perimeter = float(cv2.arcLength(contour, True))
    circularity = 0.0 if perimeter == 0 else 4.0 * math.pi * area / (perimeter * perimeter)
    hull_area = float(cv2.contourArea(cv2.convexHull(contour)))
    solidity = 0.0 if hull_area == 0 else area / hull_area
    approx = cv2.approxPolyDP(contour, 0.02 * perimeter, True)
    moments = cv2.moments(contour)
    cx = None if moments["m00"] == 0 else int(moments["m10"] / moments["m00"])
    cy = None if moments["m00"] == 0 else int(moments["m01"] / moments["m00"])

    contour_mask = np.zeros(clean.shape, dtype=np.uint8)
    cv2.drawContours(contour_mask, [contour], -1, 255, -1)
    pixels = max(1, cv2.countNonZero(contour_mask))
    red_score = cv2.countNonZero(cv2.bitwise_and(red, contour_mask)) / pixels
    blue_score = cv2.countNonZero(cv2.bitwise_and(blue, contour_mask)) / pixels
    color, color_score = max((("red", red_score), ("blue", blue_score)), key=lambda item: item[1])
    color_margin = abs(red_score - blue_score)

    # Спершу перевіряємо чотирикутник: circularity ідеального квадрата ≈ 0.785,
    # тому низький поріг кола без цього порядку помилково прийняв би квадрат.
    if len(approx) == 4 and solidity >= 0.90:
        shape, shape_score = "cube-candidate", min(1.0, solidity)
    elif circularity >= 0.82:
        shape, shape_score = "cylinder-candidate", min(1.0, circularity)
    else:
        shape, shape_score = "UNKNOWN", 0.0

    confidence = min(color_score, shape_score)
    touches_border = bool(
        np.any(contour[:, :, 0] <= 0)
        or np.any(contour[:, :, 1] <= 0)
        or np.any(contour[:, :, 0] >= image.shape[1] - 1)
        or np.any(contour[:, :, 1] >= image.shape[0] - 1)
    )
    gates = [
        (area >= float(cfg["min_area"]), "area-too-small"),
        (color_score >= float(cfg["min_color_score"]), "low-color-score"),
        (color_margin >= 0.20, "ambiguous-color"),
        (shape_score >= float(cfg["min_shape_score"]), "unknown-shape"),
        (not touches_border, "touches-border"),
        (cx is not None and cy is not None, "invalid-center"),
    ]
    failed = [reason for passed, reason in gates if not passed]
    label = "UNKNOWN" if failed else f"{color}-{shape}"
    reason = ",".join(failed) if failed else "accepted"
    cv2.drawContours(annotated, [contour], -1, (0, 180, 0), 2)
    cv2.putText(annotated, label, (8, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (30, 30, 30), 2)
    result = Result(name, label, reason, confidence, area, circularity, solidity, len(approx), cx, cy)
    return result, annotated, gray


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", type=Path, required=True)
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    cfg = json.loads(args.config.read_text(encoding="utf-8"))
    args.out.mkdir(parents=True, exist_ok=True)
    results: list[Result] = []
    for name, image in synthetic_samples().items():
        result, annotated, gray = classify(name, image, cfg)
        results.append(result)
        cv2.imwrite(str(args.out / f"{name}-annotated.png"), annotated)
        cv2.imwrite(str(args.out / f"{name}-gray.png"), gray)
    labels = {result.sample: result.label for result in results}
    assert labels["red-cylinder"] == "red-cylinder-candidate"
    assert labels["blue-cube"] == "blue-cube-candidate"
    for sample in ("empty", "tiny", "ambiguous", "border"):
        assert labels[sample] == "UNKNOWN", (sample, labels[sample])
    (args.out / "metrics.json").write_text(
        json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print(f"PASS: {len(results)} synthetic samples; outputs={args.out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Очікуваний результат: два прийняті кандидати й чотири UNKNOWN; створені анотовані та grayscale-файли й metrics.json.

Критерій правильності: усі assertions проходять; однакові вхідні файли дають однаковий JSON.

Якщо результат не отримано: не послаблювати пороги навмання; зберегти маску/ознаки, знайти конкретний gate і виправити причину.

Крок 4. Запуск і перевірка артефактів

У підготовленому середовищі виконайте:

Bash
python lab16_cv.py --config lab16-config.json --out lab16-output

Перевірте, що файли не порожні, а JSON розбирається:

Bash
python -m json.tool lab16-output/metrics.json

Очікуваний результат: повідомлення PASS: 6 synthetic samples і шість записів метрик.

Критерій правильності: labels відповідають матриці тестів; confidence скінченний і в межах 0…1.

Якщо результат не отримано: перевірити версії й доступ на запис; не переходити до реальних кадрів.

Крок 5. Дослідження граничних параметрів

Створіть копії конфігурації з min_area 499, 500 і 501 та з ядрами 3, 5, 9. Порівняйте кількість UNKNOWN, не змінюючи тестові кадри.

Очікуваний результат: таблиця чутливості порогів; гранична поведінка пояснена.

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

Якщо результат не отримано: очистити окремий вихідний каталог і повторити; не змішувати результати різних конфігурацій.

Перенесення на реальні деталі: еталон і прогалина

Для фактичних кубів/циліндрів потрібні контрольоване освітлення, фон, ROI, дані різних поворотів і висот, окремий test-набір та правило відхилення. Класична 2D-ознака має бути перевірена на реальних проєкціях; за потреби додають інші ознаки або багаторакурсне спостереження.

Сценарії перевірки

ТипВхідОчікуванняПеревірювана причина
позитивнийвелике червоне колоred-cylinder-candidateколір + circularity
позитивнийвеликий синій квадратblue-cube-candidateколір + 4 vertices + solidity
негативнийпорожній кадрUNKNOWN/no-contourнемає контуру
негативнийдвокольоровий прямокутникUNKNOWN/ambiguous-colorмалий color margin
граничниймале колоUNKNOWN/area-too-smallплоща нижче порога
граничнийобрізане колоUNKNOWN/touches-borderнеповний контур

Таблиці вимірювань і метрики

ЗразокІстинний класРішенняПлоща, px²CircularitySolidityColor scoreConfidenceПричина
red-cylindercandidate
blue-cubecandidate
emptyUNKNOWN
tinyUNKNOWN
ambiguousUNKNOWN
borderUNKNOWN

Додатково обчисліть coverage = частка не-UNKNOWN і selective accuracy = точність лише серед прийнятих. Обидві метрики потрібні: 100% accuracy можна штучно отримати, відхиливши майже все.

Вимоги до звіту

Див. загальні вимоги. Додайте конфігурацію, текст коду, матрицю тестів, таблицю ознак, по одному анотованому позитивному й негативному кадру, дослід одного порога, coverage/selective accuracy та явне зазначення, що камера й фізичний комплекс не використовувалися.

Критерії оцінювання

СкладоваЧасткаЩо оцінюється
Підготовка10%середовище, конфігурація, терміни й safety
Реалізація40%генератор, HSV/gray, морфологія, контури, UNKNOWN
Перевірка25%2 позитивні, 2 негативні, 2 граничні сценарії
Аналіз15%ознаки, чутливість порога, coverage і обмеження 2D
Звіт10%код, таблиці, підписані результати й відтворюваність
Разом100%

Контрольні питання

  1. Чому червоний HSV-діапазон розбитий на дві частини?
  2. Чим opening відрізняється від closing?
  3. Коли моменти не дають коректного центра?
  4. Чому circularity не дорівнює точно 1 для растрового кола?
  5. Чому квадратна проєкція не доводить, що об'єкт є кубом?
  6. Навіщо аналізувати coverage разом із точністю?

Висновки

Поясніть, які ознаки дали правильні рішення на синтетичних даних, які випадки стали UNKNOWN, як пороги вплинули на coverage та яких матеріалів бракує для перевірки на камері MERC-I5.

MERCI SPACE