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

Лабораторна робота 9

Лабораторна робота №09. Python-модуль камери

Мета: навчитися проєктувати єдиний апаратно незалежний інтерфейс камери; реалізувати mock відкриття потоку, отримання кадру з часовою позначкою, контролю роздільної здатності й області інтересу, виявлення втрати/порушення послідовності та безпечного збереження діагностичного кадру без персональних метаданих.

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

Після виконання студент уміє:

  • визначати контракт open/read/status/close незалежно від Picamera2, V4L2 або OpenCV;
  • розрізняти часову позначку захоплення, час обробки й номер послідовності;
  • перевіряти геометрію кадру та область інтересу (ROI);
  • виявляти кінець потоку, надмірний часовий розрив і немонотонний час;
  • не повторно використовувати «останній добрий кадр» після втрати потоку;
  • зберігати мінімальний діагностичний артефакт без EXIF, GPS, мережевих даних або ідентифікаторів людей;
  • документувати параметри камери, які ще не підтверджені на MERC-I5.

Передумови: Python (Protocol, dataclass, bytes, pathlib, винятки), основи цифрового зображення та ЛР-04–08. OpenCV не потрібний для обов'язкової частини.

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

СередовищеСтатусПримітка
Google ColabдозволеноЛише синтетичні кадри; камера браузера не використовується
Локальний ПКосновнеСамодостатній Python mock зі стандартною бібліотекою
Raspberry PiдозволеноТільки mock; не відкривати CSI/USB/V4L2 у цій редакції
Фізичний комплекс MERC-I5не потрібенКамери не активуються; фото/відео людей не збираються

Точні версії Python, Raspberry Pi OS, Picamera2, libcamera й OpenCV для курсу — потребують перевірки.

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

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

Ризики

НебезпекаНаслідокЗахист у роботі
Камера втратила потікзастаріле зображення породжує неправильне рішенняпомилка StreamLost; останній кадр не повертається повторно
Неправильний ROI/розміроб'єкт обрізано або координати хибніперевірка меж до crop
Людина в кадріпорушення приватностілише синтетичні дані; safe-smoke кодує PGM у пам'яті без metadata
Автоекспозиція змінила зображеннянестабільні ознакипараметр фіксується у паспорті; у mock — явна конфігурація
Робота біля рухомої коміркиудар або захопленнякамеру фізично не налаштовувати під час активного обладнання

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

  • відкривати /dev/video*, Picamera2, OpenCV VideoCapture або USB/CSI у цій редакції;
  • переставляти камеру, кабель чи освітлення на активному стенді;
  • знімати людей, документи, екрани з обліковими даними або інші персональні/секретні дані;
  • мовчки масштабувати чи обрізати кадр після невірної конфігурації;
  • продовжувати автоматичний сценарій після втрати або застарівання кадру;
  • видавати синтетичні часові метрики за вимірювання Raspberry Pi.

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

Для майбутньої фізичної частини: людина в небезпечній зоні, рух механізму, пошкоджений/перетиснутий кабель, нестабільне живлення, перегрів, втрата кадрів або невідома орієнтація. Для mock: немонотонний час, надмірний розрив, неправильна довжина pixel buffer, ROI поза межами, кінець потоку або необроблений виняток.

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

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

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

  • Джерело кадрів — MockCamera, не фізичний backend.
  • Код не імпортує cv2, picamera2 і не звертається до пристроїв.
  • Розмір buffer дорівнює width × height для навчального grayscale.
  • Часові позначки монотонні, а допустимий розрив визначений у mock.
  • ROI лежить у межах кадру.
  • Тимчасовий кадр не містить персональних даних і видаляється автоматично.

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

  1. Визначити модель кадру, конфігурацію та контракт adapter-а.
  2. Реалізувати й запустити MockCamera.
  3. Перевірити послідовність, timestamp і ROI.
  4. Перевірити втрату, часовий розрив і пошкоджений кадр.
  5. Зберегти мінімальний діагностичний PGM без метаданих.
  6. Заповнити паспорт, метрики й звіт.

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

