Лабораторна робота 16
Лабораторна робота №16. Класична обробка зображень і геометричні ознаки
Мета: навчитися створювати відтворювані синтетичні зображення, виконувати перетворення BGR/HSV/grayscale, порогову обробку, фільтрацію, морфологію та аналіз контурів; реалізувати класифікацію навчальних кольорових форм із числовою впевненістю та обов'язковим станом UNKNOWN.
Результати навчання та передумови
Після роботи студент уміє:
- пояснити відмінність BGR, RGB, HSV і grayscale;
- побудувати маску кольору, очистити її морфологічними операціями й знайти контур;
- обчислити площу, периметр, центр, кількість вершин, circularity та solidity;
- не плутати двовимірну проєкцію з доведеною тривимірною формою;
- повернути
UNKNOWNдля порожнього, малого, неоднозначного або граничного об'єкта; - виміряти точність на синтетичному наборі без камери.
Передумови: 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та числову причину/впевненість. - Жоден імпорт не відкриває камеру або апаратний порт.
Хід виконання роботи
- Перевірити контракт класифікатора через stdlib mock.
- Створити зовнішню конфігурацію.
- Згенерувати позитивні, негативні й граничні кадри.
- Побудувати HSV-маски та виконати морфологію.
- Обчислити геометричні ознаки й класифікувати.
- Зберегти анотовані результати й таблицю метрик.
Методичні вказівки й теоретичні відомості
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.
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. Перевірені джерела
- OpenCV: перетворення кольорових просторів;
- OpenCV: порогова обробка;
- OpenCV: морфологічні операції;
- OpenCV: ознаки контурів;
- офіційний довідник NumPy;
- програма ЛР-16.
Виконання лабораторної роботи
Крок 1. Safe-smoke політики невизначеності
Цей блок не використовує NumPy/OpenCV і перевіряє сам контракт рішення.
# 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. Пороги перевіряються тільки для синтетичних кольорів цієї роботи.
{
"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. Програма не відкриває камеру.
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. Запуск і перевірка артефактів
У підготовленому середовищі виконайте:
python lab16_cv.py --config lab16-config.json --out lab16-output
Перевірте, що файли не порожні, а JSON розбирається:
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² | Circularity | Solidity | Color score | Confidence | Причина |
|---|---|---|---|---|---|---|---|---|
| red-cylinder | candidate | |||||||
| blue-cube | candidate | |||||||
| empty | UNKNOWN | |||||||
| tiny | UNKNOWN | |||||||
| ambiguous | UNKNOWN | |||||||
| border | UNKNOWN |
Додатково обчисліть coverage = частка не-UNKNOWN і selective accuracy = точність лише серед прийнятих. Обидві метрики потрібні: 100% accuracy можна штучно отримати, відхиливши майже все.
Вимоги до звіту
Див. загальні вимоги. Додайте конфігурацію, текст коду, матрицю тестів, таблицю ознак, по одному анотованому позитивному й негативному кадру, дослід одного порога, coverage/selective accuracy та явне зазначення, що камера й фізичний комплекс не використовувалися.
Критерії оцінювання
| Складова | Частка | Що оцінюється |
|---|---|---|
| Підготовка | 10% | середовище, конфігурація, терміни й safety |
| Реалізація | 40% | генератор, HSV/gray, морфологія, контури, UNKNOWN |
| Перевірка | 25% | 2 позитивні, 2 негативні, 2 граничні сценарії |
| Аналіз | 15% | ознаки, чутливість порога, coverage і обмеження 2D |
| Звіт | 10% | код, таблиці, підписані результати й відтворюваність |
| Разом | 100% |
Контрольні питання
- Чому червоний HSV-діапазон розбитий на дві частини?
- Чим opening відрізняється від closing?
- Коли моменти не дають коректного центра?
- Чому circularity не дорівнює точно 1 для растрового кола?
- Чому квадратна проєкція не доводить, що об'єкт є кубом?
- Навіщо аналізувати coverage разом із точністю?
Висновки
Поясніть, які ознаки дали правильні рішення на синтетичних даних, які випадки стали UNKNOWN, як пороги вплинули на coverage та яких матеріалів бракує для перевірки на камері MERC-I5.