1. Кадр як перевірювана подія

Кадр — це не лише масив пікселів. Для відтворюваності потрібні геометрія, формат, номер послідовності та час захоплення. У цій роботі використано одноканальний 8-бітний mock, тому довжина buffer однозначно дорівнює width × height.

ПолеПризначенняПеревірка
sequenceпорядок кадрівстрого зростає на 1 у тестовому потоці
timestamp_nsчас захоплення mockстрого зростає; gap не перевищує межу mock
width, heightгеометріядодатні; узгоджені з buffer
pixelsдані grayscaleрівно один байт на піксель
sourceідентифікатор backendнесекретний стабільний label, не шлях пристрою

2. ROI

ROI задають як (x, y, width, height) від верхнього лівого кута. Гранична умова: x + width <= frame.width, y + height <= frame.height. Crop не повинен мовчки підрізати область за межами.

3. Втрата потоку

Повернення останнього кадру після втрати камери створює ілюзію свіжих даних. Правильний adapter припиняє видачу, позначає стан помилки й передає її споживачу. Пороговий gap конкретної MERC-I5 залежить від фактичної частоти та сценарію; число mock не переноситься на стенд.

4. Потік даних

Текстовий опис рисунка: після відкриття adapter видає новий Frame; геометрія, sequence і timestamp перевіряються до crop та передавання алгоритму, а будь-яка втрата потоку чи невалідність переводить джерело у FAULT, закриває потік і забороняє downstream продовжувати керівний сценарій.

Mermaid
flowchart LR
    A["CameraAdapter.open"] --> B["Отримати Frame"]
    B --> C{"Геометрія і час валідні?"}
    C -->|"так"| D["Crop ROI"]
    D --> E["Алгоритм споживача"]
    E --> F["Метрики / мінімальний diagnostic"]
    C -->|"ні або stream lost"| G["FAULT: кадр недійсний"]
    G --> H["Закрити потік; рух не продовжувати"]

Рис. 1. Ілюстративний конвеєр валідації кадру без фізичної камери

5. Конфігурація mock поза кодом

Приклад camera.mock.json:

JSON
{
  "backend": "mock",
  "width": 4,
  "height": 3,
  "max_gap_ns": 1000,
  "roi": [1, 1, 2, 2],
  "diagnostic_format": "PGM"
}

Малі геометрія й gap у цьому файлі призначені тільки для швидкого test fixture й не описують камеру MERC-I5. Runtime читає JSON окремо, перевіряє whitelist ключів, додатність розмірів, межі ROI та backend == "mock"; фізичні device id, FPS, exposure й версії не додають до профілю до інвентаризації.

6. Першоджерела

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

Крок 1. Визначити контракт і конфігурацію

Запишіть, що backend має повертати Frame, а не «будь-який масив». Не вказуйте /dev/video0: порядок device node не є паспортом камери.

Text
open() -> потік OPEN або CameraError
read() -> новий валідний Frame або StreamLost
crop(frame, roi) -> новий Frame або ValueError
close() -> CLOSED; read заборонений

Очікуваний результат: контракт містить стан, timestamp, sequence, геометрію та помилки.

Критерій правильності: після StreamLost неможливо отримати старий кадр як новий.

Якщо результат не отримано: відокремте кеш/preview від керівного потоку й додайте явний стан недійсності.

Крок 2. Реалізувати повний mock

Збережіть як lab09_camera_mock.py.

Python
# safe-smoke
from __future__ import annotations

import json
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Protocol


class CameraState(str, Enum):
    CLOSED = "CLOSED"
    OPEN = "OPEN"
    FAULT = "FAULT"


class CameraError(RuntimeError):
    pass


class StreamLost(CameraError):
    pass


@dataclass(frozen=True)
class Frame:
    width: int
    height: int
    pixels: bytes
    timestamp_ns: int
    sequence: int
    source: str = "mock"

    def __post_init__(self) -> None:
        if self.width <= 0 or self.height <= 0:
            raise ValueError("геометрія кадру має бути додатною")
        if len(self.pixels) != self.width * self.height:
            raise ValueError("buffer не відповідає grayscale-геометрії")
        if self.timestamp_ns < 0 or self.sequence < 0:
            raise ValueError("timestamp/sequence не можуть бути від'ємними")


@dataclass(frozen=True)
class CameraStatus:
    state: CameraState
    delivered: int
    last_error: str | None


class CameraAdapter(Protocol):
    def open(self) -> None: ...
    def read(self) -> Frame: ...
    def close(self) -> None: ...
    def status(self) -> CameraStatus: ...


@dataclass(frozen=True)
class MockCameraConfig:
    width: int
    height: int
    max_gap_ns: int

    def __post_init__(self) -> None:
        if self.width <= 0 or self.height <= 0 or self.max_gap_ns <= 0:
            raise ValueError("невалідна конфігурація mock-камери")


class MockCamera:
    def __init__(self, config: MockCameraConfig, frames: list[Frame]) -> None:
        self.config = config
        self.frames = list(frames)
        self.index = 0
        self.state = CameraState.CLOSED
        self.last_error: str | None = None
        self.last_frame: Frame | None = None

    def open(self) -> None:
        if self.state == CameraState.FAULT:
            raise CameraError("потрібна ручна перевірка після FAULT")
        self.state = CameraState.OPEN

    def _fail(self, message: str) -> None:
        self.state = CameraState.FAULT
        self.last_error = message
        raise StreamLost(message)

    def read(self) -> Frame:
        if self.state != CameraState.OPEN:
            raise CameraError("потік не відкритий")
        if self.index >= len(self.frames):
            self._fail("потік завершився")
        frame = self.frames[self.index]
        self.index += 1
        if (frame.width, frame.height) != (self.config.width, self.config.height):
            self._fail("геометрія змінилася")
        if self.last_frame is not None:
            if frame.sequence != self.last_frame.sequence + 1:
                self._fail("порушено послідовність кадрів")
            gap = frame.timestamp_ns - self.last_frame.timestamp_ns
            if gap <= 0 or gap > self.config.max_gap_ns:
                self._fail("часовий розрив потоку недопустимий")
        self.last_frame = frame
        return frame

    def close(self) -> None:
        self.state = CameraState.CLOSED

    def status(self) -> CameraStatus:
        return CameraStatus(self.state, self.index, self.last_error)


def crop(frame: Frame, roi: tuple[int, int, int, int]) -> Frame:
    x, y, width, height = roi
    if x < 0 or y < 0 or width <= 0 or height <= 0:
        raise ValueError("ROI має додатний розмір і невід'ємний початок")
    if x + width > frame.width or y + height > frame.height:
        raise ValueError("ROI виходить за межі кадру")
    rows = []
    for row in range(y, y + height):
        start = row * frame.width + x
        rows.append(frame.pixels[start:start + width])
    return Frame(width, height, b"".join(rows), frame.timestamp_ns, frame.sequence, frame.source)


def encode_diagnostic_pgm(frame: Frame) -> bytes:
    """Повертає PGM у пам'яті без source/timestamp та інших metadata."""
    header = f"P5\n{frame.width} {frame.height}\n255\n".encode("ascii")
    return header + frame.pixels


def save_diagnostic_pgm(frame: Frame, path: Path) -> None:
    """Опційний запис у явно дозволений студентський workspace."""
    path.write_bytes(encode_diagnostic_pgm(frame))


def make_frame(sequence: int, timestamp_ns: int, value: int) -> Frame:
    return Frame(4, 3, bytes([value] * 12), timestamp_ns, sequence)


config = MockCameraConfig(width=4, height=3, max_gap_ns=1_000)
camera: CameraAdapter = MockCamera(config, [make_frame(0, 10_000, 7), make_frame(1, 10_500, 8)])
camera.open()
first = camera.read()
second = camera.read()
assert first.sequence == 0 and second.timestamp_ns > first.timestamp_ns
region = crop(second, (1, 1, 2, 2))
assert (region.width, region.height, region.pixels) == (2, 2, bytes([8] * 4))

try:
    camera.read()
except StreamLost as exc:
    assert "завершився" in str(exc)
else:
    raise AssertionError("кінець потоку має бути виявлений")
assert camera.status().state == CameraState.FAULT

gap_camera = MockCamera(config, [make_frame(0, 1_000, 1), make_frame(1, 2_001, 2)])
gap_camera.open()
gap_camera.read()
try:
    gap_camera.read()
except StreamLost as exc:
    assert "розрив" in str(exc)
else:
    raise AssertionError("надмірний gap має бути виявлений")

try:
    crop(first, (3, 2, 2, 2))
except ValueError:
    pass
else:
    raise AssertionError("ROI поза кадром має бути відхилений")

data = encode_diagnostic_pgm(region)
assert data.startswith(b"P5\n2 2\n255\n") and data.endswith(bytes([8] * 4))
assert b"mock" not in data and b"timestamp" not in data

print(json.dumps({"checks": 7, "result": "PASS", "hardware": False}, ensure_ascii=False))
Bash
python -m py_compile lab09_camera_mock.py
python lab09_camera_mock.py

Очікуваний результат: JSON checks=7, result=PASS, hardware=false.

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

Якщо результат не отримано: не переходьте до фізичного backend; збережіть traceback, перевірте геометрію синтетичних кадрів і межі ROI.

Крок 3. Проаналізувати штатний потік і ROI

Простежте кадри sequence=0,1, gap 500 ns у синтетичному тесті та ROI (1,1,2,2). Ці часові числа навмисно навчальні й не моделюють частоту реальної камери.

Text
sequence: 0 -> 1
timestamp_ns: 10000 -> 10500
frame: 4 x 3
ROI: x=1, y=1, width=2, height=2

Очікуваний результат: ROI має 4 байти зі значенням 8, початковий timestamp збережено.

Критерій правильності: crop не змінює sequence/timestamp і не виходить за межі.

Якщо результат не отримано: перевірте індекс рядка row * frame.width + x; не застосовуйте мовчазне clipping.

Крок 4. Перевірити втрату й часові аномалії

Зіставте три негативні випадки: кінець списку, gap понад max_gap_ns, ROI поза кадром. Додайте власний тест із повторним sequence.

Bash
python lab09_camera_mock.py

Очікуваний результат: потік переходить у FAULT, а не повертає попередній frame.

Критерій правильності: downstream отримує контрольований виняток до будь-якого керівного рішення.

Якщо результат не отримано: видаліть fallback «повернути last_frame»; зафіксуйте причину і закрийте потік.

Крок 5. Перевірити діагностичний артефакт

Safe-smoke кодує PGM лише в пам'яті. Переконайтеся, що bytes містять лише заголовок P5, розмір, максимум 255 і чотири пікселі; source та timestamp не записані. Функцію save_diagnostic_pgm студент може окремо перевірити лише у власному явно дозволеному workspace, після чого видалити тестовий файл.

Text
Дозволено: мінімальний crop об'єкта, технічна геометрія, знеособлений ідентифікатор тесту
Заборонено: обличчя, ПІБ, екрани з токенами, EXIF/GPS, приватні IP, серійні номери без потреби

Очікуваний результат: автоматично перевірений in-memory артефакт; за окремої локальної файлової перевірки файл створюється лише в дозволеному каталозі й видаляється після тесту.

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

Якщо результат не отримано: не зберігайте кадр; спершу мінімізуйте ROI, метадані й строк зберігання.

Крок 6. Заповнити паспорт камери

ПолеРезультат цієї редакції
APICameraAdapter.open/read/status/close
Формат mock8-bit grayscale, один байт на піксель
Timestampсинтетичний монотонний ns
ROI(x, y, width, height), строгі межі
Реакція на втратуFAULT, кадр недійсний
Diagnosticавтоматично перевірений PGM у пам'яті без metadata; файловий запис — окремий локальний тест
Фізичні backend-ине підтверджено й не перевірено

Очікуваний результат: паспорт не змішує mock із IMX708 або USB-камерою.

Критерій правильності: кожна апаратна характеристика має джерело або статус «не підтверджено».

Якщо результат не отримано: вилучіть припущені /dev/video*, FPS, exposure й версії; сформуйте перелік read-only даних для інвентаризації.

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

ТипСценарійОчікування
позитивнийдва послідовні кадриsequence і timestamp зростають
позитивнийROI всередині 4×3результат 2×2, 4 байти
негативнийкадри закінчилисяStreamLost, FAULT
негативнийgap > maxStreamLost, старий кадр не повертається
негативнийbuffer неправильної довжиниValueError під час створення Frame
граничнийROI торкається правої/нижньої межіприймається, якщо дорівнює межі
граничнийROI на 1 піксель поза кадромвідхиляється

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

Кадр/тестSequenceTimestamp mock, nsGap, nsРозмірROIСтанPASS/FAIL
1
2
lost
gap

Метрики: 100% тестів PASS; 0 повторно виданих старих кадрів; 0 невалідних ROI, що пройшли; 0 файлових записів у safe-smoke; 0 апаратних відкриттів.

[!WARNING] [ПОТРЕБУЄ ДОПРАЦЮВАННЯ: АПАРАТНА АПРОБАЦІЯ КАМЕРИ ТА ПОЛІТИКА ДІАГНОСТИЧНИХ КАДРІВ] Не підтверджено: стабільність потоку, реальні FPS/latency/gap, експозиція, фокус, роздільна здатність, ROI, освітлення, вміст фону, права на зображення, строк зберігання й критерій втрати для конкретної камери MERC-I5; фізичний захват кадру не виконувався. Що потрібно додати: затверджений профіль камери й версій ПЗ, еталонну сцену без людей, правила приватності/публікації, набір часових журналів і діагностичних кадрів, критерії якості та відповідального оператора. Як завершити: після окремого дозволу виконати read-only захват нерухомої безпечної сцени, виміряти sequence/timestamp/gap серією кадрів, перевірити ROI та втрату потоку без руху механізмів, провести privacy-review; додати датовані результати й видалити блок лише після затвердження.

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

Див. загальні вимоги. Додатково подайте:

  1. контракт CameraAdapter і модель Frame;
  2. повний код mock та JSON smoke-результат;
  3. таблицю sequence/timestamp/ROI для всіх сценаріїв;
  4. аналіз небезпеки повторного використання старого кадру;
  5. опис формату PGM і перевірки відсутності metadata;
  6. паспорт із розділенням mock та майбутніх backend-ів;
  7. явну відмітку, що фізичні камери не відкривалися.

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

СкладоваЧасткаКритерії
Підготовка10%контракт, джерела, privacy/safety-чекліст, відсутність припущених пристроїв
Реалізація40%Frame, CameraAdapter, mock, ROI, втрата потоку, diagnostic PGM
Перевірка25%позитивні, негативні й граничні тести; код 0; жодного I/O камери
Аналіз15%часові аномалії, stale frame, ROI, приватність і межі моделі
Звіт10%таблиці, паспорт, джерела, висновки й чесний статус апробації
Разом100%

Відкриття фізичної камери без дозволу або публікація персональних/секретних даних є критичною помилкою.

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

  1. Чому frame має містити timestamp і sequence?
  2. Чим час захоплення відрізняється від часу обробки?
  3. Чому не можна повертати останній кадр після втрати потоку?
  4. Які умови роблять ROI валідним?
  5. Навіщо adapter приховує Picamera2/V4L2/OpenCV?
  6. Чому /dev/video0 не є стабільним паспортним ідентифікатором?
  7. Які дані можуть зробити діагностичний кадр чутливим?
  8. Які вимірювання потрібні для вибору реального max_gap?

Висновки

У роботі реалізується детермінований CameraAdapter-мock із валідованими кадрами, ROI, часовою послідовністю, явною втратою потоку та мінімальним PGM, автоматично перевіреним у пам'яті. Код не залежить від камери й не збирає реальні зображення. Моделі пристроїв, режими, версії та метрики MERC-I5 залишаються адресно заблокованими до read-only інвентаризації й затвердженої апробації.

MERCI SPACE