diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_0_module_profile_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_0_module_profile_tool.py new file mode 100644 index 000000000..785728cb4 --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_0_module_profile_tool.py @@ -0,0 +1,1019 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +_0_module_profile_tool.py +========================= + +Bootstrap/identidade do módulo multiespectral OAK-FFC-3P. + +Este script vem ANTES de todas as calibrações. Ele elimina a dependência +circular em que ferramentas iniciais tentavam ler calibration/module_params.json, +arquivo que só deve existir ao final da linha de calibração. + +Responsabilidades: + - selecionar e descobrir o dispositivo DepthAI; + - validar CAM_A RGB + CAM_B RE + CAM_C NIR; + - resolver/confirmar Bayer da RGB usando preview RAW10; + - registrar o domínio de decode RGB escolhido; + - criar a pasta oficial do perfil (ex.: calibration/mp_ar0234); + - declarar caminhos oficiais para todas as calibrações seguintes; + - promover module_profile_v1.json e atualizar active_module_profile.json. + +Não calibra foco, intrínsecos, flat-field, radiometria ou homografia. +Não gera module_params.json. + +Exemplos: + python -m calibration._0_module_profile_tool + + python -m calibration._0_module_profile_tool ^ + --rgb-bayer GRBG ^ + --rgb-processing-mode linear_demosaic ^ + --demosaic-algorithm ea + +Teclas no preview: + B alterna Bayer RGB + S salva snapshot + ENTER confirma e promove o profile + Q/ESC cancela sem alterar o profile ativo +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import time +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +import cv2 +import depthai as dai +import numpy as np + + +SCHEMA = "multispec_module_profile_v1" +SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +ROLES = ("rgb", "re", "nir") +BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") + +ROLE_TO_SOCKET = { + "rgb": "CAM_A", + "re": "CAM_B", + "nir": "CAM_C", +} + +SUPPORTED_RGB = { + "OV9782": { + "width": 1280, + "height": 800, + "resolution_enum": "THE_800_P", + "default_bayer": "BGGR", + "preview_rotate_deg": 0, + }, + "AR0234": { + "width": 1920, + "height": 1200, + "resolution_enum": "THE_1200_P", + "default_bayer": "GRBG", + "preview_rotate_deg": 180, + }, +} + +SUPPORTED_MONO = { + "OV9282": { + "width": 1280, + "height": 800, + "resolution_enum": "THE_800_P", + "preview_rotate_deg": 0, + }, +} + + +def now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def stamp() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + +def ensure_dir(path: str | Path): + Path(path).mkdir(parents=True, exist_ok=True) + + +def norm_path(path: str | Path) -> str: + p = Path(path) + try: + p = p.resolve().relative_to(Path.cwd().resolve()) + except Exception: + pass + return p.as_posix() + + +def save_json_atomic(path: str | Path, data: dict): + p = Path(path) + ensure_dir(p.parent) + tmp = p.with_suffix(p.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, p) + + +def sha256_file(path: str | Path) -> str: + h = hashlib.sha256() + with Path(path).open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def safe_profile_name(value: str) -> str: + value = re.sub(r"[^a-zA-Z0-9_.-]+", "_", str(value).strip()) + value = value.strip("._-") + if not value: + raise ValueError("Nome de profile vazio/inválido.") + return value.lower() + + +def socket_name(sock) -> str: + name = getattr(sock, "name", None) + if name: + return str(name) + text = str(sock) + for candidate in ("CAM_A", "CAM_B", "CAM_C", "CAM_D"): + if candidate in text: + return candidate + return text + + +def device_id_from_info(info) -> Optional[str]: + for method in ("getMxId", "getDeviceId"): + fn = getattr(info, method, None) + if callable(fn): + try: + value = fn() + if value: + return str(value) + except Exception: + pass + for attr in ("mxid", "deviceId"): + value = getattr(info, attr, None) + if value: + return str(value) + return None + + +def select_device_info(mx_id: Optional[str]): + devices = list(dai.Device.getAllAvailableDevices()) + if not devices: + raise RuntimeError("Nenhum dispositivo OAK/DepthAI encontrado.") + + if not mx_id: + if len(devices) > 1: + ids = [device_id_from_info(x) for x in devices] + raise RuntimeError( + "Há mais de um dispositivo conectado. Use --mx-id. " + f"Disponíveis={ids}" + ) + return devices[0] + + target = str(mx_id).strip() + for info in devices: + if device_id_from_info(info) == target: + return info + + available = [device_id_from_info(x) for x in devices] + raise RuntimeError(f"MX ID {target!r} não encontrado. Disponíveis={available}") + + +def feature_types(feature) -> list[str]: + return [ + str(x).upper() + for x in (getattr(feature, "supportedTypes", []) or []) + ] + + +def feature_is_color(feature) -> bool: + types = feature_types(feature) + sensor = str(getattr(feature, "sensorName", "") or "").upper() + if any("COLOR" in x for x in types): + return True + if any("MONO" in x for x in types): + return False + return sensor in SUPPORTED_RGB + + +def discover(dev_info): + rows = [] + with dai.Device(dev_info) as dev: + try: + usb_speed = str(dev.getUsbSpeed()) + except Exception: + usb_speed = None + + actual_mx = None + for method in ("getMxId", "getDeviceId"): + fn = getattr(dev, method, None) + if callable(fn): + try: + actual_mx = str(fn()) + if actual_mx: + break + except Exception: + pass + + for feature in dev.getConnectedCameraFeatures(): + rows.append({ + "socket": socket_name(feature.socket), + "sensor": str(feature.sensorName or "").upper(), + "width": int(getattr(feature, "width", 0) or 0), + "height": int(getattr(feature, "height", 0) or 0), + "is_color": feature_is_color(feature), + "supported_types": feature_types(feature), + "has_autofocus_ic": int( + getattr(feature, "hasAutofocusIC", 0) or 0 + ), + }) + + return rows, actual_mx, usb_speed + + +@dataclass +class CameraSpec: + role: str + socket: str + sensor: str + width: int + height: int + resolution_name: str + is_color: bool + preview_rotate_deg: int + stream_name: str + + +def validate_and_build_specs(rows: list[dict]) -> Dict[str, CameraSpec]: + by_socket = {row["socket"]: row for row in rows} + errors = [] + specs = {} + + for role in ROLES: + sock = ROLE_TO_SOCKET[role] + row = by_socket.get(sock) + if row is None: + errors.append(f"{role}: {sock} ausente") + continue + + sensor = row["sensor"] + table = SUPPORTED_RGB if role == "rgb" else SUPPORTED_MONO + cfg = table.get(sensor) + if cfg is None: + errors.append( + f"{role}: {sock}={sensor}, suportados={sorted(table)}" + ) + continue + + if role == "rgb" and not row["is_color"]: + errors.append(f"{role}: {sock}/{sensor} não anunciado como COLOR") + if role != "rgb" and row["is_color"]: + errors.append(f"{role}: {sock}/{sensor} não anunciado como MONO") + + expected = (cfg["width"], cfg["height"]) + announced = (row["width"], row["height"]) + if all(announced) and announced != expected: + errors.append( + f"{role}: {sock}/{sensor} anunciou {announced[0]}x{announced[1]}, " + f"esperado={expected[0]}x{expected[1]}" + ) + + specs[role] = CameraSpec( + role=role, + socket=sock, + sensor=sensor, + width=int(cfg["width"]), + height=int(cfg["height"]), + resolution_name=str(cfg["resolution_enum"]), + is_color=(role == "rgb"), + preview_rotate_deg=int(cfg["preview_rotate_deg"]), + stream_name=f"profile_{role}", + ) + + if errors: + raise RuntimeError("Topologia inválida:\n - " + "\n - ".join(errors)) + + return specs + + +def get_socket(name: str): + return { + "CAM_A": dai.CameraBoardSocket.CAM_A, + "CAM_B": dai.CameraBoardSocket.CAM_B, + "CAM_C": dai.CameraBoardSocket.CAM_C, + }[name] + + +def build_preview_pipeline(specs: Dict[str, CameraSpec], fps: float): + pipeline = dai.Pipeline() + + for role in ROLES: + spec = specs[role] + socket = get_socket(spec.socket) + + if spec.is_color: + cam = pipeline.createColorCamera() + cam.setBoardSocket(socket) + enum = getattr( + dai.ColorCameraProperties.SensorResolution, + spec.resolution_name, + None, + ) + if enum is None: + raise RuntimeError( + f"DepthAI não expõe ColorCamera {spec.resolution_name}." + ) + cam.setResolution(enum) + cam.setFps(float(fps)) + output = cam.raw + else: + cam = pipeline.createMonoCamera() + cam.setBoardSocket(socket) + enum = getattr( + dai.MonoCameraProperties.SensorResolution, + spec.resolution_name, + None, + ) + if enum is None: + raise RuntimeError( + f"DepthAI não expõe MonoCamera {spec.resolution_name}." + ) + cam.setResolution(enum) + cam.setFps(float(fps)) + output = cam.out + + xout = pipeline.createXLinkOut() + xout.setStreamName(spec.stream_name) + output.link(xout.input) + + return pipeline + + +def unpack_raw10(data, width: int, height: int, stride: Optional[int] = None): + width = int(width) + height = int(height) + if width <= 0 or height <= 0 or width % 4: + raise ValueError(f"Dimensão RAW10 inválida: {width}x{height}") + + raw = np.asarray(data, dtype=np.uint8).reshape(-1) + payload_bytes = (width // 4) * 5 + + candidates = [] + if stride is not None: + try: + candidates.append(int(stride)) + except Exception: + pass + if height > 0 and raw.size % height == 0: + candidates.append(raw.size // height) + candidates.append(payload_bytes) + + row_stride = next( + (x for x in candidates if x >= payload_bytes and x * height <= raw.size), + None, + ) + if row_stride is None: + raise ValueError( + f"RAW10 curto: bytes={raw.size}, payload mínimo={payload_bytes*height}" + ) + + rows = raw[: row_stride * height].reshape(height, row_stride) + groups = rows[:, :payload_bytes].reshape(height, width // 4, 5) + out = np.empty((height, width // 4, 4), dtype=np.uint16) + + out[:, :, 0] = ( + groups[:, :, 0].astype(np.uint16) << 2 + ) | (groups[:, :, 4] & 0x03) + out[:, :, 1] = ( + groups[:, :, 1].astype(np.uint16) << 2 + ) | ((groups[:, :, 4] >> 2) & 0x03) + out[:, :, 2] = ( + groups[:, :, 2].astype(np.uint16) << 2 + ) | ((groups[:, :, 4] >> 4) & 0x03) + out[:, :, 3] = ( + groups[:, :, 3].astype(np.uint16) << 2 + ) | ((groups[:, :, 4] >> 6) & 0x03) + + return np.ascontiguousarray(out.reshape(height, width)) + + +def bayer_code(pattern: str, algorithm: str): + pattern = str(pattern).upper() + algorithm = str(algorithm).lower() + + if algorithm == "ea": + table = { + "BGGR": cv2.COLOR_BayerRG2RGB_EA, + "RGGB": cv2.COLOR_BayerBG2RGB_EA, + "GRBG": cv2.COLOR_BayerGR2RGB_EA, + "GBRG": cv2.COLOR_BayerGB2RGB_EA, + } + else: + table = { + "BGGR": cv2.COLOR_BayerRG2RGB, + "RGGB": cv2.COLOR_BayerBG2RGB, + "GRBG": cv2.COLOR_BayerGR2RGB, + "GBRG": cv2.COLOR_BayerGB2RGB, + } + + return table[pattern] + + +def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str): + raw_type = str(pkt.getType()).upper() + if "PACK10" not in raw_type and "RAW10" not in raw_type: + raise RuntimeError( + f"{spec.socket}/{spec.sensor}: esperado PACK10/RAW10, recebido " + f"{pkt.getType()}" + ) + + width = int(pkt.getWidth()) + height = int(pkt.getHeight()) + if (width, height) != (spec.width, spec.height): + raise RuntimeError( + f"RGB RAW={width}x{height}, esperado={spec.width}x{spec.height}" + ) + + try: + stride = int(pkt.getStride()) + except Exception: + stride = None + + raw16 = unpack_raw10(pkt.getData(), width, height, stride) + rgb16 = cv2.cvtColor(raw16, bayer_code(pattern, algorithm)) + rgb = np.clip(rgb16.astype(np.float32) / 1023.0, 0.0, 1.0) + + # Stretch comum aos canais: melhora a inspeção sem alterar o balanço RGB. + sample = rgb[::8, ::8] + lo = float(np.percentile(sample, 0.5)) + hi = float(np.percentile(sample, 99.5)) + if hi <= lo + 1e-6: + hi = lo + 1e-6 + rgb = np.clip((rgb - lo) / (hi - lo), 0.0, 1.0) + + # OpenCV exibe BGR. + return np.ascontiguousarray((rgb[:, :, ::-1] * 255.0).astype(np.uint8)) + + +def apply_preview_orientation(img: np.ndarray, rotate_deg: int): + rotate_deg = int(rotate_deg) % 360 + if rotate_deg == 90: + return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) + if rotate_deg == 180: + return cv2.rotate(img, cv2.ROTATE_180) + if rotate_deg == 270: + return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) + return img + + +def overlay(img, lines, x=12, y=24, scale=0.48, step=21): + yy = y + for line in lines: + cv2.putText( + img, str(line), (x, yy), cv2.FONT_HERSHEY_SIMPLEX, + scale, (0, 0, 0), 3, cv2.LINE_AA, + ) + cv2.putText( + img, str(line), (x, yy), cv2.FONT_HERSHEY_SIMPLEX, + scale, (255, 255, 255), 1, cv2.LINE_AA, + ) + yy += step + + +def preview_and_confirm( + dev_info, + specs: Dict[str, CameraSpec], + initial_bayer: str, + algorithm: str, + fps: float, + panel_width: int, + panel_height: int, + snapshots_dir: Path, +): + pattern = initial_bayer + pipeline = build_preview_pipeline(specs, fps) + window = "Module Profile - Bayer Confirmation" + cv2.namedWindow(window, cv2.WINDOW_NORMAL) + + try: + with dai.Device(pipeline, dev_info) as dev: + queues = { + role: dev.getOutputQueue( + name=specs[role].stream_name, + maxSize=2, + blocking=False, + ) + for role in ROLES + } + latest = {role: None for role in ROLES} + latest_type = {role: None for role in ROLES} + last_board = None + started = time.time() + + while True: + for role in ROLES: + pkt = queues[role].tryGet() + if pkt is not None: + latest[role] = pkt + latest_type[role] = str(pkt.getType()) + + if all(latest[role] is not None for role in ROLES): + panels = [] + for role in ROLES: + spec = specs[role] + pkt = latest[role] + if role == "rgb": + view = rgb_raw_preview(pkt, spec, pattern, algorithm) + else: + view = pkt.getCvFrame() + if view.ndim == 2: + view = cv2.cvtColor(view, cv2.COLOR_GRAY2BGR) + + view = apply_preview_orientation( + view, + spec.preview_rotate_deg, + ) + panel = cv2.resize( + view, + (panel_width, panel_height), + interpolation=cv2.INTER_AREA, + ) + lines = [ + f"{role.upper()} | {spec.socket} | {spec.sensor}", + f"native={spec.width}x{spec.height}", + f"packet={latest_type[role]}", + ] + if role == "rgb": + lines += [ + f"BAYER={pattern} | DEMOSAIC={algorithm}", + "Confira vermelho/azul em objeto conhecido", + ] + overlay(panel, lines) + panels.append(panel) + + info = np.zeros( + (panel_height, panel_width, 3), + dtype=np.uint8, + ) + overlay(info, [ + "MODULE PROFILE - ETAPA 0", + "", + f"RGB Bayer selecionado: {pattern}", + f"RGB processing: linear_demosaic / {algorithm}", + "", + "B = alternar Bayer", + "S = snapshot", + "ENTER = confirmar e promover", + "Q/ESC = cancelar", + "", + "Use um objeto vermelho e outro azul", + "para confirmar que R/B não estão trocados.", + ]) + + last_board = np.vstack([ + np.hstack([panels[0], panels[1]]), + np.hstack([panels[2], info]), + ]) + cv2.imshow(window, last_board) + + elif time.time() - started > 10.0: + missing = [r for r in ROLES if latest[r] is None] + raise TimeoutError(f"Timeout aguardando preview: {missing}") + + key = cv2.waitKey(1) & 0xFF + if key in (ord("q"), ord("Q"), 27): + raise KeyboardInterrupt("Cancelado durante confirmação do Bayer.") + if key in (ord("b"), ord("B")): + idx = (BAYER_PATTERNS.index(pattern) + 1) % len(BAYER_PATTERNS) + pattern = BAYER_PATTERNS[idx] + if key in (ord("s"), ord("S")) and last_board is not None: + ensure_dir(snapshots_dir) + cv2.imwrite( + str(snapshots_dir / f"bayer_{pattern}_{stamp()}.png"), + last_board, + ) + if key in (10, 13) and last_board is not None: + ensure_dir(snapshots_dir) + confirmed_path = snapshots_dir / "bayer_confirmed.png" + cv2.imwrite(str(confirmed_path), last_board) + return pattern, confirmed_path + + time.sleep(0.002) + finally: + cv2.destroyWindow(window) + + +def artifact_paths(profile_dir: Path) -> dict: + return { + "module_profile_json": norm_path(profile_dir / "module_profile_v1.json"), + "focus_qc_json": norm_path(profile_dir / "focus_qc_active.json"), + "intrinsics_json": norm_path(profile_dir / "intrinsics_calibration_v1.json"), + "flatfield_npz": norm_path(profile_dir / "flatfield_maps_v1.npz"), + "flatfield_json": norm_path(profile_dir / "flatfield_maps_v1.json"), + "radiometry_json": norm_path(profile_dir / "radiometry_calibration_v1.json"), + "camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v1.json"), + "homography_json": norm_path(profile_dir / "homography_calibration_v1.json"), + "module_params_json": norm_path(profile_dir / "module_params.json"), + "module_params_assembly_report_json": norm_path( + profile_dir / "module_params_assembly_report.json" + ), + "candidate_roots": { + "focus": norm_path(profile_dir / "focus_candidates"), + "intrinsics": norm_path(profile_dir / "intrinsics_candidates"), + "flatfield": norm_path(profile_dir / "flatfield_candidates"), + "radiometry": norm_path(profile_dir / "radiometry_candidates"), + "camera_startup": norm_path(profile_dir / "camera_startup_candidates"), + "homography": norm_path(profile_dir / "homography_candidates"), + }, + } + + +def build_profile( + args, + specs: Dict[str, CameraSpec], + rows: list[dict], + actual_mx: str, + usb_speed: Optional[str], + profile_name: str, + profile_dir: Path, + bayer: str, + confirmed_with_raw_preview: bool, + confirmation_snapshot: Optional[Path], +): + orientation = { + role: { + "rotate_deg": int(specs[role].preview_rotate_deg), + "flip_horizontal": False, + "flip_vertical": False, + "source": "sensor_default", + } + for role in ROLES + } + + hardware_signature = { + role: { + "socket": specs[role].socket, + "sensor": specs[role].sensor, + "size": [specs[role].width, specs[role].height], + } + for role in ROLES + } + + return { + "schema": SCHEMA, + "created_at": now_str(), + "status": "active", + "profile_name": profile_name, + "profile_dir": norm_path(profile_dir), + "device_mx_id": actual_mx, + "module_id": args.module_id or None, + "operator": args.operator or None, + "depthai_version": getattr(dai, "__version__", "unknown"), + "usb_speed": usb_speed, + "hardware_signature": hardware_signature, + "resolved_setup": { + role: asdict(specs[role]) + for role in ROLES + }, + "camera_inventory": rows, + "rgb_decode": { + "bayer_pattern": bayer, + "mode": args.rgb_processing_mode, + "demosaic_algorithm": args.demosaic_algorithm, + "bayer_source": ( + "raw_preview_confirmed" + if confirmed_with_raw_preview + else "sensor_default_or_cli_headless" + ), + "confirmed_with_raw_preview": bool(confirmed_with_raw_preview), + "confirmation_snapshot": ( + norm_path(confirmation_snapshot) + if confirmation_snapshot is not None + else None + ), + }, + "preview_orientation_defaults": orientation, + "geometry_bootstrap": { + "native_space": "native_stream_no_external_undistort", + "orientation_status": "preview_default_until_homography", + "note": ( + "A Homography homologada será a autoridade final da orientação " + "científica e do espaço canônico." + ), + }, + "module_params_seed": { + "frame_type": "RAW_BRUTO", + "sensor_width": specs["rgb"].width, + "sensor_height": specs["rgb"].height, + "bayer_pattern": bayer, + "sensor_size_by_role": { + role: [specs[role].width, specs[role].height] + for role in ROLES + }, + "camera_hardware": hardware_signature, + "rgb_processing": { + "mode": args.rgb_processing_mode, + "demosaic_algorithm": args.demosaic_algorithm, + }, + }, + "artifact_paths": artifact_paths(profile_dir), + "traceability": { + "lens_by_role": { + "rgb": args.lens_rgb or None, + "re": args.lens_re or None, + "nir": args.lens_nir or None, + }, + "notes": args.notes or "", + }, + } + + +def validate_existing_target( + profile_path: Path, + actual_mx: str, + specs: Dict[str, CameraSpec], + allow_different_device: bool, +): + if not profile_path.is_file(): + return + + try: + old = json.loads(profile_path.read_text(encoding="utf-8")) + except Exception as exc: + raise RuntimeError( + f"Profile existente não pôde ser lido: {profile_path}" + ) from exc + + old_mx = str(old.get("device_mx_id") or "") + old_rgb = ( + (old.get("hardware_signature", {}) or {}) + .get("rgb", {}) + .get("sensor") + ) + + if old_mx and actual_mx and old_mx != actual_mx and not allow_different_device: + raise RuntimeError( + f"A pasta já pertence ao MX ID {old_mx}, mas foi conectado " + f"{actual_mx}. Use outra --profile-name/--output-dir ou, somente " + "se intencional, --replace-different-device." + ) + + if old_rgb and str(old_rgb).upper() != specs["rgb"].sensor: + raise RuntimeError( + f"A pasta contém profile RGB={old_rgb}, conectado={specs['rgb'].sensor}." + ) + + +def backup_if_exists(path: Path): + if path.exists(): + backup = path.with_name( + f"{path.stem}.backup_{stamp()}{path.suffix}" + ) + shutil.copy2(path, backup) + return backup + return None + + +def main(): + parser = argparse.ArgumentParser( + description="Bootstrap do profile físico/decode do módulo multiespectral.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("--mx-id", default=None) + parser.add_argument("--module-id", default="") + parser.add_argument("--operator", default="") + parser.add_argument("--profile-name", default=None) + parser.add_argument( + "--output-dir", + default=None, + help="Override da pasta. Default=calibration/mp_.", + ) + parser.add_argument( + "--active-selector", + default="calibration/active_module_profile.json", + help="Ponteiro usado pelos próximos scripts para localizar o profile ativo.", + ) + parser.add_argument( + "--rgb-bayer", + default="auto", + choices=["auto", *BAYER_PATTERNS], + ) + parser.add_argument( + "--rgb-processing-mode", + default="linear_demosaic", + choices=["linear_demosaic", "linear_demosaic_half", "bayer_planes"], + ) + parser.add_argument( + "--demosaic-algorithm", + default="ea", + choices=["ea", "bilinear"], + ) + parser.add_argument("--fps", type=float, default=10.0) + parser.add_argument("--panel-width", type=int, default=640) + parser.add_argument("--panel-height", type=int, default=400) + parser.add_argument( + "--no-preview", + action="store_true", + help="Não abre confirmação RAW. Útil apenas em automação/headless.", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Confirma promoção em --no-preview.", + ) + parser.add_argument("--replace-different-device", action="store_true") + parser.add_argument("--lens-rgb", default="") + parser.add_argument("--lens-re", default="") + parser.add_argument("--lens-nir", default="") + parser.add_argument("--notes", default="") + args = parser.parse_args() + + if args.no_preview and not args.yes: + raise RuntimeError("--no-preview exige --yes para promover conscientemente.") + + dev_info = select_device_info(args.mx_id) + rows, actual_mx, usb_speed = discover(dev_info) + specs = validate_and_build_specs(rows) + + rgb_sensor = specs["rgb"].sensor + default_profile_name = f"mp_{rgb_sensor.lower()}" + profile_name = safe_profile_name(args.profile_name or default_profile_name) + profile_dir = ( + Path(args.output_dir) + if args.output_dir + else Path("calibration") / profile_name + ) + + candidate_dir = profile_dir / "module_profile_candidates" / stamp() + snapshots_dir = candidate_dir / "snapshots" + candidate_report = candidate_dir / "report.json" + active_profile = profile_dir / "module_profile_v1.json" + active_selector = Path(args.active_selector) + + ensure_dir(candidate_dir) + validate_existing_target( + active_profile, + actual_mx, + specs, + args.replace_different_device, + ) + + if args.rgb_bayer == "auto": + selected_bayer = SUPPORTED_RGB[rgb_sensor]["default_bayer"] + bayer_initial_source = "sensor_default" + else: + selected_bayer = args.rgb_bayer + bayer_initial_source = "cli" + + print("=" * 88) + print("MODULE PROFILE - ETAPA 0") + print(f"MX ID : {actual_mx}") + print(f"USB : {usb_speed}") + print(f"Profile : {profile_name}") + print(f"Pasta : {profile_dir}") + print("-" * 88) + for role in ROLES: + spec = specs[role] + print( + f"{spec.socket} -> {role.upper():3s} | {spec.sensor:8s} | " + f"{spec.width}x{spec.height}" + ) + print("-" * 88) + print( + f"RGB decode : Bayer={selected_bayer} ({bayer_initial_source}) | " + f"mode={args.rgb_processing_mode} | algo={args.demosaic_algorithm}" + ) + print("=" * 88) + + confirmed_with_raw_preview = False + confirmation_snapshot = None + + try: + if not args.no_preview: + selected_bayer, confirmation_snapshot = preview_and_confirm( + dev_info=dev_info, + specs=specs, + initial_bayer=selected_bayer, + algorithm=args.demosaic_algorithm, + fps=args.fps, + panel_width=args.panel_width, + panel_height=args.panel_height, + snapshots_dir=snapshots_dir, + ) + confirmed_with_raw_preview = True + + profile = build_profile( + args=args, + specs=specs, + rows=rows, + actual_mx=actual_mx, + usb_speed=usb_speed, + profile_name=profile_name, + profile_dir=profile_dir, + bayer=selected_bayer, + confirmed_with_raw_preview=confirmed_with_raw_preview, + confirmation_snapshot=confirmation_snapshot, + ) + + save_json_atomic(candidate_report, profile) + backup_profile = backup_if_exists(active_profile) + backup_selector = backup_if_exists(active_selector) + save_json_atomic(active_profile, profile) + + selector = { + "schema": SELECTOR_SCHEMA, + "updated_at": now_str(), + "profile_name": profile_name, + "profile_path": norm_path(active_profile), + "profile_sha256": sha256_file(active_profile), + "device_mx_id": actual_mx, + "rgb_sensor": rgb_sensor, + "profile_dir": norm_path(profile_dir), + } + save_json_atomic(active_selector, selector) + + (candidate_dir / "PASS.txt").write_text( + "\n".join([ + "status=pass", + f"profile={norm_path(active_profile)}", + f"profile_sha256={selector['profile_sha256']}", + f"active_selector={norm_path(active_selector)}", + "", + ]), + encoding="utf-8", + ) + + print("") + print("=" * 88) + print("[PASS] MODULE PROFILE PROMOVIDO") + print(f"[PROFILE] {active_profile}") + print(f"[SELECTOR] {active_selector}") + print(f"[SHA256] {selector['profile_sha256']}") + print(f"[BAYER] {selected_bayer}") + if backup_profile: + print(f"[BACKUP PROFILE] {backup_profile}") + if backup_selector: + print(f"[BACKUP SELECTOR] {backup_selector}") + print("[NEXT] Os demais scripts devem ler active_module_profile.json") + print(" e gravar nos artifact_paths deste profile.") + print("=" * 88) + + except KeyboardInterrupt as exc: + cancelled = { + "schema": SCHEMA, + "created_at": now_str(), + "status": "cancelled", + "error": str(exc), + "device_mx_id": actual_mx, + "profile_name": profile_name, + } + save_json_atomic(candidate_report, cancelled) + (candidate_dir / "CANCELLED.txt").write_text( + f"reason={exc}\n", + encoding="utf-8", + ) + print(f"[CANCELLED] {exc}") + print("[SAFE] Profile ativo anterior preservado.") + + except Exception as exc: + failed = { + "schema": SCHEMA, + "created_at": now_str(), + "status": "error", + "error": f"{type(exc).__name__}: {exc}", + "device_mx_id": actual_mx, + "profile_name": profile_name, + } + try: + save_json_atomic(candidate_report, failed) + (candidate_dir / "FAIL.txt").write_text( + f"error={type(exc).__name__}: {exc}\n", + encoding="utf-8", + ) + except Exception: + pass + print(f"[ERROR] {type(exc).__name__}: {exc}") + print("[SAFE] Profile ativo anterior preservado.") + raise + + finally: + cv2.destroyAllWindows() + + +if __name__ == "__main__": + main() diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_1_focus_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_1_focus_calibration_tool.py index f519c7593..bd993fb5e 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_1_focus_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_1_focus_calibration_tool.py @@ -15,7 +15,9 @@ Topologia oficial do produto Objetivos --------- -- Detectar e validar o hardware antes de permitir a calibração. +- Carregar o module profile criado pelo Script 0 antes da calibração. +- Conectar automaticamente ao MX ID registrado no profile. +- Validar o hardware conectado contra o contrato do profile. - Suportar OV9782 e AR0234 na CAM_A. - Mostrar RGB + RE + NIR simultaneamente. - Deixar AE estabilizar e depois congelar exposição/ISO. @@ -24,6 +26,7 @@ Objetivos - Exigir estabilidade próxima ao pico antes do ACCEPT. - Medir centro + 4 regiões periféricas para detectar tilt/decentering. - Salvar evidência auditável por sessão. +- Nunca depender de module_params (ele ainda não existe nesta etapa). - Nunca alterar module_params nem qualquer calibração de runtime. - Nunca sobrescrever um resultado ativo aprovado com uma sessão FAIL/CANCELLED. @@ -34,7 +37,12 @@ Foco é um ajuste FÍSICO da lente. Portanto: module_params_fragment = null O JSON produzido por esta ferramenta é um relatório de QC/rastreabilidade. -O assembler final do module_params pode ignorá-lo. +O assembler final pode validar seu PASS e sua assinatura de hardware, mas não +há fragmento de configuração de runtime para mesclar. + +O Bayer NÃO participa da medição de foco, pois a RGB é medida na saída ISP. +Mesmo assim, a identidade de decode definida no Script 0 é copiada para o +relatório como proveniência, com used_by_focus_measurement=false. Target recomendado ------------------ @@ -105,9 +113,17 @@ Há um override de engenharia: que permite aceitar sem bracket/field QA, mas isso fica registrado no JSON e o resultado não é considerado homologação normal. -Saídas padrão -------------- -calibration/focus_candidates// +Entrada obrigatória +------------------- +Por padrão, o script lê: + calibration/active_module_profile.json + +Também é possível selecionar explicitamente: + --module-profile calibration/mp_ar0234/module_profile_v1.json + +Saídas padrão (herdadas do module profile) +------------------------------------------ +calibration//focus_candidates// report.json PASS.txt / FAIL.txt / CANCELLED.txt snapshots/ @@ -118,7 +134,7 @@ calibration/focus_candidates// nir_... Se PASS: - calibration/focus_qc_active.json + calibration//focus_qc_active.json Nenhum NPZ é gerado. Nenhuma chave do module_params é alterada. @@ -207,6 +223,9 @@ SENSOR_ORIENTATION = { } SCHEMA = "multispec_focus_qc_v3" +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") # ============================================================ @@ -302,6 +321,161 @@ def sha256_file(path: str | Path) -> str: return h.hexdigest() +def load_json_file(path: str | Path) -> dict: + p = Path(path) + + if not p.is_file(): + raise RuntimeError(f"JSON obrigatório não encontrado: {p}") + + try: + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + raise RuntimeError(f"Falha ao ler JSON {p}: {exc}") from exc + + if not isinstance(data, dict): + raise RuntimeError(f"JSON deve conter um objeto na raiz: {p}") + + return data + + +def resolve_existing_path(value: str | Path, anchor: Optional[Path] = None) -> Path: + """Resolve paths do contrato sem depender do diretório do próprio script.""" + raw = Path(value).expanduser() + candidates = [raw] + + if not raw.is_absolute() and anchor is not None: + candidates.append(anchor.parent / raw) + + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + + tried = ", ".join(str(x) for x in candidates) + raise RuntimeError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def validate_module_profile(profile: dict, profile_path: Path): + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema de module profile inesperado em {profile_path}: " + f"{profile.get('schema')!r}" + ) + + if str(profile.get("status", "")).lower() != "active": + raise RuntimeError( + f"Module profile não está ativo: status={profile.get('status')!r}" + ) + + profile_name = str(profile.get("profile_name") or "").strip() + profile_dir = str(profile.get("profile_dir") or "").strip() + device_mx_id = str(profile.get("device_mx_id") or "").strip() + + if not profile_name or not profile_dir or not device_mx_id: + raise RuntimeError( + "Module profile incompleto: profile_name, profile_dir e " + "device_mx_id são obrigatórios." + ) + + signature = profile.get("hardware_signature") + if not isinstance(signature, dict): + raise RuntimeError("Module profile sem hardware_signature válido.") + + for role in ROLES: + item = signature.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"hardware_signature sem role {role!r}.") + + if not item.get("socket") or not item.get("sensor"): + raise RuntimeError(f"hardware_signature/{role} sem socket/sensor.") + + size = item.get("size") + if not isinstance(size, (list, tuple)) or len(size) != 2: + raise RuntimeError(f"hardware_signature/{role}/size inválido: {size!r}") + + rgb_decode = profile.get("rgb_decode") + if not isinstance(rgb_decode, dict): + raise RuntimeError("Module profile sem rgb_decode válido.") + + bayer = str(rgb_decode.get("bayer_pattern") or "").upper() + if bayer not in BAYER_PATTERNS: + raise RuntimeError(f"rgb_decode.bayer_pattern inválido: {bayer!r}") + + orientations = profile.get("preview_orientation_defaults") + if not isinstance(orientations, dict): + raise RuntimeError("Module profile sem preview_orientation_defaults.") + + artifacts = profile.get("artifact_paths") + candidate_roots = artifacts.get("candidate_roots") if isinstance(artifacts, dict) else None + + if not isinstance(candidate_roots, dict) or not candidate_roots.get("focus"): + raise RuntimeError("Module profile sem artifact_paths.candidate_roots.focus.") + + if not artifacts.get("focus_qc_json"): + raise RuntimeError("Module profile sem artifact_paths.focus_qc_json.") + + +def load_module_profile_contract( + module_profile_arg: Optional[str], + active_selector_arg: str, +): + selector_path = None + selector = None + + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + try: + selector_path = resolve_existing_path(active_selector_arg) + except RuntimeError as exc: + raise RuntimeError( + "Active module profile não encontrado. Execute primeiro o " + "Script 0 ou informe --module-profile explicitamente." + ) from exc + selector = load_json_file(selector_path) + + if selector.get("schema") != ACTIVE_SELECTOR_SCHEMA: + raise RuntimeError( + f"Schema do seletor ativo inesperado: {selector.get('schema')!r}" + ) + + declared = selector.get("profile_path") + if not declared: + raise RuntimeError("Seletor ativo sem profile_path.") + + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json_file(profile_path) + validate_module_profile(profile, profile_path) + profile_sha256 = sha256_file(profile_path) + + if selector is not None: + declared_sha = str(selector.get("profile_sha256") or "").lower() + if declared_sha and declared_sha != profile_sha256.lower(): + raise RuntimeError( + "Hash do module profile diverge do active selector. " + "Execute novamente o Script 0 para oficializar o profile." + ) + + selector_mx = str(selector.get("device_mx_id") or "") + profile_mx = str(profile.get("device_mx_id") or "") + if selector_mx and selector_mx != profile_mx: + raise RuntimeError("MX ID do selector diverge do module profile.") + + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha256, + "selector": selector, + "selector_path": selector_path, + } + + +def resolve_output_path(value: str | Path) -> Path: + """Paths de artifact_paths são deliberadamente relativos à raiz do projeto.""" + return Path(value).expanduser() + + def safe_int(v, default=None): try: if v is None: @@ -599,9 +773,52 @@ def rect_from_norm(shape_hw, x0, y0, x1, y1): ) +def normalize_preview_orientation(cfg: Optional[dict], label: str = "preview") -> dict: + cfg = cfg or {} + + try: + rotate_deg = int(cfg.get("rotate_deg", 0)) % 360 + except Exception as exc: + raise RuntimeError(f"{label}: rotate_deg inválido.") from exc + + if rotate_deg not in (0, 90, 180, 270): + raise RuntimeError( + f"{label}: rotate_deg={rotate_deg}; permitidos=0,90,180,270." + ) + + flip_horizontal = cfg.get("flip_horizontal", False) + flip_vertical = cfg.get("flip_vertical", False) + + if not isinstance(flip_horizontal, bool) or not isinstance(flip_vertical, bool): + raise RuntimeError(f"{label}: flips devem ser booleanos.") + + return { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_horizontal, + "flip_vertical": flip_vertical, + } + + +def resolve_preview_orientation(profile: dict, specs: Dict[str, "SensorSpec"]) -> dict: + declared = profile.get("preview_orientation_defaults", {}) + result = {} + + for role in ROLES: + spec = specs[role] + fallback = SENSOR_ORIENTATION.get(spec.sensor_name, {}) + cfg = declared.get(role, fallback) + result[role] = normalize_preview_orientation( + cfg, + label=f"preview_orientation_defaults/{role}", + ) + + return result + + def apply_sensor_orientation( img: np.ndarray, sensor_name: str, + orientation_cfg: Optional[dict] = None, ) -> np.ndarray: if img is None: return img @@ -611,12 +828,11 @@ def apply_sensor_orientation( or "" ).strip().upper() - cfg = ( - SENSOR_ORIENTATION.get( - sensor, - {} - ) - or {} + cfg = normalize_preview_orientation( + orientation_cfg + if orientation_cfg is not None + else SENSOR_ORIENTATION.get(sensor, {}), + label=f"orientation/{sensor or 'unknown'}", ) rotate_deg = int( @@ -860,6 +1076,51 @@ def build_specs(camera_rows) -> Dict[str, SensorSpec]: return specs +def validate_profile_against_device( + profile: dict, + specs: Dict[str, SensorSpec], + actual_mx: Optional[str], +): + expected_mx = str(profile.get("device_mx_id") or "") + actual_mx_text = str(actual_mx or "") + + if expected_mx and actual_mx_text != expected_mx: + raise RuntimeError( + f"Câmera conectada não pertence ao profile ativo: " + f"MX esperado={expected_mx}, detectado={actual_mx_text or None}." + ) + + signature = profile["hardware_signature"] + errors = [] + + for role in ROLES: + expected = signature[role] + actual = specs[role] + expected_size = tuple(int(x) for x in expected["size"]) + actual_size = (actual.configured_width, actual.configured_height) + + if str(expected["socket"]).upper() != actual.socket_name.upper(): + errors.append( + f"{role}: socket profile={expected['socket']} device={actual.socket_name}" + ) + + if str(expected["sensor"]).upper() != actual.sensor_name.upper(): + errors.append( + f"{role}: sensor profile={expected['sensor']} device={actual.sensor_name}" + ) + + if expected_size != actual_size: + errors.append( + f"{role}: size profile={expected_size} device={actual_size}" + ) + + if errors: + raise RuntimeError( + "Hardware conectado diverge do module profile ativo:\n - " + + "\n - ".join(errors) + ) + + # ============================================================ # Pipeline e controles # ============================================================ @@ -1851,11 +2112,19 @@ class FocusRuntime: specs: Dict[str, SensorSpec], args, qa, + preview_orientation: Dict[str, dict], ): self.device = device self.specs = specs self.args = args self.qa = qa + self.preview_orientation = { + role: normalize_preview_orientation( + preview_orientation.get(role), + label=f"runtime_preview/{role}", + ) + for role in ROLES + } self.output_queues = { role: device.getOutputQueue( @@ -1947,6 +2216,7 @@ class FocusRuntime: img = apply_sensor_orientation( img, sensor_name=spec.sensor_name, + orientation_cfg=self.preview_orientation[role], ) if img.ndim == 2: @@ -3308,7 +3578,14 @@ def build_base_report( isp_settings, qa, candidate_dir, + profile_contract, + preview_orientation, ): + profile = profile_contract["profile"] + profile_path = profile_contract["profile_path"] + selector_path = profile_contract["selector_path"] + rgb_decode = dict(profile["rgb_decode"]) + return { "schema": SCHEMA, "created_at": now_str(), @@ -3326,7 +3603,41 @@ def build_base_report( ), "device_mx_id": actual_mx, "usb_speed": usb_speed, + "module_profile_contract": { + "schema": profile.get("schema"), + "profile_name": profile.get("profile_name"), + "profile_path": str(profile_path), + "profile_sha256": profile_contract["profile_sha256"], + "active_selector_path": ( + str(selector_path) if selector_path is not None else None + ), + "device_match": True, + "artifact_routing": { + "candidate_root": str(args.candidate_root), + "candidate_root_source": ( + "cli_override" + if args.candidate_root_overridden + else "module_profile.artifact_paths.candidate_roots.focus" + ), + "active_json": str(args.active_json), + "active_json_source": ( + "cli_override" + if args.active_json_overridden + else "module_profile.artifact_paths.focus_qc_json" + ), + }, + }, + "rgb_decode_provenance": { + **rgb_decode, + "used_by_focus_measurement": False, + "focus_measurement_source": "ISP", + "notes": ( + "Bayer/decode pertence ao module profile. O Focus apenas " + "registra a identidade; sua métrica usa o stream ISP." + ), + }, "topology": topology_report, + "hardware_signature": profile["hardware_signature"], "camera_inventory": [ { k: v @@ -3386,6 +3697,7 @@ def build_base_report( }, "notes": args.notes or "", "candidate_dir": str(candidate_dir), + "active_json": str(args.active_json), "preflight": None, "locked_controls": None, "calibration_roi_by_role": None, @@ -3395,14 +3707,8 @@ def build_base_report( "orientation_used": { role: { "sensor": specs[role].sensor_name, - **SENSOR_ORIENTATION.get( - specs[role].sensor_name, - { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, - ), + **preview_orientation[role], + "source": "module_profile.preview_orientation_defaults", } for role in ROLES }, @@ -3426,6 +3732,23 @@ def main(): parser.add_argument( "--mx-id", default=None, + help=( + "Confirmação explícita opcional. Deve coincidir com o module profile ativo." + ), + ) + + parser.add_argument( + "--module-profile", + default=None, + help=( + "Module profile explícito. Se omitido, resolve pelo active selector." + ), + ) + + parser.add_argument( + "--active-selector", + default="calibration/active_module_profile.json", + help="Seletor criado pelo Script 0.", ) parser.add_argument( @@ -3559,12 +3882,19 @@ def main(): # Output parser.add_argument( "--candidate-root", - default="calibration/focus_candidates", + default=None, + help=( + "Override de saída. Por padrão usa o module profile " + "artifact_paths.candidate_roots.focus." + ), ) parser.add_argument( "--active-json", - default="calibration/focus_qc_active.json", + default=None, + help=( + "Override. Por padrão usa artifact_paths.focus_qc_json do profile." + ), ) parser.add_argument( @@ -3611,6 +3941,36 @@ def main(): args = parser.parse_args() + # O Script 0 é a autoridade de identidade e roteamento. Focus não lê + # module_params, pois ele ainda não existe nesta etapa do fluxo. + profile_contract = load_module_profile_contract( + module_profile_arg=args.module_profile, + active_selector_arg=args.active_selector, + ) + module_profile = profile_contract["profile"] + artifacts = module_profile["artifact_paths"] + + args.candidate_root_overridden = args.candidate_root is not None + args.active_json_overridden = args.active_json is not None + + expected_mx = str(module_profile.get("device_mx_id") or "") + if args.mx_id and str(args.mx_id) != expected_mx: + raise RuntimeError( + f"--mx-id={args.mx_id} diverge do module profile ativo ({expected_mx})." + ) + + args.mx_id = expected_mx + args.candidate_root = str( + resolve_output_path( + args.candidate_root or artifacts["candidate_roots"]["focus"] + ) + ) + args.active_json = str( + resolve_output_path( + args.active_json or artifacts["focus_qc_json"] + ) + ) + # Campo mutável apenas em runtime para E/M. args.equalize_runtime = bool(args.equalize) @@ -3667,11 +4027,24 @@ def main(): camera_rows ) + validate_profile_against_device( + module_profile, + specs, + actual_mx, + ) + + preview_orientation = resolve_preview_orientation( + module_profile, + specs, + ) + print("=" * 82) print("FOCUS CALIBRATION - PRODUCTION") print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}") print(f"MX ID : {actual_mx}") print(f"USB : {usb_speed}") + print(f"Profile : {module_profile['profile_name']}") + print(f"Profile JSON: {profile_contract['profile_path']}") print("-" * 82) for role in ROLES: @@ -3686,6 +4059,13 @@ def main(): f"{spec.source}" ) + orient = preview_orientation[role] + print( + f" preview rotate={orient['rotate_deg']} " + f"flip_h={orient['flip_horizontal']} " + f"flip_v={orient['flip_vertical']} | source=module_profile" + ) + print("=" * 82) # ---------------------------------------------------- @@ -3722,6 +4102,8 @@ def main(): isp_settings, qa, candidate_dir, + profile_contract, + preview_orientation, ) with device_ctx as device: @@ -3730,6 +4112,7 @@ def main(): specs, args, qa, + preview_orientation, ) # ------------------------------------------------ diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py index 0806699bd..86c8e991c 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py @@ -16,7 +16,7 @@ Domínio: sem undistort externo Gera: - calibration/intrinsics_calibration_v1.json + calibration//intrinsics_calibration_v1.json O artefato contém: module_params_fragment.intrinsics_config @@ -31,15 +31,24 @@ IMPORTANTE: espaço nativo do sensor. Fluxo: - 1) Mostre o ChArUco nas 3 câmeras. - 2) ENTER trava EXP/ISO. - 3) R seleciona a ROI operacional da câmera escolhida (opcional). - 4) Enquadre o ChArUco no quadrilátero-guia. - 5) G captura e avança; F força captura fora da tolerância do guia. - 6) O roteiro varia X/Y, distância, yaw, pitch e roll. - 7) Ideal: completar as 25 poses guiadas. - 8) A calibra e executa acceptance. - 9) PASS promove o JSON ativo. + 1) Carrega o module profile criado pelo Script 0. + 2) Exige Focus QC ativo, homologado e pertencente ao mesmo profile. + 3) Mostre o ChArUco nas 3 câmeras. + 4) ENTER trava EXP/ISO. + 5) A ROI operacional nasce da CALIBRATION ROI do Focus. + 6) R pode selecionar outra ROI para esta sessão; C usa frame completo. + 7) Enquadre o ChArUco no quadrilátero-guia. + 8) G captura e avança; F força captura fora da tolerância do guia. + 9) O roteiro varia X/Y, distância, yaw, pitch e roll. + 10) Ideal: completar as 25 poses guiadas. + 11) A calibra e executa acceptance. + 12) PASS promove o JSON ativo dentro da pasta do profile. + +Contrato de bootstrap: + - NÃO lê module_params.json; ele ainda não existe nesta etapa. + - O MX ID, hardware, Bayer/decode, orientação e destinos vêm do Script 0. + - Bayer é registrado como proveniência, mas não participa da medição: + RGB usa ISP; RE/NIR usam MONO_OUT. Teclas: G captura view quando o guia estiver READY @@ -56,6 +65,7 @@ Teclas: from __future__ import annotations import argparse +import hashlib import json import math import os @@ -74,6 +84,10 @@ import numpy as np ROLES = ("rgb", "re", "nir") SCHEMA = "multispec_intrinsics_calibration_v1" CALIBRATION_SPACE = "native_stream_no_external_undistort" +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +FOCUS_SCHEMA = "multispec_focus_qc_v3" +BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") TOPOLOGY = { "rgb": ("CAM_A", ("OV9782", "AR0234")), @@ -212,6 +226,209 @@ def save_json_atomic(path, data): os.replace(tmp, p) +def sha256_file(path): + h = hashlib.sha256() + with Path(path).open("rb") as f: + while True: + chunk = f.read(1024 * 1024) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def load_json_file(path): + p = Path(path) + if not p.is_file(): + raise RuntimeError(f"JSON obrigatório não encontrado: {p}") + try: + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + raise RuntimeError(f"Falha ao ler JSON {p}: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"JSON deve conter um objeto na raiz: {p}") + return data + + +def resolve_existing_path(value, anchor=None): + raw = Path(value).expanduser() + candidates = [raw] + if not raw.is_absolute() and anchor is not None: + candidates.append(Path(anchor).parent / raw) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + tried = ", ".join(str(x) for x in candidates) + raise RuntimeError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def resolve_output_path(value): + """artifact_paths do Script 0 são relativos à raiz do projeto.""" + return Path(value).expanduser() + + +def validate_module_profile(profile, profile_path): + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema de module profile inesperado em {profile_path}: " + f"{profile.get('schema')!r}" + ) + if str(profile.get("status", "")).lower() != "active": + raise RuntimeError( + f"Module profile não está ativo: status={profile.get('status')!r}" + ) + + required = ("profile_name", "profile_dir", "device_mx_id") + missing = [key for key in required if not str(profile.get(key) or "").strip()] + if missing: + raise RuntimeError(f"Module profile incompleto: campos ausentes={missing}") + + signature = profile.get("hardware_signature") + if not isinstance(signature, dict): + raise RuntimeError("Module profile sem hardware_signature válido.") + for role in ROLES: + item = signature.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"hardware_signature sem role {role!r}.") + size = item.get("size") + if not item.get("socket") or not item.get("sensor"): + raise RuntimeError(f"hardware_signature/{role} sem socket/sensor.") + if not isinstance(size, (list, tuple)) or len(size) != 2: + raise RuntimeError(f"hardware_signature/{role}/size inválido: {size!r}") + + rgb_decode = profile.get("rgb_decode") + if not isinstance(rgb_decode, dict): + raise RuntimeError("Module profile sem rgb_decode válido.") + bayer = str(rgb_decode.get("bayer_pattern") or "").upper() + if bayer not in BAYER_PATTERNS: + raise RuntimeError(f"rgb_decode.bayer_pattern inválido: {bayer!r}") + + orientations = profile.get("preview_orientation_defaults") + if not isinstance(orientations, dict): + raise RuntimeError("Module profile sem preview_orientation_defaults.") + for role in ROLES: + normalize_preview_orientation(orientations.get(role)) + + artifacts = profile.get("artifact_paths") + roots = artifacts.get("candidate_roots") if isinstance(artifacts, dict) else None + if not isinstance(roots, dict) or not roots.get("intrinsics"): + raise RuntimeError( + "Module profile sem artifact_paths.candidate_roots.intrinsics." + ) + for key in ("focus_qc_json", "intrinsics_json"): + if not artifacts.get(key): + raise RuntimeError(f"Module profile sem artifact_paths.{key}.") + + +def load_module_profile_contract(module_profile_arg, active_selector_arg): + selector = None + selector_path = None + + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + try: + selector_path = resolve_existing_path(active_selector_arg) + except RuntimeError as exc: + raise RuntimeError( + "Active module profile não encontrado. Execute primeiro o " + "Script 0 ou informe --module-profile explicitamente." + ) from exc + selector = load_json_file(selector_path) + if selector.get("schema") != ACTIVE_SELECTOR_SCHEMA: + raise RuntimeError( + f"Schema do seletor ativo inesperado: {selector.get('schema')!r}" + ) + declared = selector.get("profile_path") + if not declared: + raise RuntimeError("Seletor ativo sem profile_path.") + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json_file(profile_path) + validate_module_profile(profile, profile_path) + profile_sha256 = sha256_file(profile_path) + + if selector is not None: + declared_sha = str(selector.get("profile_sha256") or "").lower() + if declared_sha and declared_sha != profile_sha256.lower(): + raise RuntimeError( + "Hash do module profile diverge do active selector. " + "Execute novamente o Script 0." + ) + if str(selector.get("device_mx_id") or "") != str( + profile.get("device_mx_id") or "" + ): + raise RuntimeError("MX ID do selector diverge do module profile.") + + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha256, + "selector": selector, + "selector_path": selector_path, + } + + +def load_focus_prerequisite(profile_contract): + profile = profile_contract["profile"] + declared = profile["artifact_paths"]["focus_qc_json"] + try: + path = resolve_existing_path(declared) + except RuntimeError as exc: + raise RuntimeError( + "Focus QC ativo não encontrado para este profile. Execute e aprove " + "o Script 1 antes dos intrínsecos." + ) from exc + + focus = load_json_file(path) + if focus.get("schema") != FOCUS_SCHEMA: + raise RuntimeError(f"Schema do Focus QC inesperado: {focus.get('schema')!r}") + if str(focus.get("status", "")).lower() != "pass": + raise RuntimeError(f"Focus QC não está PASS: status={focus.get('status')!r}") + if focus.get("promoted") is not True: + raise RuntimeError("Focus QC não foi promovido.") + if focus.get("normal_production_homologation") is not True: + raise RuntimeError("Focus QC não representa homologação normal de produção.") + if str(focus.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError("Focus QC pertence a outro MX ID.") + + provenance = focus.get("module_profile_contract") + if not isinstance(provenance, dict): + raise RuntimeError( + "Focus QC não possui o novo module_profile_contract. " + "Rode novamente o Script 1 revisado." + ) + if provenance.get("profile_sha256") != profile_contract["profile_sha256"]: + raise RuntimeError("Focus QC foi gerado com outra versão do module profile.") + if provenance.get("profile_name") != profile.get("profile_name"): + raise RuntimeError("Focus QC pertence a outro profile.") + + results = focus.get("results_by_role") + if not isinstance(results, dict) or not all( + isinstance(results.get(role), dict) and results[role].get("accepted") is True + for role in ROLES + ): + raise RuntimeError("Focus QC não contém acceptance das três câmeras.") + + focus_rois = focus.get("calibration_roi_by_role") + if not isinstance(focus_rois, dict) or not all( + isinstance(focus_rois.get(role), (list, tuple)) + and len(focus_rois[role]) == 4 + for role in ROLES + ): + raise RuntimeError("Focus QC não contém CALIBRATION ROI das três câmeras.") + + if focus.get("hardware_signature") != profile.get("hardware_signature"): + raise RuntimeError("Assinatura de hardware do Focus diverge do profile.") + + return { + "report": focus, + "path": path, + "sha256": sha256_file(path), + } + + def status_rank(s): return {"good": 0, "warning": 1, "bad": 2}.get(str(s), 2) @@ -480,38 +697,20 @@ def build_guide_targets(): ] -def resolve_preview_orientation(specs, args): +def resolve_preview_orientation(profile, specs): """ - Resolve orientação visual por sensor, com override opcional por role. + Resolve a orientação visual exclusivamente pelo module profile. Isso NÃO muda o domínio científico da calibração. """ result = {} + declared = profile.get("preview_orientation_defaults", {}) for role in ROLES: sensor = str(specs[role].sensor).upper() - - base = dict( - PREVIEW_ORIENTATION_BY_SENSOR.get( - sensor, - { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, - ) - ) - - rotate_override = getattr(args, f"{role}_preview_rotate_deg") - if rotate_override is not None: - base["rotate_deg"] = int(rotate_override) - - if getattr(args, f"{role}_preview_flip_horizontal"): - base["flip_horizontal"] = True - - if getattr(args, f"{role}_preview_flip_vertical"): - base["flip_vertical"] = True - + base = declared.get(role) + if not isinstance(base, dict): + base = PREVIEW_ORIENTATION_BY_SENSOR.get(sensor, {}) result[role] = normalize_preview_orientation(base) return result @@ -686,6 +885,64 @@ def hardware_signature(specs): } +def validate_profile_against_device(profile, specs, actual_mx): + expected_mx = str(profile.get("device_mx_id") or "") + detected_mx = str(actual_mx or "") + if detected_mx != expected_mx: + raise RuntimeError( + "Câmera conectada não pertence ao profile ativo: " + f"MX esperado={expected_mx}, detectado={detected_mx or None}." + ) + + expected_signature = profile["hardware_signature"] + detected_signature = hardware_signature(specs) + errors = [] + for role in ROLES: + expected = expected_signature[role] + detected = detected_signature[role] + for key in ("socket", "sensor", "size"): + if expected.get(key) != detected.get(key): + errors.append( + f"{role}/{key}: profile={expected.get(key)!r} " + f"device={detected.get(key)!r}" + ) + if errors: + raise RuntimeError( + "Hardware conectado diverge do module profile ativo:\n - " + + "\n - ".join(errors) + ) + + +def focus_roi_to_native(rect, spec, orientation): + """Converte a ROI canônica [x0,y0,x1,y1] do Focus para SENSOR-NATIVE.""" + if not isinstance(rect, (list, tuple)) or len(rect) != 4: + raise RuntimeError(f"ROI do Focus inválida para {spec.role}: {rect!r}") + x0, y0, x1, y1 = (int(x) for x in rect) + if x1 <= x0 or y1 <= y0: + raise RuntimeError(f"ROI do Focus vazia para {spec.role}: {rect!r}") + + canonical = np.asarray([ + [x0, y0], [x1 - 1, y0], + [x1 - 1, y1 - 1], [x0, y1 - 1], + ], dtype=np.float32) + native = unorient_points( + canonical, + (spec.height, spec.width), + orientation, + ) + nx0, ny0 = np.floor(native.min(axis=0)).astype(int) + nx1, ny1 = np.ceil(native.max(axis=0)).astype(int) + return normalize_roi( + { + "x": int(nx0), + "y": int(ny0), + "width": int(nx1 - nx0 + 1), + "height": int(ny1 - ny0 + 1), + }, + (spec.width, spec.height), + ) + + def build_pipeline(specs, fps, sharpness, luma, chroma): p = dai.Pipeline() isp = {"sharpness": None, "luma_denoise": None, "chroma_denoise": None, @@ -2234,7 +2491,19 @@ def main(): formatter_class=argparse.ArgumentDefaultsHelpFormatter ) - ap.add_argument("--mx-id", default=None) + ap.add_argument( + "--mx-id", default=None, + help="Confirmação opcional; deve coincidir com o module profile ativo.", + ) + ap.add_argument( + "--module-profile", default=None, + help="Profile explícito; se omitido, usa o active selector do Script 0.", + ) + ap.add_argument( + "--active-selector", + default="calibration/active_module_profile.json", + help="Seletor criado pelo Script 0.", + ) ap.add_argument("--fps", type=float, default=20.0) ap.add_argument("--isp-sharpness", type=int, default=0) @@ -2272,17 +2541,20 @@ def main(): metavar="X,Y,W,H", help=( "ROI operacional em coordenadas SENSOR-NATIVE. " - "Vazio usa o frame completo; também pode ser selecionada com R." + "Vazio herda a CALIBRATION ROI do Focus; também pode ser " + "selecionada com R." ), ) ap.add_argument( "--candidate-root", - default="calibration/intrinsics_candidates", + default=None, + help="Override; por padrão vem de artifact_paths.candidate_roots.intrinsics.", ) ap.add_argument( "--active-json", - default="calibration/intrinsics_calibration_v1.json", + default=None, + help="Override; por padrão vem de artifact_paths.intrinsics_json.", ) ap.add_argument("--module-id", default="") @@ -2292,31 +2564,42 @@ def main(): ap.add_argument("--lens-nir", default="") ap.add_argument("--notes", default="") - # Orientação SOMENTE de preview. - # Defaults vêm do sensor detectado; as flags abaixo são overrides locais. - for role in ROLES: - ap.add_argument( - f"--{role}-preview-rotate-deg", - type=int, - default=None, - choices=[0, 90, 180, 270], - help=( - "Rotação SOMENTE do preview. " - "Não altera o domínio da calibração intrínseca." - ), - ) - ap.add_argument( - f"--{role}-preview-flip-horizontal", - action="store_true", - help="Flip horizontal somente para preview.", - ) - ap.add_argument( - f"--{role}-preview-flip-vertical", - action="store_true", - help="Flip vertical somente para preview.", + args = ap.parse_args() + + if args.charuco_squares_x < 3 or args.charuco_squares_y < 3: + raise ValueError("ChArUco deve ter ao menos 3x3 quadrados.") + if not ( + args.charuco_square_length > 0 + and args.charuco_marker_length > 0 + and args.charuco_marker_length < args.charuco_square_length + ): + raise ValueError( + "Medidas ChArUco inválidas: use 0 < marker-length < square-length." ) - args = ap.parse_args() + profile_contract = load_module_profile_contract( + args.module_profile, + args.active_selector, + ) + module_profile = profile_contract["profile"] + focus_contract = load_focus_prerequisite(profile_contract) + artifacts = module_profile["artifact_paths"] + + args.candidate_root_overridden = args.candidate_root is not None + args.active_json_overridden = args.active_json is not None + + expected_mx = str(module_profile.get("device_mx_id") or "") + if args.mx_id and str(args.mx_id) != expected_mx: + raise RuntimeError( + f"--mx-id={args.mx_id} diverge do module profile ativo ({expected_mx})." + ) + args.mx_id = expected_mx + args.candidate_root = str(resolve_output_path( + args.candidate_root or artifacts["candidate_roots"]["intrinsics"] + )) + args.active_json = str(resolve_output_path( + args.active_json or artifacts["intrinsics_json"] + )) sid = stamp() candidate_dir = Path(args.candidate_root) / sid @@ -2349,13 +2632,26 @@ def main(): try: rows, actual_mx, usb = discover(args.mx_id) specs = validate_specs(rows) - preview_orientation = resolve_preview_orientation(specs, args) - operational_rois = { - role: parse_roi_arg( - getattr(args, f"{role}_operational_roi"), specs[role] - ) - for role in ROLES - } + validate_profile_against_device(module_profile, specs, actual_mx) + preview_orientation = resolve_preview_orientation(module_profile, specs) + + focus_rois = focus_contract["report"].get("calibration_roi_by_role", {}) + operational_rois = {} + operational_roi_sources = {} + for role in ROLES: + explicit_roi = getattr(args, f"{role}_operational_roi") + if explicit_roi: + operational_rois[role] = parse_roi_arg(explicit_roi, specs[role]) + operational_roi_sources[role] = "cli_override" + else: + operational_rois[role] = focus_roi_to_native( + focus_rois.get(role), + specs[role], + preview_orientation[role], + ) + operational_roi_sources[role] = ( + "focus_qc.calibration_roi_by_role_converted_to_sensor_native" + ) guide_targets = [] if args.no_guide else build_guide_targets() pipeline, isp = build_pipeline( @@ -2370,6 +2666,9 @@ def main(): print("INTRINSICS CALIBRATION - PRODUCTION") print(f"MX ID: {actual_mx}") print(f"USB : {usb}") + print(f"Profile: {module_profile['profile_name']}") + print(f"Profile JSON: {profile_contract['profile_path']}") + print(f"Focus QC: {focus_contract['path']}") for role in ROLES: s = specs[role] @@ -2387,7 +2686,13 @@ def main(): f"rotate={o['rotate_deg']:3d} | " f"flip_h={o['flip_horizontal']} | " f"flip_v={o['flip_vertical']} | " - "runtime_effect=NONE" + "source=module_profile | runtime_effect=NONE" + ) + roi = operational_rois[role] + print( + f"[ROI] {role.upper():3s} | " + f"{roi['x']},{roi['y']},{roi['width']},{roi['height']} | " + f"source={operational_roi_sources[role]}" ) print("=" * 82) @@ -2403,8 +2708,57 @@ def main(): "device_mx_id": actual_mx, "usb_speed": usb, "hardware_signature": hardware_signature(specs), + "module_profile_contract": { + "schema": module_profile.get("schema"), + "profile_name": module_profile.get("profile_name"), + "profile_path": str(profile_contract["profile_path"]), + "profile_sha256": profile_contract["profile_sha256"], + "active_selector_path": ( + str(profile_contract["selector_path"]) + if profile_contract["selector_path"] is not None + else None + ), + "device_match": True, + "artifact_routing": { + "candidate_root": str(args.candidate_root), + "candidate_root_source": ( + "cli_override" + if args.candidate_root_overridden + else "module_profile.artifact_paths.candidate_roots.intrinsics" + ), + "active_json": str(args.active_json), + "active_json_source": ( + "cli_override" + if args.active_json_overridden + else "module_profile.artifact_paths.intrinsics_json" + ), + }, + }, + "prerequisites": { + "focus_qc": { + "path": str(focus_contract["path"]), + "sha256": focus_contract["sha256"], + "schema": focus_contract["report"].get("schema"), + "status": focus_contract["report"].get("status"), + "promoted": focus_contract["report"].get("promoted"), + "normal_production_homologation": focus_contract[ + "report" + ].get("normal_production_homologation"), + "validated": True, + }, + }, + "rgb_decode_provenance": { + **module_profile["rgb_decode"], + "used_by_intrinsics_measurement": False, + "intrinsics_rgb_source": "ISP", + "notes": ( + "Bayer/decode é identidade do profile, mas K/D da RGB são " + "medidos diretamente no stream ISP full-resolution." + ), + }, "calibration_space": CALIBRATION_SPACE, "operational_rois_native": operational_rois, + "operational_roi_sources": operational_roi_sources, "guided_capture": { "enabled": not args.no_guide, "guide_role": args.guide_role, @@ -2662,7 +3016,9 @@ def main(): ) if chosen is not None: operational_rois[selected_role] = chosen + operational_roi_sources[selected_role] = "interactive_manual" report["operational_rois_native"] = operational_rois + report["operational_roi_sources"] = operational_roi_sources save_json_atomic(report_path, report) message = ( f"ROI {selected_role.upper()}=" @@ -2682,7 +3038,9 @@ def main(): operational_rois[selected_role] = full_roi( specs[selected_role] ) + operational_roi_sources[selected_role] = "interactive_full_frame" report["operational_rois_native"] = operational_rois + report["operational_roi_sources"] = operational_roi_sources save_json_atomic(report_path, report) message = f"ROI {selected_role.upper()} = frame completo." message_t = time.time() diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py index 134d8fc6e..088fafa0a 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py @@ -14,10 +14,10 @@ Setup suportado: Princípios de produto: ---------------------- -1. Descobre e valida o hardware antes de iniciar. -2. Configura resolução por SENSOR, não por resolução global. +1. Carrega o Module Profile ativo criado pelo Script 0. +2. Valida MX ID e hardware conectado contra esse contrato. 3. Captura RAW10 nativo de cada sensor. -4. Usa o mesmo RawProcessorCore da aplicação para obter R/G/B/RE/NIR. +4. Usa no RawProcessorCore exatamente o Bayer e o decode do Module Profile. 5. Deixa AE estabilizar no WHITE e então CONGELA exposição/ISO. 6. WHITE, DARK e WHITE-VALIDATION usam exatamente os mesmos controles. 7. Não redimensiona dark para "caber": shape divergente é erro fatal. @@ -27,9 +27,11 @@ Princípios de produto: 11. Sempre salva um candidate auditável. 12. Só promove para "active" se o acceptance test passar. 13. Preserva calibração ativa anterior em caso de falha. -14. Gera metadados, hashes, previews e sugestão de flatfield_config. +14. Gera metadados, hashes, previews e module_params_fragment. 15. Orientação de câmera é SOMENTE visual neste calibrador; o Flat-Field científico permanece no espaço nativo/decode do sensor. +16. Não lê module_params.json: nesta etapa ele ainda não existe. +17. Não contém fallback de Bayer, dimensão, socket ou orientação por sensor. Dependências: ------------- @@ -60,35 +62,39 @@ Fluxo: Saídas: ------- - calibration/flatfield_candidates// + calibration//flatfield_candidates// flatfield_maps.npz report.json previews/ PASS.txt ou FAIL.txt Se PASS: - calibration/flatfield_maps_v1.npz - calibration/flatfield_maps_v1.json + calibration//flatfield_maps_v1.npz + calibration//flatfield_maps_v1.json -Observação importante sobre Bayer: ----------------------------------- - --rgb-bayer auto usa: - OV9782 -> BGGR (configuração histórica do módulo deste projeto) - AR0234 -> GRBG (CFA nativo usual do AR0234) +Contrato de bootstrap: +---------------------- + O Script 0 é a única autoridade para: + - device_mx_id e hardware_signature; + - rgb_decode (Bayer, mode e demosaic_algorithm); + - preview_orientation_defaults; + - artifact_paths e candidate_roots. - Para produto, o Bayer resolvido fica registrado no report.json. - Se o seu módulo AR0234 específico estiver montado com orientação/mirror - diferente, use --rgb-bayer RGGB|BGGR|GRBG|GBRG explicitamente. + O Focus QC é pré-requisito físico obrigatório: trocar foco/lente altera + o shading. Intrinsics é apenas proveniência geométrica opcional e nunca + participa do cálculo científico do Flat. Exemplo: -------- - python flatfield_calibration_production.py + python -m utils._3_flatfield_calibration_tool - python flatfield_calibration_production.py ^ - --module-params calibration/module_params.json ^ + python -m utils._3_flatfield_calibration_tool ^ --frames 60 ^ --validation-frames 30 + python -m utils._3_flatfield_calibration_tool ^ + --module-profile calibration/mp_ar0234/module_profile_v1.json + Teclas: ------- ENTER = avançar/iniciar etapa @@ -139,59 +145,12 @@ from core.raw_processor_core import RawProcessorCore ROLES = ("rgb", "re", "nir") CHANNELS = ("R", "G", "B", "RE", "NIR") - -ROLE_TO_SOCKET = { - "rgb": "CAM_A", - "re": "CAM_B", - "nir": "CAM_C", -} - -EXPECTED_MONO_SENSOR = "OV9282" - -SUPPORTED_RGB = { - "OV9782": { - "width": 1280, - "height": 800, - "resolution_enum": "THE_800_P", - "default_bayer": "BGGR", - }, - "AR0234": { - "width": 1920, - "height": 1200, - "resolution_enum": "THE_1200_P", - "default_bayer": "GRBG", - }, -} - -SUPPORTED_MONO = { - "OV9282": { - "width": 1280, - "height": 800, - "resolution_enum": "THE_800_P", - }, -} - BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") -# Orientação SOMENTE de preview/diagnóstico. -# O Flat-Field científico permanece no espaço nativo/decode do sensor. -PREVIEW_ORIENTATION_BY_SENSOR = { - "OV9782": { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, - "AR0234": { - "rotate_deg": 180, - "flip_horizontal": False, - "flip_vertical": False, - }, - "OV9282": { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, -} +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +FOCUS_SCHEMA = "multispec_focus_qc_v3" +INTRINSICS_SCHEMA = "multispec_intrinsics_calibration_v1" # Raw10 RAW10_MAX = 1023.0 @@ -273,14 +232,18 @@ def ensure_dir(path: str | Path): Path(path).mkdir(parents=True, exist_ok=True) -def load_json(path: Optional[str]) -> dict: - if not path: - return {} +def load_json_file(path: str | Path) -> dict: p = Path(path) if not p.is_file(): - return {} - with p.open("r", encoding="utf-8") as f: - return json.load(f) + raise RuntimeError(f"JSON obrigatório não encontrado: {p}") + try: + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + raise RuntimeError(f"Falha ao ler JSON {p}: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"JSON deve conter um objeto na raiz: {p}") + return data def save_json(path: str | Path, data: dict): @@ -305,6 +268,23 @@ def sha256_file(path: str | Path) -> str: return h.hexdigest() +def resolve_existing_path(value: str | Path, anchor: Optional[Path] = None) -> Path: + raw = Path(value).expanduser() + candidates = [raw] + if not raw.is_absolute() and anchor is not None: + candidates.append(Path(anchor).parent / raw) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + tried = ", ".join(str(x) for x in candidates) + raise RuntimeError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def resolve_output_path(value: str | Path) -> Path: + """artifact_paths do Script 0 são relativos à raiz do projeto.""" + return Path(value).expanduser() + + def safe_float(v, default=None): try: if v is None: @@ -499,78 +479,291 @@ def apply_preview_orientation( return np.ascontiguousarray(img) -def resolve_preview_orientation( - specs: Dict[str, "CameraSpec"], - args, - module_params: Optional[dict] = None, -) -> Dict[str, dict]: - """ - Precedência: - 1. defaults por sensor; - 2. camera_orientation do module_params, se enabled=true; - 3. overrides explícitos de CLI. - - O resultado afeta SOMENTE UI/previews. - """ - module_params = module_params or {} - mp_orientation = module_params.get("camera_orientation", {}) - mp_orientation_enabled = bool( - isinstance(mp_orientation, dict) - and mp_orientation.get("enabled", False) - ) - mp_by_role = ( - mp_orientation.get("by_role", {}) - if mp_orientation_enabled - else {} - ) +def resolve_preview_orientation(profile: dict) -> Dict[str, dict]: + """Carrega a orientação visual diretamente do Module Profile.""" + declared = profile.get("preview_orientation_defaults") + if not isinstance(declared, dict): + raise RuntimeError("Module Profile sem preview_orientation_defaults.") result = {} - for role in ROLES: - sensor = str(specs[role].sensor_name or "").upper() - - base = dict( - PREVIEW_ORIENTATION_BY_SENSOR.get( - sensor, - { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, + item = declared.get(role) + if not isinstance(item, dict): + raise RuntimeError( + f"Module Profile sem orientação de preview para {role}." ) - ) - base["source"] = "sensor_default" - - mp_role = mp_by_role.get(role) if isinstance(mp_by_role, dict) else None - if isinstance(mp_role, dict): - for key in ("rotate_deg", "flip_horizontal", "flip_vertical"): - if key in mp_role: - base[key] = mp_role[key] - base["source"] = "module_params" - - rotate_override = getattr(args, f"{role}_preview_rotate_deg", None) - cli_override = False - - if rotate_override is not None: - base["rotate_deg"] = int(rotate_override) - cli_override = True - - if bool(getattr(args, f"{role}_preview_flip_horizontal", False)): - base["flip_horizontal"] = True - cli_override = True - - if bool(getattr(args, f"{role}_preview_flip_vertical", False)): - base["flip_vertical"] = True - cli_override = True - - if cli_override: - base["source"] = "cli_override" - - result[role] = normalize_preview_orientation(base) - + missing = [ + key + for key in ("rotate_deg", "flip_horizontal", "flip_vertical") + if key not in item + ] + if missing: + raise RuntimeError( + f"Orientação de preview incompleta em {role}: faltam={missing}" + ) + cfg = dict(item) + cfg["source"] = "module_profile" + result[role] = normalize_preview_orientation(cfg) return result +def validate_module_profile(profile: dict, profile_path: Path): + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema de Module Profile inesperado em {profile_path}: " + f"{profile.get('schema')!r}" + ) + if str(profile.get("status", "")).lower() != "active": + raise RuntimeError( + f"Module Profile não está ativo: status={profile.get('status')!r}" + ) + + for key in ("profile_name", "profile_dir", "device_mx_id"): + if not str(profile.get(key) or "").strip(): + raise RuntimeError(f"Module Profile sem {key}.") + + signature = profile.get("hardware_signature") + if not isinstance(signature, dict): + raise RuntimeError("Module Profile sem hardware_signature válido.") + for role in ROLES: + item = signature.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"hardware_signature sem role {role}.") + size = item.get("size") + if not item.get("socket") or not item.get("sensor"): + raise RuntimeError(f"hardware_signature/{role} sem socket/sensor.") + if not isinstance(size, (list, tuple)) or len(size) != 2: + raise RuntimeError(f"hardware_signature/{role}/size inválido: {size!r}") + if int(size[0]) <= 0 or int(size[1]) <= 0: + raise RuntimeError(f"hardware_signature/{role}/size vazio: {size!r}") + + resolved = profile.get("resolved_setup") + if not isinstance(resolved, dict): + raise RuntimeError("Module Profile sem resolved_setup válido.") + for role in ROLES: + item = resolved.get(role) + if not isinstance(item, dict) or not str( + item.get("resolution_name") or "" + ).strip(): + raise RuntimeError( + f"resolved_setup/{role}/resolution_name ausente." + ) + expected = signature[role] + declared_size = [item.get("width"), item.get("height")] + if ( + item.get("socket") != expected.get("socket") + or item.get("sensor") != expected.get("sensor") + or declared_size != list(expected.get("size")) + ): + raise RuntimeError( + f"resolved_setup/{role} diverge de hardware_signature." + ) + + rgb_decode = profile.get("rgb_decode") + if not isinstance(rgb_decode, dict): + raise RuntimeError("Module Profile sem rgb_decode válido.") + bayer = str(rgb_decode.get("bayer_pattern") or "").upper() + mode = str(rgb_decode.get("mode") or "").strip().lower() + algorithm = str(rgb_decode.get("demosaic_algorithm") or "").strip().lower() + if bayer not in BAYER_PATTERNS: + raise RuntimeError(f"rgb_decode.bayer_pattern inválido: {bayer!r}") + if mode not in {"linear_demosaic", "linear_demosaic_half", "bayer_planes"}: + raise RuntimeError(f"rgb_decode.mode inválido: {mode!r}") + if algorithm not in {"ea", "bilinear"}: + raise RuntimeError( + f"rgb_decode.demosaic_algorithm inválido: {algorithm!r}" + ) + + resolve_preview_orientation(profile) + + artifacts = profile.get("artifact_paths") + roots = artifacts.get("candidate_roots") if isinstance(artifacts, dict) else None + if not isinstance(roots, dict) or not roots.get("flatfield"): + raise RuntimeError( + "Module Profile sem artifact_paths.candidate_roots.flatfield." + ) + for key in ("focus_qc_json", "intrinsics_json", "flatfield_npz", "flatfield_json"): + if not artifacts.get(key): + raise RuntimeError(f"Module Profile sem artifact_paths.{key}.") + + +def load_module_profile_contract( + module_profile_arg: Optional[str], + active_selector_arg: str, +) -> dict: + selector = None + selector_path = None + + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + try: + selector_path = resolve_existing_path(active_selector_arg) + except RuntimeError as exc: + raise RuntimeError( + "Active Module Profile não encontrado. Execute primeiro o " + "Script 0 ou informe --module-profile explicitamente." + ) from exc + selector = load_json_file(selector_path) + if selector.get("schema") != ACTIVE_SELECTOR_SCHEMA: + raise RuntimeError( + f"Schema do seletor ativo inesperado: {selector.get('schema')!r}" + ) + declared = selector.get("profile_path") + if not declared: + raise RuntimeError("Seletor ativo sem profile_path.") + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json_file(profile_path) + validate_module_profile(profile, profile_path) + profile_sha256 = sha256_file(profile_path) + + if selector is not None: + declared_sha = str(selector.get("profile_sha256") or "").lower() + if declared_sha and declared_sha != profile_sha256.lower(): + raise RuntimeError( + "Hash do Module Profile diverge do active selector. " + "Execute novamente o Script 0." + ) + if str(selector.get("device_mx_id") or "") != str( + profile.get("device_mx_id") or "" + ): + raise RuntimeError("MX ID do selector diverge do Module Profile.") + + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha256, + "selector": selector, + "selector_path": selector_path, + } + + +def artifact_provenance(profile_contract: dict) -> dict: + profile = profile_contract["profile"] + return { + "profile_name": profile["profile_name"], + "profile_path": str(profile_contract["profile_path"]), + "profile_sha256": profile_contract["profile_sha256"], + "device_mx_id": profile["device_mx_id"], + "selector_path": ( + None + if profile_contract["selector_path"] is None + else str(profile_contract["selector_path"]) + ), + } + + +def validate_artifact_profile( + report: dict, + profile_contract: dict, + label: str, +): + profile = profile_contract["profile"] + provenance = report.get("module_profile_contract") + if not isinstance(provenance, dict): + raise RuntimeError( + f"{label} não possui module_profile_contract. Rode novamente o " + "calibrador revisado correspondente." + ) + if provenance.get("profile_sha256") != profile_contract["profile_sha256"]: + raise RuntimeError(f"{label} foi gerado com outra versão do Module Profile.") + if provenance.get("profile_name") != profile.get("profile_name"): + raise RuntimeError(f"{label} pertence a outro profile.") + + +def canonical_hardware_signature(value: dict) -> dict: + if not isinstance(value, dict): + return {} + result = {} + for role in ROLES: + item = value.get(role) + if not isinstance(item, dict): + return {} + size = item.get("size") + result[role] = { + "socket": item.get("socket"), + "sensor": item.get("sensor"), + "size": list(size) if isinstance(size, (list, tuple)) else size, + } + return result + + +def load_focus_prerequisite(profile_contract: dict) -> dict: + profile = profile_contract["profile"] + try: + path = resolve_existing_path( + profile["artifact_paths"]["focus_qc_json"], + anchor=profile_contract["profile_path"], + ) + except RuntimeError as exc: + raise RuntimeError( + "Focus QC ativo não encontrado para este profile. Execute e " + "aprove o Script 1 antes do Flat Field." + ) from exc + focus = load_json_file(path) + if focus.get("schema") != FOCUS_SCHEMA: + raise RuntimeError(f"Schema do Focus QC inesperado: {focus.get('schema')!r}") + if str(focus.get("status", "")).lower() != "pass": + raise RuntimeError(f"Focus QC não está PASS: {focus.get('status')!r}") + if focus.get("promoted") is not True: + raise RuntimeError("Focus QC não foi promovido.") + if focus.get("normal_production_homologation") is not True: + raise RuntimeError("Focus QC não representa homologação normal de produção.") + if str(focus.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError("Focus QC pertence a outro MX ID.") + if canonical_hardware_signature(focus.get("hardware_signature")) != ( + canonical_hardware_signature(profile.get("hardware_signature")) + ): + raise RuntimeError("Hardware do Focus QC diverge do Module Profile.") + validate_artifact_profile(focus, profile_contract, "Focus QC") + + results = focus.get("results_by_role") + if not isinstance(results, dict) or not all( + isinstance(results.get(role), dict) and results[role].get("accepted") is True + for role in ROLES + ): + raise RuntimeError("Focus QC não aprovou as três câmeras.") + return {"path": path, "sha256": sha256_file(path), "report": focus} + + +def load_optional_intrinsics(profile_contract: dict) -> dict: + """Intrinsics é rastreado, mas não altera o domínio científico do Flat.""" + profile = profile_contract["profile"] + declared = profile["artifact_paths"]["intrinsics_json"] + try: + path = resolve_existing_path( + declared, + anchor=profile_contract["profile_path"], + ) + except RuntimeError: + return {"present": False, "scientific_dependency": False} + + report = load_json_file(path) + if report.get("schema") != INTRINSICS_SCHEMA: + raise RuntimeError( + f"Schema de Intrinsics inesperado: {report.get('schema')!r}" + ) + if str(report.get("status", "")).lower() not in {"pass", "warning_promoted"}: + raise RuntimeError( + f"Intrinsics existente não está promovido: {report.get('status')!r}" + ) + if report.get("promoted") is not True: + raise RuntimeError("Intrinsics existente não foi promovido.") + if canonical_hardware_signature(report.get("hardware_signature")) != ( + canonical_hardware_signature(profile.get("hardware_signature")) + ): + raise RuntimeError("Hardware de Intrinsics diverge do Module Profile.") + validate_artifact_profile(report, profile_contract, "Intrinsics") + return { + "present": True, + "scientific_dependency": False, + "path": str(path), + "sha256": sha256_file(path), + "status": report.get("status"), + } + + def status_rank(status: str) -> int: return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 2) @@ -811,131 +1004,92 @@ def get_socket(name: str): return mapping[name] -def resolve_bayer(sensor_name: str, arg_value: str, module_params: dict) -> str: - arg = str(arg_value or "auto").upper() - - if arg != "AUTO": - if arg not in BAYER_PATTERNS: - raise ValueError(f"Bayer inválido: {arg}") - return arg - - # 1) Procura configuração explícita no module_params. - candidates = [ - module_params.get("bayer_pattern"), - module_params.get("bayer"), - ] - - cam_a_cfg = ( - module_params.get("camera_info", {}).get("CAM_A", {}) - if isinstance(module_params.get("camera_info"), dict) - else {} - ) - - if isinstance(cam_a_cfg, dict): - candidates.extend([ - cam_a_cfg.get("bayer_pattern"), - cam_a_cfg.get("bayer"), - ]) - - for c in candidates: - if c and str(c).upper() in BAYER_PATTERNS: - return str(c).upper() - - # 2) Fallback sensor-aware. - sensor = str(sensor_name).upper() - if sensor in SUPPORTED_RGB: - return SUPPORTED_RGB[sensor]["default_bayer"] - - raise RuntimeError( - f"Não foi possível resolver Bayer automaticamente para {sensor_name}. " - f"Use --rgb-bayer explicitamente." - ) - - def validate_and_build_specs( features: list[dict], - rgb_bayer_arg: str, - module_params: dict, + profile: dict, ) -> Dict[str, CameraSpec]: by_socket = {x["socket_name"]: x for x in features} + signature = profile["hardware_signature"] + resolved = profile["resolved_setup"] + bayer = str(profile["rgb_decode"]["bayer_pattern"]).upper() + specs = {} + errors = [] - missing = [sock for sock in ("CAM_A", "CAM_B", "CAM_C") if sock not in by_socket] - if missing: - raise RuntimeError(f"Módulo incompleto. Sockets ausentes: {missing}") + for role in ROLES: + expected = signature[role] + socket = str(expected["socket"]) + sensor = str(expected["sensor"]).upper() + width, height = (int(x) for x in expected["size"]) + row = by_socket.get(socket) - rgb_row = by_socket["CAM_A"] - re_row = by_socket["CAM_B"] - nir_row = by_socket["CAM_C"] - - rgb_sensor = rgb_row["sensor_name"].upper() - - if rgb_sensor not in SUPPORTED_RGB: - raise RuntimeError( - f"CAM_A deve ser OV9782 ou AR0234. Detectado: {rgb_row['sensor_name']}" - ) - - for role, row in (("RE", re_row), ("NIR", nir_row)): - if row["sensor_name"].upper() != EXPECTED_MONO_SENSOR: - raise RuntimeError( - f"{role} deve usar OV9282. Detectado em " - f"{'CAM_B' if role == 'RE' else 'CAM_C'}: {row['sensor_name']}" + if row is None: + errors.append(f"{role}: socket {socket} ausente") + continue + if str(row["sensor_name"]).upper() != sensor: + errors.append( + f"{role}/{socket}: sensor profile={sensor}, " + f"device={row['sensor_name']}" + ) + if row["width"] and row["height"] and ( + int(row["width"]), int(row["height"]) + ) != (width, height): + errors.append( + f"{role}/{socket}: size profile={width}x{height}, " + f"device={row['width']}x{row['height']}" ) - rgb_cfg = SUPPORTED_RGB[rgb_sensor] - bayer = resolve_bayer(rgb_sensor, rgb_bayer_arg, module_params) + resolution_enum = str(resolved[role]["resolution_name"]) - specs = { - "rgb": CameraSpec( - role="rgb", - socket_name="CAM_A", - sensor_name=rgb_sensor, - width=rgb_cfg["width"], - height=rgb_cfg["height"], - resolution_enum=rgb_cfg["resolution_enum"], - stream_name="raw_rgb", - control_name="ctrl_rgb", - is_color=True, - bayer_pattern=bayer, - ), - "re": CameraSpec( - role="re", - socket_name="CAM_B", - sensor_name="OV9282", - width=1280, - height=800, - resolution_enum="THE_800_P", - stream_name="raw_re", - control_name="ctrl_re", - is_color=False, - ), - "nir": CameraSpec( - role="nir", - socket_name="CAM_C", - sensor_name="OV9282", - width=1280, - height=800, - resolution_enum="THE_800_P", - stream_name="raw_nir", - control_name="ctrl_nir", - is_color=False, - ), + supported = [str(x).upper() for x in row.get("supported_types", [])] + if role == "rgb" and any("MONO" in x for x in supported): + errors.append(f"{role}/{socket}/{sensor}: anunciado como MONO") + if role != "rgb" and any("COLOR" in x for x in supported): + errors.append(f"{role}/{socket}/{sensor}: anunciado como COLOR") + + specs[role] = CameraSpec( + role=role, + socket_name=socket, + sensor_name=sensor, + width=width, + height=height, + resolution_enum=resolution_enum, + stream_name=f"raw_{role}", + control_name=f"ctrl_{role}", + is_color=(role == "rgb"), + bayer_pattern=bayer if role == "rgb" else None, + ) + + if errors: + raise RuntimeError( + "Hardware conectado diverge do Module Profile:\n - " + + "\n - ".join(errors) + ) + return specs + + +def hardware_signature(specs: Dict[str, CameraSpec]) -> dict: + return { + role: { + "socket": specs[role].socket_name, + "sensor": specs[role].sensor_name, + "size": [specs[role].width, specs[role].height], + } + for role in ROLES } - # Valida o que o device anunciou, sem aceitar silent mismatch. - for role, spec in specs.items(): - row = by_socket[spec.socket_name] - if row["width"] and row["height"]: - if (row["width"], row["height"]) != (spec.width, spec.height): - # Alguns firmwares podem anunciar dimensão diferente da mode table. - # Produto: não adivinhamos. - raise RuntimeError( - f"{spec.socket_name}/{spec.sensor_name}: dimensão anunciada " - f"{row['width']}x{row['height']} diverge da esperada " - f"{spec.width}x{spec.height}." - ) - - return specs +def validate_profile_against_device( + profile: dict, + specs: Dict[str, CameraSpec], + actual_mx: Optional[str], +): + if str(actual_mx or "") != str(profile.get("device_mx_id") or ""): + raise RuntimeError( + "Câmera conectada não pertence ao Module Profile ativo: " + f"esperado={profile.get('device_mx_id')!r}, detectado={actual_mx!r}." + ) + if hardware_signature(specs) != profile.get("hardware_signature"): + raise RuntimeError("Assinatura do hardware conectado diverge do profile.") # ============================================================ @@ -2009,9 +2163,8 @@ def disable_nonflat_processing(core: RawProcessorCore): - enhancement; - ganhos RGB globais/calibração de cor. - O único item herdável do module_params é a estratégia de DECODE RGB - (mode + demosaic_algorithm), pois o mapa precisa nascer no mesmo domínio - espacial em que será aplicado na aplicação. + Bayer, mode e demosaic_algorithm vêm do Module Profile. Nenhum artefato + final de calibração é carregado durante a construção do novo Flat. """ try: core.flatfield_config["enabled"] = False @@ -2077,72 +2230,61 @@ def disable_nonflat_processing(core: RawProcessorCore): def create_processing_core( specs: Dict[str, CameraSpec], - module_params_path: Optional[str], + rgb_decode: dict, ) -> RawProcessorCore: """ Cria um RawProcessorCore LIMPO. - Regra importante: - NUNCA passa module_params como calibration_json_path aqui. O construtor do - RawProcessorCore pode carregar flatfield_config imediatamente, portanto um - flat antigo da OV9782 poderia contaminar ou até quebrar a calibração da - AR0234 antes de conseguirmos desligá-lo. - - Do module_params copiamos apenas: - rgb_processing.mode - rgb_processing.demosaic_algorithm + Regra importante: calibration_json_path permanece None. Assim nenhum + module_params ou Flat anterior pode participar da própria medição. """ rgb = specs["rgb"] + bayer = str(rgb_decode.get("bayer_pattern") or "").upper() + mode = str(rgb_decode.get("mode") or "").strip().lower() + algorithm = str(rgb_decode.get("demosaic_algorithm") or "").strip().lower() + + if bayer != str(rgb.bayer_pattern or "").upper(): + raise RuntimeError( + f"Bayer do spec ({rgb.bayer_pattern}) diverge do profile ({bayer})." + ) + if mode not in {"linear_demosaic", "linear_demosaic_half", "bayer_planes"}: + raise RuntimeError(f"rgb_decode.mode incompatível: {mode!r}") + if algorithm not in {"ea", "bilinear"}: + raise RuntimeError( + f"rgb_decode.demosaic_algorithm incompatível: {algorithm!r}" + ) core = RawProcessorCore( sensor_width=rgb.width, sensor_height=rgb.height, - bayer_pattern=rgb.bayer_pattern or "BGGR", + bayer_pattern=bayer, calibration_json_path=None, ) - - module_cfg = load_json(module_params_path) - - rgb_cfg = module_cfg.get("rgb_processing") - if isinstance(rgb_cfg, dict): - mode = str( - rgb_cfg.get("mode", core.rgb_processing_config.get("mode", "linear_demosaic")) - ).strip().lower() - - allowed_modes = { - "linear_demosaic", - "linear_demosaic_half", - "bayer_planes", - } - - if mode not in allowed_modes: - raise RuntimeError( - "rgb_processing.mode do module_params não é seguro/compatível " - f"com este calibrador: {mode!r}. Permitidos: {sorted(allowed_modes)}" - ) - - algorithm = str( - rgb_cfg.get( - "demosaic_algorithm", - core.rgb_processing_config.get("demosaic_algorithm", "ea"), - ) - ).strip().lower() - - if algorithm not in {"ea", "bilinear"}: - raise RuntimeError( - "rgb_processing.demosaic_algorithm inválido no module_params: " - f"{algorithm!r}. Use 'ea' ou 'bilinear'." - ) - - core.rgb_processing_config["mode"] = mode - core.rgb_processing_config["demosaic_algorithm"] = algorithm - - # A identidade do sensor recém-descoberto sempre vence qualquer JSON antigo. + core.rgb_processing_config["mode"] = mode + core.rgb_processing_config["demosaic_algorithm"] = algorithm core.sensor_width = int(rgb.width) core.sensor_height = int(rgb.height) - core.bayer_pattern = str(rgb.bayer_pattern or "BGGR").upper() + core.bayer_pattern = bayer disable_nonflat_processing(core) + + actual = { + "bayer_pattern": str(core.bayer_pattern).upper(), + "mode": str(core.rgb_processing_config.get("mode") or "").lower(), + "demosaic_algorithm": str( + core.rgb_processing_config.get("demosaic_algorithm") or "" + ).lower(), + } + expected = { + "bayer_pattern": bayer, + "mode": mode, + "demosaic_algorithm": algorithm, + } + if actual != expected: + raise RuntimeError( + f"RawProcessorCore não preservou o rgb_decode do profile: " + f"esperado={expected}, atual={actual}" + ) return core @@ -2730,29 +2872,34 @@ def main(): formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - # Hardware - parser.add_argument("--mx-id", default=None) + # Contrato do módulo / hardware + parser.add_argument( + "--module-profile", + default=None, + help=( + "Module Profile explícito. Se omitido, usa --active-profile. " + "Este script nunca lê module_params.json." + ), + ) + parser.add_argument( + "--active-profile", + default="calibration/active_module_profile.json", + help="Seletor criado pelo Script 0.", + ) + parser.add_argument( + "--mx-id", + default=None, + help=( + "Verificação opcional. Se informado, deve ser exatamente o MX ID " + "do Module Profile." + ), + ) parser.add_argument("--fps", type=float, default=20.0) parser.add_argument( "--anti-banding", default="OFF", choices=["OFF", "50", "60", "50HZ", "60HZ"], ) - parser.add_argument( - "--rgb-bayer", - default="auto", - choices=["auto", "RGGB", "BGGR", "GRBG", "GBRG"], - ) - parser.add_argument( - "--module-params", - default="calibration/module_params.json", - help=( - "Lê SOMENTE rgb_processing.mode e demosaic_algorithm para manter " - "o mesmo domínio de decode da produção. O JSON completo NÃO é " - "carregado no RawProcessorCore durante a calibração, evitando que " - "flat/radiometric/enhancement antigos contaminem o novo flat." - ), - ) # Captura parser.add_argument("--frames", type=int, default=60) @@ -2766,29 +2913,6 @@ def main(): parser.add_argument(f"--{role}-exp-us", type=int, default=None) parser.add_argument(f"--{role}-iso", type=int, default=None) - # Orientação SOMENTE de preview/diagnóstico. - for role in ROLES: - parser.add_argument( - f"--{role}-preview-rotate-deg", - type=int, - default=None, - choices=[0, 90, 180, 270], - help=( - "Rotação SOMENTE da UI/previews. " - "Flat científico permanece no espaço nativo." - ), - ) - parser.add_argument( - f"--{role}-preview-flip-horizontal", - action="store_true", - help="Flip horizontal SOMENTE da UI/previews.", - ) - parser.add_argument( - f"--{role}-preview-flip-vertical", - action="store_true", - help="Flip vertical SOMENTE da UI/previews.", - ) - # Processamento parser.add_argument( "--sigma-frac", @@ -2823,20 +2947,6 @@ def main(): ) parser.add_argument("--no-promote", action="store_true") - # Output - parser.add_argument( - "--candidate-root", - default="calibration/flatfield_candidates", - ) - parser.add_argument( - "--active-npz", - default="calibration/flatfield_maps_v1.npz", - ) - parser.add_argument( - "--active-json", - default="calibration/flatfield_maps_v1.json", - ) - # UI parser.add_argument("--panel-width", type=int, default=640) parser.add_argument("--panel-height", type=int, default=400) @@ -2853,8 +2963,27 @@ def main(): thresholds = dict(DEFAULT_THRESHOLDS) + profile_contract = load_module_profile_contract( + args.module_profile, + args.active_profile, + ) + profile = profile_contract["profile"] + if args.mx_id and str(args.mx_id) != str(profile["device_mx_id"]): + raise RuntimeError( + f"--mx-id={args.mx_id!r} diverge do Module Profile " + f"({profile['device_mx_id']!r})." + ) + + focus_prerequisite = load_focus_prerequisite(profile_contract) + intrinsics_provenance = load_optional_intrinsics(profile_contract) + + artifacts = profile["artifact_paths"] + candidate_root = resolve_output_path(artifacts["candidate_roots"]["flatfield"]) + active_npz = resolve_output_path(artifacts["flatfield_npz"]) + active_json = resolve_output_path(artifacts["flatfield_json"]) + sid = session_id() - candidate_dir = Path(args.candidate_root) / sid + candidate_dir = candidate_root / sid preview_dir = candidate_dir / "previews" candidate_npz = candidate_dir / "flatfield_maps.npz" candidate_report_path = candidate_dir / "report.json" @@ -2862,34 +2991,29 @@ def main(): ensure_dir(candidate_dir) ensure_dir(preview_dir) - module_params = load_json(args.module_params) - # -------------------------------------------------------- # Descoberta # -------------------------------------------------------- - dev_info = select_device_info(args.mx_id) + dev_info = select_device_info(profile["device_mx_id"]) mx_id = device_id_from_info(dev_info) features, usb_speed = discover_features(dev_info) specs = validate_and_build_specs( features=features, - rgb_bayer_arg=args.rgb_bayer, - module_params=module_params, + profile=profile, ) + validate_profile_against_device(profile, specs, mx_id) rgb_spec = specs["rgb"] - preview_orientation = resolve_preview_orientation( - specs, - args, - module_params=module_params, - ) + preview_orientation = resolve_preview_orientation(profile) print("=" * 82) print("FLAT-FIELD CALIBRATION - PRODUCTION") print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}") + print(f"Profile : {profile['profile_name']}") print(f"MX ID : {mx_id}") print(f"USB : {usb_speed}") print("-" * 82) @@ -2939,12 +3063,23 @@ def main(): "created_at": now_str(), "status": "running", "promoted": False, + "module_profile_contract": artifact_provenance(profile_contract), "depthai_version": getattr(dai, "__version__", "unknown"), "device": { "mx_id": mx_id, "usb_speed": usb_speed, }, "hardware_inventory": features, + "hardware_signature": hardware_signature(specs), + "prerequisites": { + "focus": { + "required": True, + "path": str(focus_prerequisite["path"]), + "sha256": focus_prerequisite["sha256"], + "scientific_reason": "lens_and_focus_change_flatfield_shading", + }, + "intrinsics": intrinsics_provenance, + }, "resolved_setup": { role: asdict(spec) for role, spec in specs.items() @@ -2966,8 +3101,9 @@ def main(): "camera_orientation -> homography" ), "scientific_orientation_effect": "none", + "intrinsics_scientific_dependency": False, }, - "module_params_path": args.module_params, + "rgb_decode_contract": dict(profile["rgb_decode"]), "rgb_processing": None, "capture": { "fps": args.fps, @@ -2992,10 +3128,11 @@ def main(): "candidate_dir": str(candidate_dir), "candidate_npz": str(candidate_npz), "candidate_report": str(candidate_report_path), - "active_npz": args.active_npz, - "active_json": args.active_json, + "active_npz": str(active_npz), + "active_json": str(active_json), }, - "suggested_module_params_patch": suggested_flatfield_config(args.active_npz), + "module_params_fragment": suggested_flatfield_config(str(active_npz)), + "suggested_module_params_patch": suggested_flatfield_config(str(active_npz)), } try: @@ -3013,9 +3150,11 @@ def main(): # Core de processamento da própria aplicação # ---------------------------------------------------- - core = create_processing_core(specs, args.module_params) + core = create_processing_core(specs, profile["rgb_decode"]) report["rgb_processing"] = { + "source": "module_profile.rgb_decode", + "bayer_pattern": str(core.bayer_pattern).upper(), "mode": str(core.rgb_processing_config.get("mode")), "demosaic_algorithm": str(core.rgb_processing_config.get("demosaic_algorithm")), "enhancement_forced_off": True, @@ -3292,9 +3431,13 @@ def main(): save_json(candidate_report_path, report) # Marca candidate. - marker = candidate_dir / ( - "PASS.txt" if promote_allowed else "FAIL.txt" - ) + if promote_allowed and final_status == "good": + marker_name = "PASS.txt" + elif promote_allowed: + marker_name = "PASS_WARNING.txt" + else: + marker_name = "FAIL.txt" + marker = candidate_dir / marker_name marker.write_text( ( @@ -3314,8 +3457,8 @@ def main(): atomic_promote( candidate_npz=candidate_npz, candidate_report=candidate_report_path, - active_npz=Path(args.active_npz), - active_json=Path(args.active_json), + active_npz=active_npz, + active_json=active_json, ) report["promoted"] = True @@ -3323,13 +3466,13 @@ def main(): # Atualiza report candidate e active JSON com estado promovido. save_json(candidate_report_path, report) - save_json(args.active_json, report) + save_json(active_json, report) print("") print("=" * 82) print("[PASS] FLAT-FIELD APROVADO E PROMOVIDO") - print(f"[ACTIVE] NPZ : {args.active_npz}") - print(f"[ACTIVE] JSON: {args.active_json}") + print(f"[ACTIVE] NPZ : {active_npz}") + print(f"[ACTIVE] JSON: {active_json}") print(f"[CANDIDATE] {candidate_dir}") print("=" * 82) diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py index d6c33059e..29d81bf3f 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py @@ -97,24 +97,25 @@ Se a bancada mudar, a referência radiométrica muda junto. Uso recomendado --------------- -1. Coloque um painel cinza/difuso uniforme preenchendo o FOV. -2. Use iluminação de bancada estável e reproduzível. -3. Rode: +1. Execute antes os Scripts 0, 1 e 3 para o mesmo profile. +2. Coloque um painel cinza/difuso uniforme preenchendo o FOV. +3. Use iluminação de bancada estável e reproduzível. +4. Rode: - python radiometric_calibration_production.py ^ + python -m utils._4_radiometric_calibration_tool ^ --rig-id RIG_MS_01 ^ --target-id GRAY_PANEL_01 ^ --illumination-id LED_CAL_01 -4. Espere o preflight ficar GOOD. -5. ENTER inicia a calibração automática. -6. Não mexa mais em câmera, painel ou luz. -7. O script executa o sweep, regressão e validação. -8. PASS promove o artefato. +5. Espere o preflight ficar GOOD. +6. ENTER inicia a calibração automática. +7. Não mexa mais em câmera, painel ou luz. +8. O script executa o sweep, regressão e validação. +9. PASS promove o artefato. Saídas ------ -calibration/radiometry_candidates// +calibration//radiometry_candidates// report.json sweep.csv PASS.txt / FAIL.txt / CANCELLED.txt @@ -126,13 +127,22 @@ calibration/radiometry_candidates// validation_nir.png Se PASS: - calibration/radiometry_calibration_v5.json + calibration//radiometry_calibration_v1.json O JSON ativo contém: module_params_fragment.radiometric_normalization O assembler final continua responsável por montar module_params.json. +Contrato de bootstrap +--------------------- +- Não lê module_params.json; ele ainda não existe nesta etapa. +- MX ID, hardware, resoluções, orientação visual, rgb_decode e destinos vêm + exclusivamente do Module Profile criado pelo Script 0. +- Focus QC e Flat Field ativos são pré-requisitos do mesmo profile. +- O Flat é validado como cadeia de produção, mas NÃO é aplicado ao sweep: + esta medição pertence ao RAW nativo antes do flat gain. + Política fail-closed -------------------- - BAD nunca promove. @@ -180,61 +190,12 @@ import numpy as np ROLES = ("rgb", "re", "nir") -PRODUCT_TOPOLOGY = { - "rgb": { - "socket": "CAM_A", - "allowed_sensors": ("OV9782", "AR0234"), - }, - "re": { - "socket": "CAM_B", - "allowed_sensors": ("OV9282",), - }, - "nir": { - "socket": "CAM_C", - "allowed_sensors": ("OV9282",), - }, -} - -RGB_SENSOR_MODES = { - "OV9782": { - "resolution_enum": "THE_800_P", - "width": 1280, - "height": 800, - }, - "AR0234": { - "resolution_enum": "THE_1200_P", - "width": 1920, - "height": 1200, - }, -} - -MONO_SENSOR_MODES = { - "OV9282": { - "resolution_enum": "THE_800_P", - "width": 1280, - "height": 800, - }, -} - -# Orientação EXCLUSIVAMENTE de preview/evidência. -# O domínio científico permanece native_sensor_raw_linear. -PREVIEW_ORIENTATION_BY_SENSOR = { - "OV9782": { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, - "AR0234": { - "rotate_deg": 180, - "flip_horizontal": False, - "flip_vertical": False, - }, - "OV9282": { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, -} +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +FOCUS_SCHEMA = "multispec_focus_qc_v3" +FLATFIELD_SCHEMA = "multispec_flatfield_production_v2" +INTRINSICS_SCHEMA = "multispec_intrinsics_calibration_v1" +BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") SCHEMA = "multispec_radiometric_calibration_v6" CALIBRATION_DOMAIN = "native_sensor_raw_linear" @@ -344,14 +305,18 @@ def save_json_atomic(path: str | Path, data: dict): os.replace(tmp, p) -def load_json(path: str | Path) -> dict: +def load_json_file(path: str | Path) -> dict: p = Path(path) - if not p.is_file(): - return {} - - with p.open("r", encoding="utf-8") as f: - return json.load(f) + raise RuntimeError(f"JSON obrigatório não encontrado: {p}") + try: + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + raise RuntimeError(f"Falha ao ler JSON {p}: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"JSON deve conter um objeto na raiz: {p}") + return data def sha256_file(path: str | Path) -> str: @@ -367,6 +332,26 @@ def sha256_file(path: str | Path) -> str: return h.hexdigest() +def resolve_existing_path( + value: str | Path, + anchor: Optional[Path] = None, +) -> Path: + raw = Path(value).expanduser() + candidates = [raw] + if not raw.is_absolute() and anchor is not None: + candidates.append(Path(anchor).parent / raw) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + tried = ", ".join(str(x) for x in candidates) + raise RuntimeError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def resolve_output_path(value: str | Path) -> Path: + """artifact_paths do Script 0 são relativos à raiz do projeto.""" + return Path(value).expanduser() + + def safe_int(v, default=None): try: if v is None: @@ -465,20 +450,13 @@ def socket_name(socket) -> str: def get_socket(name: str): - mapping = { - "CAM_A": dai.CameraBoardSocket.CAM_A, - "CAM_B": dai.CameraBoardSocket.CAM_B, - "CAM_C": dai.CameraBoardSocket.CAM_C, - } - key = str(name).upper() - - if key not in mapping: + value = getattr(dai.CameraBoardSocket, key, None) + if value is None: raise ValueError( f"Socket inválido: {name}" ) - - return mapping[key] + return value def enum_if_exists(enum_cls, name: str): @@ -499,12 +477,8 @@ def supported_type_strings(feature): ] -def feature_is_color(feature) -> bool: +def feature_is_color(feature) -> Optional[bool]: types = supported_type_strings(feature) - sensor = str( - getattr(feature, "sensorName", "") - or "" - ).upper() if any("COLOR" in x for x in types): return True @@ -512,13 +486,10 @@ def feature_is_color(feature) -> bool: if any("MONO" in x for x in types): return False - return sensor in { - "OV9782", - "AR0234", - } + return None -def feature_is_mono(feature) -> bool: +def feature_is_mono(feature) -> Optional[bool]: types = supported_type_strings(feature) if any("MONO" in x for x in types): @@ -527,7 +498,7 @@ def feature_is_mono(feature) -> bool: if any("COLOR" in x for x in types): return False - return not feature_is_color(feature) + return None def resize_panel( @@ -624,122 +595,302 @@ def apply_preview_orientation( return np.ascontiguousarray(img) -def load_optional_json(path: Optional[str]) -> dict: - if not path: - return {} - - p = Path(path) - if not p.is_file(): - return {} - - with p.open("r", encoding="utf-8") as f: - data = json.load(f) - - return data if isinstance(data, dict) else {} - - -def resolve_preview_orientation( - specs, - args, - module_params: Optional[dict] = None, -): - """ - Precedência: - 1. default por sensor; - 2. camera_orientation.by_role do module_params, se fornecido; - 3. override explícito por CLI. - - Tudo continua sendo SOMENTE preview. - """ - module_params = module_params or {} - - mp_orientation = module_params.get( - "camera_orientation", - {}, - ) - if not isinstance(mp_orientation, dict): - mp_orientation = {} - - mp_by_role = mp_orientation.get( - "by_role", - {}, - ) - if not isinstance(mp_by_role, dict): - mp_by_role = {} +def resolve_preview_orientation(profile: dict) -> dict: + """Carrega a orientação visual diretamente do Module Profile.""" + declared = profile.get("preview_orientation_defaults") + if not isinstance(declared, dict): + raise RuntimeError("Module Profile sem preview_orientation_defaults.") result = {} - for role in ROLES: - sensor = str( - specs[role].sensor_name - or "" - ).upper() - - cfg = dict( - PREVIEW_ORIENTATION_BY_SENSOR.get( - sensor, - { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, + item = declared.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"Module Profile sem orientação para {role}.") + missing = [ + key + for key in ("rotate_deg", "flip_horizontal", "flip_vertical") + if key not in item + ] + if missing: + raise RuntimeError( + f"Orientação de preview incompleta em {role}: faltam={missing}" ) - ) - - source = "sensor_default" - - mp_role = mp_by_role.get(role) - if isinstance(mp_role, dict): - for key in ( - "rotate_deg", - "flip_horizontal", - "flip_vertical", - ): - if key in mp_role: - cfg[key] = mp_role[key] - source = "module_params" - - rotate_override = getattr( - args, - f"{role}_preview_rotate_deg", - None, - ) - if rotate_override is not None: - cfg["rotate_deg"] = int( - rotate_override - ) - source = "cli_override" - - if bool( - getattr( - args, - f"{role}_preview_flip_horizontal", - False, - ) - ): - cfg["flip_horizontal"] = True - source = "cli_override" - - if bool( - getattr( - args, - f"{role}_preview_flip_vertical", - False, - ) - ): - cfg["flip_vertical"] = True - source = "cli_override" - - cfg = normalize_preview_orientation( - cfg - ) - cfg["source"] = source - + cfg = normalize_preview_orientation(dict(item)) + cfg["source"] = "module_profile" result[role] = cfg - return result +def validate_module_profile(profile: dict, profile_path: Path): + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema de Module Profile inesperado em {profile_path}: " + f"{profile.get('schema')!r}" + ) + if str(profile.get("status", "")).lower() != "active": + raise RuntimeError( + f"Module Profile não está ativo: {profile.get('status')!r}" + ) + for key in ("profile_name", "profile_dir", "device_mx_id"): + if not str(profile.get(key) or "").strip(): + raise RuntimeError(f"Module Profile sem {key}.") + + signature = profile.get("hardware_signature") + resolved = profile.get("resolved_setup") + if not isinstance(signature, dict) or not isinstance(resolved, dict): + raise RuntimeError("Module Profile sem hardware_signature/resolved_setup.") + for role in ROLES: + hw = signature.get(role) + setup = resolved.get(role) + if not isinstance(hw, dict) or not isinstance(setup, dict): + raise RuntimeError(f"Contrato de hardware sem role {role}.") + size = hw.get("size") + if ( + not hw.get("socket") + or not hw.get("sensor") + or not isinstance(size, (list, tuple)) + or len(size) != 2 + ): + raise RuntimeError(f"hardware_signature/{role} inválido.") + if not str(setup.get("resolution_name") or "").strip(): + raise RuntimeError(f"resolved_setup/{role}/resolution_name ausente.") + if ( + setup.get("socket") != hw.get("socket") + or setup.get("sensor") != hw.get("sensor") + or [setup.get("width"), setup.get("height")] != list(size) + ): + raise RuntimeError( + f"resolved_setup/{role} diverge de hardware_signature." + ) + + rgb_decode = profile.get("rgb_decode") + if not isinstance(rgb_decode, dict): + raise RuntimeError("Module Profile sem rgb_decode.") + bayer = str(rgb_decode.get("bayer_pattern") or "").upper() + mode = str(rgb_decode.get("mode") or "").lower() + algorithm = str(rgb_decode.get("demosaic_algorithm") or "").lower() + if bayer not in BAYER_PATTERNS: + raise RuntimeError(f"rgb_decode.bayer_pattern inválido: {bayer!r}") + if mode not in {"linear_demosaic", "linear_demosaic_half", "bayer_planes"}: + raise RuntimeError(f"rgb_decode.mode inválido: {mode!r}") + if algorithm not in {"ea", "bilinear"}: + raise RuntimeError( + f"rgb_decode.demosaic_algorithm inválido: {algorithm!r}" + ) + + resolve_preview_orientation(profile) + artifacts = profile.get("artifact_paths") + roots = artifacts.get("candidate_roots") if isinstance(artifacts, dict) else None + if not isinstance(roots, dict) or not roots.get("radiometry"): + raise RuntimeError( + "Module Profile sem artifact_paths.candidate_roots.radiometry." + ) + for key in ( + "focus_qc_json", + "intrinsics_json", + "flatfield_npz", + "flatfield_json", + "radiometry_json", + ): + if not artifacts.get(key): + raise RuntimeError(f"Module Profile sem artifact_paths.{key}.") + + +def load_module_profile_contract( + module_profile_arg: Optional[str], + active_selector_arg: str, +) -> dict: + selector = None + selector_path = None + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + try: + selector_path = resolve_existing_path(active_selector_arg) + except RuntimeError as exc: + raise RuntimeError( + "Active Module Profile não encontrado. Execute primeiro o " + "Script 0 ou informe --module-profile." + ) from exc + selector = load_json_file(selector_path) + if selector.get("schema") != ACTIVE_SELECTOR_SCHEMA: + raise RuntimeError( + f"Schema do seletor ativo inesperado: {selector.get('schema')!r}" + ) + declared = selector.get("profile_path") + if not declared: + raise RuntimeError("Seletor ativo sem profile_path.") + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json_file(profile_path) + validate_module_profile(profile, profile_path) + profile_sha256 = sha256_file(profile_path) + if selector is not None: + declared_sha = str(selector.get("profile_sha256") or "").lower() + if declared_sha and declared_sha != profile_sha256.lower(): + raise RuntimeError( + "Hash do Module Profile diverge do active selector. " + "Execute novamente o Script 0." + ) + if str(selector.get("device_mx_id") or "") != str( + profile.get("device_mx_id") or "" + ): + raise RuntimeError("MX ID do selector diverge do Module Profile.") + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha256, + "selector": selector, + "selector_path": selector_path, + } + + +def module_profile_provenance(profile_contract: dict) -> dict: + profile = profile_contract["profile"] + return { + "profile_name": profile["profile_name"], + "profile_path": str(profile_contract["profile_path"]), + "profile_sha256": profile_contract["profile_sha256"], + "device_mx_id": profile["device_mx_id"], + "selector_path": ( + None + if profile_contract["selector_path"] is None + else str(profile_contract["selector_path"]) + ), + } + + +def canonical_hardware_signature(value: dict) -> dict: + if not isinstance(value, dict): + return {} + result = {} + for role in ROLES: + item = value.get(role) + if not isinstance(item, dict): + return {} + size = item.get("size") + result[role] = { + "socket": item.get("socket"), + "sensor": item.get("sensor"), + "size": list(size) if isinstance(size, (list, tuple)) else size, + } + return result + + +def validate_artifact_profile( + report: dict, + profile_contract: dict, + label: str, +): + profile = profile_contract["profile"] + provenance = report.get("module_profile_contract") + if not isinstance(provenance, dict): + raise RuntimeError( + f"{label} não possui module_profile_contract. Rode novamente o " + "calibrador revisado correspondente." + ) + if provenance.get("profile_sha256") != profile_contract["profile_sha256"]: + raise RuntimeError(f"{label} foi gerado com outra versão do Module Profile.") + if provenance.get("profile_name") != profile.get("profile_name"): + raise RuntimeError(f"{label} pertence a outro profile.") + if canonical_hardware_signature(report.get("hardware_signature")) != ( + canonical_hardware_signature(profile.get("hardware_signature")) + ): + raise RuntimeError(f"Hardware de {label} diverge do Module Profile.") + + +def load_focus_prerequisite(profile_contract: dict) -> dict: + profile = profile_contract["profile"] + try: + path = resolve_existing_path( + profile["artifact_paths"]["focus_qc_json"], + anchor=profile_contract["profile_path"], + ) + except RuntimeError as exc: + raise RuntimeError( + "Focus QC ativo não encontrado. Execute e aprove o Script 1." + ) from exc + report = load_json_file(path) + if report.get("schema") != FOCUS_SCHEMA: + raise RuntimeError(f"Schema do Focus QC inesperado: {report.get('schema')!r}") + if str(report.get("status", "")).lower() != "pass": + raise RuntimeError(f"Focus QC não está PASS: {report.get('status')!r}") + if report.get("promoted") is not True: + raise RuntimeError("Focus QC não foi promovido.") + if report.get("normal_production_homologation") is not True: + raise RuntimeError("Focus QC não é homologação normal de produção.") + validate_artifact_profile(report, profile_contract, "Focus QC") + return {"path": str(path), "sha256": sha256_file(path), "status": "pass"} + + +def load_flatfield_prerequisite(profile_contract: dict) -> dict: + profile = profile_contract["profile"] + artifacts = profile["artifact_paths"] + try: + json_path = resolve_existing_path( + artifacts["flatfield_json"], anchor=profile_contract["profile_path"] + ) + npz_path = resolve_existing_path( + artifacts["flatfield_npz"], anchor=profile_contract["profile_path"] + ) + except RuntimeError as exc: + raise RuntimeError( + "Flat Field ativo incompleto. Execute e promova o Script 3 antes " + "da Radiometria." + ) from exc + report = load_json_file(json_path) + if report.get("schema") != FLATFIELD_SCHEMA: + raise RuntimeError( + f"Schema do Flat Field inesperado: {report.get('schema')!r}" + ) + if str(report.get("status", "")).lower() not in {"good", "warning"}: + raise RuntimeError(f"Flat Field ativo inválido: {report.get('status')!r}") + if report.get("promoted") is not True: + raise RuntimeError("Flat Field não foi promovido.") + validate_artifact_profile(report, profile_contract, "Flat Field") + npz_sha = sha256_file(npz_path) + declared_sha = str(report.get("candidate_sha256") or "").lower() + if declared_sha and declared_sha != npz_sha.lower(): + raise RuntimeError("Hash do NPZ ativo diverge do report do Flat Field.") + return { + "json_path": str(json_path), + "json_sha256": sha256_file(json_path), + "npz_path": str(npz_path), + "npz_sha256": npz_sha, + "status": report.get("status"), + "scientific_dependency": False, + "reason": "workflow_provenance_only_raw_sweep_precedes_flat_gain", + } + + +def load_optional_intrinsics(profile_contract: dict) -> dict: + profile = profile_contract["profile"] + try: + path = resolve_existing_path( + profile["artifact_paths"]["intrinsics_json"], + anchor=profile_contract["profile_path"], + ) + except RuntimeError: + return {"present": False, "scientific_dependency": False} + report = load_json_file(path) + if report.get("schema") != INTRINSICS_SCHEMA: + raise RuntimeError( + f"Schema de Intrinsics inesperado: {report.get('schema')!r}" + ) + if str(report.get("status", "")).lower() not in {"pass", "warning_promoted"}: + raise RuntimeError(f"Intrinsics existente não foi promovido.") + if report.get("promoted") is not True: + raise RuntimeError("Intrinsics existente não foi promovido.") + validate_artifact_profile(report, profile_contract, "Intrinsics") + return { + "present": True, + "path": str(path), + "sha256": sha256_file(path), + "status": report.get("status"), + "scientific_dependency": False, + } + + def robust_median(arr: np.ndarray) -> float: f = np.asarray( arr, @@ -1277,6 +1428,7 @@ def discover_cameras( def validate_product_topology( camera_rows, + profile, ): by_socket = { row["socket_name"]: row @@ -1284,15 +1436,11 @@ def validate_product_topology( } errors = [] + signature = profile["hardware_signature"] for role in ROLES: - contract = ( - PRODUCT_TOPOLOGY[role] - ) - - socket = contract[ - "socket" - ] + expected = signature[role] + socket = str(expected["socket"]) row = by_socket.get( socket @@ -1304,23 +1452,28 @@ def validate_product_topology( ) continue - sensor = row[ - "sensor_name" - ] + sensor = str(row["sensor_name"]).upper() + expected_sensor = str(expected["sensor"]).upper() + expected_size = tuple(int(x) for x in expected["size"]) - if sensor not in contract[ - "allowed_sensors" - ]: + if sensor != expected_sensor: errors.append( f"{role}: {socket} " - f"sensor={sensor}, " - f"esperado=" - f"{contract['allowed_sensors']}" + f"sensor={sensor}, esperado={expected_sensor}" + ) + + if row["width"] and row["height"] and ( + int(row["width"]), int(row["height"]) + ) != expected_size: + errors.append( + f"{role}: {socket}/{sensor} size=" + f"{row['width']}x{row['height']}, " + f"esperado={expected_size[0]}x{expected_size[1]}" ) if ( role == "rgb" - and not row["is_color"] + and row["is_color"] is False ): errors.append( f"RGB {socket}/{sensor} " @@ -1329,7 +1482,7 @@ def validate_product_topology( if ( role in ("re", "nir") - and not row["is_mono"] + and row["is_mono"] is False ): errors.append( f"{role.upper()} " @@ -1351,6 +1504,7 @@ def validate_product_topology( def build_specs( camera_rows, + profile, ): by_socket = { row["socket_name"]: row @@ -1358,60 +1512,27 @@ def build_specs( } specs = {} + signature = profile["hardware_signature"] + resolved = profile["resolved_setup"] for role in ROLES: - socket = ( - PRODUCT_TOPOLOGY[role] - ["socket"] - ) + expected = signature[role] + socket = str(expected["socket"]) row = by_socket[ socket ] - sensor = row[ - "sensor_name" - ] - - cfg = ( - RGB_SENSOR_MODES[sensor] - if role == "rgb" - else MONO_SENSOR_MODES[sensor] - ) - - if ( - row["width"] - and row["height"] - and ( - row["width"], - row["height"], - ) - != ( - cfg["width"], - cfg["height"], - ) - ): - raise RuntimeError( - f"{socket}/{sensor}: " - f"anunciado " - f"{row['width']}x{row['height']}, " - f"esperado " - f"{cfg['width']}x{cfg['height']}" - ) + sensor = str(expected["sensor"]).upper() + width, height = (int(x) for x in expected["size"]) specs[role] = CameraSpec( role=role, socket_name=socket, sensor_name=sensor, - width=int( - cfg["width"] - ), - height=int( - cfg["height"] - ), - resolution_name=( - cfg["resolution_enum"] - ), + width=width, + height=height, + resolution_name=str(resolved[role]["resolution_name"]), stream_name=( f"rad_raw_{role}" ), @@ -1446,6 +1567,22 @@ def hardware_signature( } +def validate_profile_against_device( + profile, + specs, + actual_mx, +): + if str(actual_mx or "") != str(profile.get("device_mx_id") or ""): + raise RuntimeError( + "Câmera conectada não pertence ao Module Profile: " + f"esperado={profile.get('device_mx_id')!r}, detectado={actual_mx!r}." + ) + if canonical_hardware_signature(hardware_signature(specs)) != ( + canonical_hardware_signature(profile.get("hardware_signature")) + ): + raise RuntimeError("Assinatura do hardware conectado diverge do profile.") + + # ============================================================ # Pipeline # ============================================================ @@ -4168,7 +4305,7 @@ def promote_active( ) if active_path.exists(): - old = load_json( + old = load_json_file( active_path ) @@ -4213,10 +4350,27 @@ def main(): formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - # Hardware + # Contrato do módulo / hardware + parser.add_argument( + "--module-profile", + default=None, + help=( + "Module Profile explícito. Se omitido, usa --active-profile. " + "Este script nunca lê module_params.json." + ), + ) + parser.add_argument( + "--active-profile", + default="calibration/active_module_profile.json", + help="Seletor criado pelo Script 0.", + ) parser.add_argument( "--mx-id", default=None, + help=( + "Verificação opcional; deve coincidir exatamente com o MX ID " + "do Module Profile." + ), ) parser.add_argument( @@ -4225,38 +4379,6 @@ def main(): default=20.0, ) - parser.add_argument( - "--module-params", - default="calibration/module_params.json", - help=( - "Opcional. Lê SOMENTE camera_orientation para alinhar a UI/evidências " - "com o módulo. Não altera a medição radiométrica." - ), - ) - - # Orientação SOMENTE de preview/evidência. - for role in ROLES: - parser.add_argument( - f"--{role}-preview-rotate-deg", - type=int, - default=None, - choices=[0, 90, 180, 270], - help=( - "Rotação SOMENTE da UI/evidências. " - "Sweep/regressão permanecem no RAW nativo." - ), - ) - parser.add_argument( - f"--{role}-preview-flip-horizontal", - action="store_true", - help="Flip horizontal SOMENTE da UI/evidências.", - ) - parser.add_argument( - f"--{role}-preview-flip-vertical", - action="store_true", - help="Flip vertical SOMENTE da UI/evidências.", - ) - # Bancada parser.add_argument( "--rig-id", @@ -4405,34 +4527,6 @@ def main(): default=400, ) - # Output - parser.add_argument( - "--candidate-root", - default=( - "calibration/" - "radiometry_candidates" - ), - ) - - parser.add_argument( - "--active-json", - default=( - "calibration/" - "radiometry_calibration_v5.json" - ), - ) - - # Trace - parser.add_argument( - "--module-id", - default="", - ) - - parser.add_argument( - "--operator", - default="", - ) - parser.add_argument( "--notes", default="", @@ -4509,12 +4603,31 @@ def main(): "Sweep com poucos fatores" ) + profile_contract = load_module_profile_contract( + args.module_profile, + args.active_profile, + ) + profile = profile_contract["profile"] + if args.mx_id and str(args.mx_id) != str(profile["device_mx_id"]): + raise RuntimeError( + f"--mx-id={args.mx_id!r} diverge do Module Profile " + f"({profile['device_mx_id']!r})." + ) + + focus_prerequisite = load_focus_prerequisite(profile_contract) + flatfield_prerequisite = load_flatfield_prerequisite(profile_contract) + intrinsics_provenance = load_optional_intrinsics(profile_contract) + + artifacts = profile["artifact_paths"] + candidate_root = resolve_output_path( + artifacts["candidate_roots"]["radiometry"] + ) + active_json = resolve_output_path(artifacts["radiometry_json"]) + sid = session_stamp() candidate_dir = ( - Path( - args.candidate_root - ) + candidate_root / sid ) @@ -4554,33 +4667,31 @@ def main(): actual_mx, usb_speed, ) = discover_cameras( - args.mx_id + profile["device_mx_id"] ) topology = ( validate_product_topology( - camera_rows + camera_rows, + profile, ) ) specs = build_specs( - camera_rows + camera_rows, + profile, ) + validate_profile_against_device(profile, specs, actual_mx) - module_params = load_optional_json( - args.module_params - ) - - preview_orientation = resolve_preview_orientation( - specs, - args, - module_params=module_params, - ) + preview_orientation = resolve_preview_orientation(profile) print("=" * 88) print( "RADIOMETRIC CALIBRATION - PRODUCTION" ) + print( + f"Profile: {profile['profile_name']}" + ) print( f"MX ID : {actual_mx}" ) @@ -4650,6 +4761,9 @@ def main(): "created_at": now_str(), "status": "running", "promoted": False, + "module_profile_contract": module_profile_provenance( + profile_contract + ), "depthai_version": getattr( dai, "__version__", @@ -4671,6 +4785,21 @@ def main(): specs ) ), + "rgb_decode_contract": dict(profile["rgb_decode"]), + "prerequisites": { + "focus": { + **focus_prerequisite, + "required": True, + "scientific_reason": ( + "lens_and_focus_change_optical_response" + ), + }, + "flatfield": { + **flatfield_prerequisite, + "required": True, + }, + "intrinsics": intrinsics_provenance, + }, "calibration_domain": ( CALIBRATION_DOMAIN ), @@ -4685,9 +4814,9 @@ def main(): } for role in ROLES }, - "module_params_path": args.module_params, - "module_params_orientation_used_for_preview_only": True, + "preview_orientation_source": "module_profile", "scientific_orientation_effect": "none", + "flatfield_scientific_dependency": False, "runtime_order": ( "decode -> radiometric_normalization -> flat_native -> " "camera_orientation -> homography" @@ -4761,11 +4890,11 @@ def main(): "qa_thresholds": qa, "traceability": { "module_id": ( - args.module_id + profile.get("module_id") or None ), "operator": ( - args.operator + profile.get("operator") or None ), }, @@ -4801,7 +4930,7 @@ def main(): candidate_dir ), "active_json": ( - args.active_json + str(active_json) ), } @@ -5507,11 +5636,12 @@ def main(): report, ) - marker_name = ( - "PASS.txt" - if promote - else "FAIL.txt" - ) + if promote and overall_status == "good": + marker_name = "PASS.txt" + elif promote: + marker_name = "PASS_WARNING.txt" + else: + marker_name = "FAIL.txt" ( candidate_dir @@ -5531,16 +5661,14 @@ def main(): ] = now_str() promote_active( - Path( - args.active_json - ), + active_json, report, ) report[ "active_json_sha256" ] = sha256_file( - args.active_json + active_json ) save_json_atomic( @@ -5604,7 +5732,7 @@ def main(): "", f"Candidate: {candidate_dir}", ( - f"Active: {args.active_json}" + f"Active: {active_json}" if promote else "Active calibration preserved" ), @@ -5636,7 +5764,7 @@ def main(): "CALIBRATION PROMOTED" ) print( - f"[ACTIVE] {args.active_json}" + f"[ACTIVE] {active_json}" ) print( f"[CANDIDATE] {candidate_dir}" diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py b/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py index 5e591f63a..8a239c37b 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py @@ -5,163 +5,80 @@ camera_startup_profile_production.py ==================================== -Gerador/auditor FINAL de produção para o bloco `camera_settings` -do módulo multiespectral. +Gerador de produção dos blocos `camera_settings` e `rgb_calibration`. -IMPORTANTE ----------- -Isto NÃO é uma calibração óptica/radiométrica nova. +Responsabilidade +---------------- +Este script NÃO calibra nem transforma pixels. Ele converte os +`radiometric_normalization.reference_controls` homologados no Script 4 em +fallbacks coerentes para a inicialização das câmeras: -O script antigo "Sensor Calibration Tool" misturava: - - camera_settings; - - ajustes manuais AE/AWB/exposure/gain; - - ROIs de cana/erva/solo; - - guidance heurístico; - - rgb_calibration manual; - - snapshots/offline samples. + RGB: AE=ON, AWB=ON + RE : AE=ON, AWB=OFF + NIR: AE=ON, AWB=OFF -Na arquitetura final de produto essas responsabilidades já foram separadas. +Enquanto AE está ativo, exposure/gain ficam armazenados como fallback; os +controles reais de cada frame continuam alimentando a normalização +radiométrica. -Esta ferramenta preserva SOMENTE o que ainda é necessário ao runtime: - camera_settings +Contrato de bootstrap +--------------------- +- Não lê module_params.json; ele só será montado no final. +- Module Profile é a única autoridade para MX ID, hardware, orientação, + rgb_decode e caminhos dos artefatos. +- A Radiometria promovida do mesmo profile é a dependência científica direta. +- Focus, Intrinsics e Flat Field são herdados pela cadeia auditável registrada + na Radiometria; não são recalculados nem aplicados aqui. +- `camera_orientation` não pertence a `camera_settings` e continua sob + responsabilidade do RawProcessorCore/Homography. -Ela NÃO captura frames e NÃO transforma pixels. +Saídas +------ + calibration//camera_startup_candidates// + report.json + PASS.txt -Portanto `camera_orientation` NÃO pertence ao `camera_settings`. -Nesta revisão a ferramenta apenas: - - reconhece/audita `module_params.camera_orientation`, quando um MP existe; - - registra o contrato geométrico no artefato; - - garante que orientação nunca seja misturada em camera_settings/rgb_calibration. + calibration//camera_startup_profile_v1.json -A aplicação real de rotate/flip continua pertencendo ao RawProcessorCore, -na etapa: - radiometric_normalization -> flat_native -> camera_orientation -> homography - -e força explicitamente: - rgb_calibration.enabled = false - gains = 1,1,1 - -para impedir que ganhos RGB históricos/experimentais vazem para o tensor. - -Dependência ------------ -A ferramenta lê o artefato homologado da etapa radiométrica: - - calibration/radiometry_calibration_v5.json - -e reaproveita: - radiometric_normalization.reference_controls - -como valores de fallback do startup. - -Política normal de produto --------------------------- - RGB: - AE = ON - AWB = ON - exposure_time_us = referência radiométrica (fallback) - analogue_gain = ISO_ref / 100 - colour_gains = [1.0, 1.0] - - RE: - AE = ON - AWB = OFF - exposure_time_us = referência radiométrica (fallback) - analogue_gain = ISO_ref / 100 - - NIR: - AE = ON - AWB = OFF - exposure_time_us = referência radiométrica (fallback) - analogue_gain = ISO_ref / 100 - -Por que manter os valores manuais se AE está ON? -------------------------------------------------- -No manager atual, com AE ligado, exposure/gain do JSON não são forçados -fisicamente. Eles ficam armazenados como estado/fallback para uma eventual -transição futura para manual. - -Assim: - - o campo continua adaptando exposição automaticamente; - - a normalização radiométrica usa os controles reais de cada frame; - - se AE for desabilitado em algum fluxo futuro, o fallback já é coerente - com a calibração radiométrica do módulo. - -Saída ------ - calibration/camera_startup_profile_v3.json - -Contém: +O artefato contém: module_params_fragment.camera_settings module_params_fragment.rgb_calibration -O assembler final do module_params continua sendo a autoridade de merge. - -Fail-closed ------------ -- exige CAM_A = OV9782 ou AR0234; -- exige CAM_B/C = OV9282; -- exige artefato radiométrico promovido; -- exige hardware_signature compatível; -- exige reference_controls válidos para rgb/re/nir; -- não aceita ISO inválido; -- não ativa rgb_calibration; -- preserva backup do ativo anterior. - Uso --- - python camera_startup_profile_production.py + python -m utils._5_camera_startup_profile -Com rastreabilidade: - python camera_startup_profile_production.py ^ - --module-id MOD_MS_001 ^ - --operator Diego + python -m utils._5_camera_startup_profile ^ + --module-profile calibration/mp_ar0234/module_profile_v1.json -Somente auditoria, sem salvar: - python camera_startup_profile_production.py --check-only + python -m utils._5_camera_startup_profile --check-only """ from __future__ import annotations import argparse +import hashlib import json import os import shutil -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path -from typing import Dict, Optional, List +from typing import Dict, List, Optional import depthai as dai SCHEMA = "multispec_camera_startup_profile_v3" -TOOL_VERSION = "production_orientation_aware_v1_2026_08_31" -CAMERA_ORIENTATION_SCHEMA = "multispec_camera_orientation_v1" +TOOL_VERSION = "module_profile_contract_v1_2026_09_09" +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +RADIOMETRY_SCHEMA = "multispec_radiometric_calibration_v6" +NORMALIZATION_METHOD = "oak_ae_frame_controls_affine_v2" +FACTOR_MODEL = "exposure_time_us_x_iso" ISO_BASE = 100.0 - ROLES = ("rgb", "re", "nir") - -PRODUCT_TOPOLOGY = { - "rgb": { - "socket": "CAM_A", - "allowed_sensors": ("OV9782", "AR0234"), - }, - "re": { - "socket": "CAM_B", - "allowed_sensors": ("OV9282",), - }, - "nir": { - "socket": "CAM_C", - "allowed_sensors": ("OV9282",), - }, -} - -SENSOR_NATIVE_SIZE = { - "OV9782": (1280, 800), - "AR0234": (1920, 1200), - "OV9282": (1280, 800), -} +BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") def now_str() -> str: @@ -176,70 +93,272 @@ def ensure_dir(path: str | Path): Path(path).mkdir(parents=True, exist_ok=True) -def load_json(path: str | Path) -> dict: - p = Path(path) - if not p.is_file(): - raise FileNotFoundError(f"Arquivo não encontrado: {p}") - - with p.open("r", encoding="utf-8") as f: - return json.load(f) - - def save_json_atomic(path: str | Path, data: dict): p = Path(path) ensure_dir(p.parent) - tmp = p.with_suffix(p.suffix + ".tmp") - with tmp.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") f.flush() os.fsync(f.fileno()) - os.replace(tmp, p) +def load_json_file(path: str | Path) -> dict: + p = Path(path) + if not p.is_file(): + raise RuntimeError(f"JSON obrigatório não encontrado: {p}") + try: + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + raise RuntimeError(f"Falha ao ler JSON {p}: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"JSON deve conter um objeto na raiz: {p}") + return data + + +def sha256_file(path: str | Path) -> str: + h = hashlib.sha256() + with Path(path).open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def resolve_existing_path( + value: str | Path, + anchor: Optional[Path] = None, +) -> Path: + raw = Path(value).expanduser() + candidates = [raw] + if not raw.is_absolute() and anchor is not None: + candidates.append(Path(anchor).parent / raw) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + tried = ", ".join(str(x) for x in candidates) + raise RuntimeError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def resolve_output_path(value: str | Path) -> Path: + """artifact_paths do Script 0 são relativos à raiz do projeto.""" + return Path(value).expanduser() + + +def normalize_orientation(value: dict, role: str) -> dict: + if not isinstance(value, dict): + raise RuntimeError(f"preview_orientation_defaults/{role} inválido.") + missing = [ + key + for key in ("rotate_deg", "flip_horizontal", "flip_vertical") + if key not in value + ] + if missing: + raise RuntimeError( + f"preview_orientation_defaults/{role} incompleto: faltam={missing}" + ) + try: + rotate = int(value["rotate_deg"]) % 360 + except Exception as exc: + raise RuntimeError(f"rotate_deg inválido em {role}.") from exc + if rotate not in (0, 90, 180, 270): + raise RuntimeError(f"rotate_deg inválido em {role}: {rotate}") + flip_h = value["flip_horizontal"] + flip_v = value["flip_vertical"] + if not isinstance(flip_h, bool) or not isinstance(flip_v, bool): + raise RuntimeError(f"Flips de orientação devem ser bool em {role}.") + return { + "rotate_deg": rotate, + "flip_horizontal": flip_h, + "flip_vertical": flip_v, + "source": "module_profile", + } + + +def canonical_signature(value: dict) -> dict: + if not isinstance(value, dict): + raise RuntimeError("hardware_signature inválida.") + result = {} + for role in ROLES: + item = value.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"hardware_signature sem role {role}.") + socket = item.get("socket") or item.get("socket_name") + sensor = item.get("sensor") or item.get("sensor_name") + size = item.get("size") + if size is None and item.get("width") is not None: + size = [item.get("width"), item.get("height")] + if ( + not socket + or not sensor + or not isinstance(size, (list, tuple)) + or len(size) != 2 + ): + raise RuntimeError(f"hardware_signature/{role} incompleta: {item}") + result[role] = { + "socket": str(socket), + "sensor": str(sensor).upper(), + "size": [int(size[0]), int(size[1])], + } + return result + + +def validate_module_profile(profile: dict, path: Path): + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema de Module Profile inesperado em {path}: " + f"{profile.get('schema')!r}" + ) + if str(profile.get("status", "")).lower() != "active": + raise RuntimeError(f"Module Profile não está ativo: {profile.get('status')!r}") + for key in ("profile_name", "profile_dir", "device_mx_id"): + if not str(profile.get(key) or "").strip(): + raise RuntimeError(f"Module Profile sem {key}.") + + signature = canonical_signature(profile.get("hardware_signature")) + resolved = profile.get("resolved_setup") + if not isinstance(resolved, dict): + raise RuntimeError("Module Profile sem resolved_setup.") + for role in ROLES: + item = resolved.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"resolved_setup sem role {role}.") + if ( + item.get("socket") != signature[role]["socket"] + or str(item.get("sensor") or "").upper() != signature[role]["sensor"] + or [item.get("width"), item.get("height")] != signature[role]["size"] + ): + raise RuntimeError(f"resolved_setup/{role} diverge do hardware_signature.") + + decode = profile.get("rgb_decode") + if not isinstance(decode, dict): + raise RuntimeError("Module Profile sem rgb_decode.") + if str(decode.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS: + raise RuntimeError("rgb_decode.bayer_pattern inválido.") + if str(decode.get("mode") or "").lower() not in { + "linear_demosaic", + "linear_demosaic_half", + "bayer_planes", + }: + raise RuntimeError("rgb_decode.mode inválido.") + if str(decode.get("demosaic_algorithm") or "").lower() not in { + "ea", + "bilinear", + }: + raise RuntimeError("rgb_decode.demosaic_algorithm inválido.") + + orientations = profile.get("preview_orientation_defaults") + if not isinstance(orientations, dict): + raise RuntimeError("Module Profile sem preview_orientation_defaults.") + for role in ROLES: + normalize_orientation(orientations.get(role), role) + + artifacts = profile.get("artifact_paths") + roots = artifacts.get("candidate_roots") if isinstance(artifacts, dict) else None + if not isinstance(roots, dict) or not roots.get("camera_startup"): + raise RuntimeError( + "Module Profile sem artifact_paths.candidate_roots.camera_startup." + ) + for key in ("radiometry_json", "camera_startup_json"): + if not artifacts.get(key): + raise RuntimeError(f"Module Profile sem artifact_paths.{key}.") + + +def load_module_profile_contract( + module_profile_arg: Optional[str], + active_selector_arg: str, +) -> dict: + selector = None + selector_path = None + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + try: + selector_path = resolve_existing_path(active_selector_arg) + except RuntimeError as exc: + raise RuntimeError( + "Active Module Profile não encontrado. Execute primeiro o " + "Script 0 ou informe --module-profile." + ) from exc + selector = load_json_file(selector_path) + if selector.get("schema") != ACTIVE_SELECTOR_SCHEMA: + raise RuntimeError( + f"Schema do seletor ativo inesperado: {selector.get('schema')!r}" + ) + declared = selector.get("profile_path") + if not declared: + raise RuntimeError("Seletor ativo sem profile_path.") + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json_file(profile_path) + validate_module_profile(profile, profile_path) + profile_sha = sha256_file(profile_path) + if selector is not None: + declared_sha = str(selector.get("profile_sha256") or "").lower() + if declared_sha and declared_sha != profile_sha.lower(): + raise RuntimeError( + "Hash do Module Profile diverge do active selector. " + "Execute novamente o Script 0." + ) + if str(selector.get("device_mx_id") or "") != str( + profile.get("device_mx_id") or "" + ): + raise RuntimeError("MX ID do selector diverge do Module Profile.") + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha, + "selector": selector, + "selector_path": selector_path, + } + + +def profile_provenance(contract: dict) -> dict: + profile = contract["profile"] + return { + "profile_name": profile["profile_name"], + "profile_path": str(contract["profile_path"]), + "profile_sha256": contract["profile_sha256"], + "device_mx_id": profile["device_mx_id"], + "selector_path": ( + None if contract["selector_path"] is None else str(contract["selector_path"]) + ), + } + + def socket_name(socket) -> str: name = getattr(socket, "name", None) if name: return str(name) - text = str(socket) for candidate in ("CAM_A", "CAM_B", "CAM_C", "CAM_D"): if candidate in text: return candidate - return text def supported_types(feature) -> List[str]: - return [ - str(v).upper() - for v in (getattr(feature, "supportedTypes", []) or []) - ] + return [str(v).upper() for v in (getattr(feature, "supportedTypes", []) or [])] -def feature_is_color(feature) -> bool: +def feature_is_color(feature) -> Optional[bool]: types = supported_types(feature) - sensor = str(getattr(feature, "sensorName", "") or "").upper() - - if any("COLOR" in x for x in types): + if any("COLOR" in value for value in types): return True - if any("MONO" in x for x in types): + if any("MONO" in value for value in types): return False - - return sensor in {"OV9782", "AR0234"} + return None -def feature_is_mono(feature) -> bool: +def feature_is_mono(feature) -> Optional[bool]: types = supported_types(feature) - - if any("MONO" in x for x in types): + if any("MONO" in value for value in types): return True - if any("COLOR" in x for x in types): + if any("COLOR" in value for value in types): return False - - return not feature_is_color(feature) + return None @dataclass @@ -253,13 +372,10 @@ class CameraInfo: is_mono: bool -def discover_hardware(mx_id: Optional[str]): - info = dai.DeviceInfo(mx_id) if mx_id else None - ctx = dai.Device(info) if info is not None else dai.Device() - - with ctx as device: +def discover_hardware(mx_id: str): + info = dai.DeviceInfo(mx_id) + with dai.Device(info) as device: actual_mx = None - for method_name in ("getMxId", "getDeviceId"): fn = getattr(device, method_name, None) if callable(fn): @@ -270,724 +386,260 @@ def discover_hardware(mx_id: Optional[str]): break except Exception: pass - - usb_speed = None try: usb_speed = str(device.getUsbSpeed()) except Exception: - pass - + usb_speed = None rows = [] - - for f in device.getConnectedCameraFeatures(): + for feature in device.getConnectedCameraFeatures(): rows.append({ - "socket": socket_name(f.socket), - "sensor": str(getattr(f, "sensorName", "") or "").upper(), - "width": int(getattr(f, "width", 0) or 0), - "height": int(getattr(f, "height", 0) or 0), - "is_color": feature_is_color(f), - "is_mono": feature_is_mono(f), - "supported_types": supported_types(f), + "socket": socket_name(feature.socket), + "sensor": str(getattr(feature, "sensorName", "") or "").upper(), + "width": int(getattr(feature, "width", 0) or 0), + "height": int(getattr(feature, "height", 0) or 0), + "is_color": feature_is_color(feature), + "is_mono": feature_is_mono(feature), + "supported_types": supported_types(feature), }) - return rows, actual_mx, usb_speed -def validate_and_resolve_hardware(rows: list[dict]) -> Dict[str, CameraInfo]: +def validate_and_resolve_hardware( + rows: list[dict], + profile: dict, + actual_mx: Optional[str], +) -> Dict[str, CameraInfo]: + expected_mx = str(profile["device_mx_id"]) + if str(actual_mx or "") != expected_mx: + raise RuntimeError( + f"Câmera conectada não pertence ao profile: " + f"esperado={expected_mx!r}, detectado={actual_mx!r}." + ) + + signature = canonical_signature(profile["hardware_signature"]) by_socket = {row["socket"]: row for row in rows} resolved = {} errors = [] - for role in ROLES: - contract = PRODUCT_TOPOLOGY[role] - sock = contract["socket"] - - row = by_socket.get(sock) + expected = signature[role] + row = by_socket.get(expected["socket"]) if row is None: - errors.append(f"{role.upper()}: {sock} ausente") + errors.append(f"{role}: socket {expected['socket']} ausente") continue - - sensor = row["sensor"] - - if sensor not in contract["allowed_sensors"]: + if row["sensor"] != expected["sensor"]: errors.append( - f"{role.upper()}: {sock} sensor={sensor}, " - f"permitidos={contract['allowed_sensors']}" + f"{role}/{expected['socket']}: sensor=" + f"{row['sensor']}, esperado={expected['sensor']}" ) - continue - - expected_w, expected_h = SENSOR_NATIVE_SIZE[sensor] - - if row["width"] and row["height"]: - if (row["width"], row["height"]) != (expected_w, expected_h): - errors.append( - f"{role.upper()}: {sensor} anunciou " - f"{row['width']}x{row['height']}, esperado " - f"{expected_w}x{expected_h}" - ) - - if role == "rgb" and not row["is_color"]: - errors.append(f"RGB: {sock}/{sensor} não anunciado COLOR") - - if role in ("re", "nir") and not row["is_mono"]: - errors.append(f"{role.upper()}: {sock}/{sensor} não anunciado MONO") - + if row["width"] and row["height"] and [ + row["width"], row["height"] + ] != expected["size"]: + errors.append( + f"{role}/{expected['socket']}: size=" + f"{row['width']}x{row['height']}, " + f"esperado={expected['size'][0]}x{expected['size'][1]}" + ) + if role == "rgb" and row["is_color"] is False: + errors.append(f"{role}/{expected['socket']} contradiz COLOR") + if role != "rgb" and row["is_mono"] is False: + errors.append(f"{role}/{expected['socket']} contradiz MONO") resolved[role] = CameraInfo( role=role, - socket=sock, - sensor=sensor, - width=expected_w, - height=expected_h, - is_color=bool(row["is_color"]), - is_mono=bool(row["is_mono"]), + socket=expected["socket"], + sensor=expected["sensor"], + width=expected["size"][0], + height=expected["size"][1], + is_color=(role == "rgb"), + is_mono=(role != "rgb"), ) - if errors: raise RuntimeError( - "Topologia de produto inválida:\n - " + "Hardware conectado diverge do Module Profile:\n - " + "\n - ".join(errors) ) - return resolved -def hardware_signature_from_resolved(resolved: Dict[str, CameraInfo]) -> dict: +def validate_radiometry_artifact(contract: dict) -> dict: + profile = contract["profile"] + try: + path = resolve_existing_path( + profile["artifact_paths"]["radiometry_json"], + anchor=contract["profile_path"], + ) + except RuntimeError as exc: + raise RuntimeError( + "Radiometria ativa não encontrada. Execute e promova o Script 4." + ) from exc + data = load_json_file(path) + if data.get("schema") != RADIOMETRY_SCHEMA: + raise RuntimeError(f"Schema radiométrico inesperado: {data.get('schema')!r}") + if str(data.get("status", "")).lower() not in {"good", "warning"}: + raise RuntimeError(f"Radiometria não homologada: {data.get('status')!r}") + if data.get("promoted") is not True: + raise RuntimeError("Radiometria não foi promovida.") + if str(data.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError("Radiometria pertence a outro MX ID.") + if canonical_signature(data.get("hardware_signature")) != canonical_signature( + profile.get("hardware_signature") + ): + raise RuntimeError("Hardware da Radiometria diverge do Module Profile.") + provenance = data.get("module_profile_contract") + if not isinstance(provenance, dict): + raise RuntimeError( + "Radiometria não possui module_profile_contract; rode novamente " + "o Script 4 revisado." + ) + if provenance.get("profile_sha256") != contract["profile_sha256"]: + raise RuntimeError("Radiometria foi gerada com outra versão do profile.") + if provenance.get("profile_name") != profile.get("profile_name"): + raise RuntimeError("Radiometria pertence a outro profile.") + + fragment = data.get("module_params_fragment") + normalization = ( + fragment.get("radiometric_normalization") + if isinstance(fragment, dict) + else None + ) + if not isinstance(normalization, dict) or normalization.get("enabled") is not True: + raise RuntimeError( + "Radiometria sem module_params_fragment.radiometric_normalization ativo." + ) + if normalization.get("method") != NORMALIZATION_METHOD: + raise RuntimeError( + f"Método radiométrico incompatível: {normalization.get('method')!r}." + ) + if normalization.get("factor_model") != FACTOR_MODEL: + raise RuntimeError("factor_model radiométrico incompatível.") + if normalization.get("apply_stage") != "after_dark_before_flat_gain": + raise RuntimeError("apply_stage radiométrico incompatível.") + + refs = normalization.get("reference_controls") + if not isinstance(refs, dict): + raise RuntimeError("Radiometria sem reference_controls.") + for role in ROLES: + ref = refs.get(role) + if not isinstance(ref, dict): + raise RuntimeError(f"reference_controls ausente para {role}.") + try: + exposure = int(ref["exposure_time_us"]) + iso = int(ref["sensitivity_iso"]) + except Exception as exc: + raise RuntimeError(f"Controles radiométricos inválidos para {role}.") from exc + if exposure <= 0 or iso < 50: + raise RuntimeError( + f"Controles radiométricos fora da faixa em {role}: " + f"EXP={exposure}, ISO={iso}." + ) return { - role: { - "socket": resolved[role].socket, - "sensor": resolved[role].sensor, - "size": [resolved[role].width, resolved[role].height], - } - for role in ROLES + "path": path, + "sha256": sha256_file(path), + "report": data, + "normalization": normalization, } -def normalize_signature(sig: dict) -> dict: - """ - Aceita assinatura no formato dos calibradores de produção. - Retorna apenas role/socket/sensor/size para comparação robusta. - """ - out = {} - - for role in ROLES: - item = (sig or {}).get(role, {}) or {} - - socket = ( - item.get("socket") - or item.get("socket_name") - ) - - sensor = ( - item.get("sensor") - or item.get("sensor_name") - ) - - size = item.get("size") - - if size is None: - width = item.get("width") - height = item.get("height") - if width is not None and height is not None: - size = [int(width), int(height)] - - if socket is None or sensor is None or size is None: - raise RuntimeError( - f"hardware_signature incompleta para {role}: {item}" - ) - - out[role] = { - "socket": str(socket), - "sensor": str(sensor).upper(), - "size": [int(size[0]), int(size[1])], - } - - return out - - -def extract_radiometric_fragment(data: dict) -> dict: - """ - O artefato radiométrico de produção guarda: - module_params_fragment.radiometric_normalization - """ - fragment = data.get("module_params_fragment", {}) or {} - rn = fragment.get("radiometric_normalization") - - if not isinstance(rn, dict): - raise RuntimeError( - "Artefato radiométrico sem " - "module_params_fragment.radiometric_normalization" - ) - - if not rn.get("enabled", False): - raise RuntimeError( - "radiometric_normalization do artefato não está enabled=true" - ) - - return rn - - -def validate_radiometry_artifact( - data: dict, - current_signature: dict, -): - schema = str(data.get("schema", "")) - - if schema != "multispec_radiometric_calibration_v6": - raise RuntimeError( - f"Schema radiométrico inesperado: {schema!r}. " - "Esperado 'multispec_radiometric_calibration_v6'." - ) - - status = str(data.get("status", "")).lower() - - if status not in ("good", "warning"): - raise RuntimeError( - f"Radiometria não homologada para consumo: status={status!r}" - ) - - if not bool(data.get("promoted", False)): - raise RuntimeError( - "Artefato radiométrico não foi promovido." - ) - - artifact_sig = normalize_signature( - data.get("hardware_signature", {}) - ) - - if artifact_sig != current_signature: - raise RuntimeError( - "Hardware conectado não corresponde ao hardware da radiometria.\n" - f"Atual : {current_signature}\n" - f"Rad : {artifact_sig}" - ) - - rn = extract_radiometric_fragment(data) - - refs = rn.get("reference_controls", {}) or {} - - for role in ROLES: - ref = refs.get(role) - - if not isinstance(ref, dict): - raise RuntimeError( - f"reference_controls ausente para {role}" - ) - - exp = ref.get("exposure_time_us") - iso = ref.get("sensitivity_iso") - - if exp is None or float(exp) <= 0: - raise RuntimeError( - f"reference exposure inválida para {role}: {exp}" - ) - - if iso is None or float(iso) < 50: - raise RuntimeError( - f"reference ISO inválido para {role}: {iso}" - ) - - return rn - - def iso_to_analogue_gain(iso: float) -> float: - """ - O manager atual representa analogue_gain aproximadamente como ISO/100. - """ - gain = float(iso) / ISO_BASE - return max(1.0, gain) + return max(1.0, float(iso) / ISO_BASE) def build_camera_settings(reference_controls: dict) -> dict: settings = {} - for role in ROLES: ref = reference_controls[role] - - exp = int(round(float(ref["exposure_time_us"]))) - iso = float(ref["sensitivity_iso"]) - gain = iso_to_analogue_gain(iso) - - if role == "rgb": - settings[role] = { - "ae_enable": True, - "awb_enable": True, - "exposure_time_us": exp, - "analogue_gain": gain, - "colour_gains": [1.0, 1.0], - } - else: - settings[role] = { - "ae_enable": True, - "awb_enable": False, - "exposure_time_us": exp, - "analogue_gain": gain, - "colour_gains": None, - } - + exposure = int(round(float(ref["exposure_time_us"]))) + gain = iso_to_analogue_gain(float(ref["sensitivity_iso"])) + settings[role] = { + "ae_enable": True, + "awb_enable": role == "rgb", + "exposure_time_us": exposure, + "analogue_gain": gain, + "colour_gains": [1.0, 1.0] if role == "rgb" else None, + } return settings def build_rgb_calibration_policy() -> dict: - """ - Produto final mantém correção multiplicativa RGB desligada. - Se um dia ela for homologada, deve existir um calibrador separado e - dataset/runtime precisam usar o mesmo contrato. - """ return { "enabled": False, - "gains": { - "R": 1.0, - "G": 1.0, - "B": 1.0, - }, + "gains": {"R": 1.0, "G": 1.0, "B": 1.0}, "policy": "disabled_product_default", "reason": ( - "No validated RGB tensor-gain calibration is currently active. " - "Historical manual gains are intentionally discarded." + "No validated RGB tensor-gain calibration is active; historical " + "manual gains are intentionally discarded." ), } -def _normalize_orientation_role( - role: str, - cfg: dict, -) -> dict: - if not isinstance(cfg, dict): - raise RuntimeError( - f"camera_orientation.by_role.{role} ausente/inválido." - ) - - raw_rotate = cfg.get("rotate_deg", 0) - - try: - rotate_float = float(raw_rotate) - except Exception as exc: - raise RuntimeError( - f"camera_orientation.{role}.rotate_deg inválido: {raw_rotate!r}" - ) from exc - - if abs(rotate_float - round(rotate_float)) > 1e-9: - raise RuntimeError( - f"camera_orientation.{role}.rotate_deg deve ser inteiro: " - f"{raw_rotate!r}" - ) - - rotate_deg = int(round(rotate_float)) % 360 - - if rotate_deg not in (0, 90, 180, 270): - raise RuntimeError( - f"camera_orientation.{role}.rotate_deg inválido: {rotate_deg}. " - "Permitidos=0,90,180,270." - ) - - flip_horizontal = cfg.get("flip_horizontal", False) - flip_vertical = cfg.get("flip_vertical", False) - - if not isinstance(flip_horizontal, bool): - raise RuntimeError( - f"camera_orientation.{role}.flip_horizontal deve ser bool." - ) - - if not isinstance(flip_vertical, bool): - raise RuntimeError( - f"camera_orientation.{role}.flip_vertical deve ser bool." - ) - - return { - "rotate_deg": rotate_deg, - "flip_horizontal": flip_horizontal, - "flip_vertical": flip_vertical, - } - - -def audit_camera_orientation_contract( - module_params: dict, - current_signature: dict, -) -> dict: - """ - Audita camera_orientation sem assumir autoridade sobre ela. - - Este tool NÃO gera nem aplica orientação. Ele apenas verifica um MP já - montado, quando disponível. - """ - if not isinstance(module_params, dict) or not module_params: - return { - "available": False, - "valid": None, - "reason": "module_params_not_available", - } - - orientation = module_params.get("camera_orientation") - - if not isinstance(orientation, dict): - return { - "available": False, - "valid": None, - "reason": "camera_orientation_not_present", - "module_params_schema": module_params.get("schema"), - } - - schema = str( - orientation.get("schema", "") - or "" - ).strip() - - enabled = bool( - orientation.get("enabled", False) - ) - - if schema != CAMERA_ORIENTATION_SCHEMA: - raise RuntimeError( - f"camera_orientation.schema inválido: {schema!r}. " - f"Esperado {CAMERA_ORIENTATION_SCHEMA!r}." - ) - - input_space = str( - orientation.get("input_space", "") - or "" - ).strip() - - output_space = str( - orientation.get("output_space", "") - or "" - ).strip() - - apply_stage = str( - orientation.get("apply_stage", "") - or "" - ).strip().lower() - - if not input_space: - raise RuntimeError( - "camera_orientation.input_space ausente/vazio." - ) - - if not output_space: - raise RuntimeError( - "camera_orientation.output_space ausente/vazio." - ) - - if apply_stage != "after_native_flat_before_fusion": - raise RuntimeError( - "camera_orientation.apply_stage inválido: " - f"{apply_stage!r}. Esperado " - "'after_native_flat_before_fusion'." - ) - - by_role = orientation.get("by_role", {}) or {} - - if not isinstance(by_role, dict): - raise RuntimeError( - "camera_orientation.by_role inválido." - ) - - normalized = {} - has_transform = False - - for role in ROLES: - normalized[role] = _normalize_orientation_role( - role, - by_role.get(role), - ) - - cfg = normalized[role] - - if ( - cfg["rotate_deg"] != 0 - or cfg["flip_horizontal"] - or cfg["flip_vertical"] - ): - has_transform = True - - if not enabled: - if has_transform: - raise RuntimeError( - "camera_orientation.enabled=false, mas existe rotate/flip " - "diferente de identidade." - ) - - if input_space != output_space: - raise RuntimeError( - "camera_orientation.enabled=false exige " - "input_space == output_space." - ) - - # Se camera_hardware está presente no MP, garante que o contrato de - # orientação pertence ao mesmo módulo/hardware conectado. - mp_hw = module_params.get("camera_hardware") - - if isinstance(mp_hw, dict): - mp_sig = {} - - for role in ROLES: - item = mp_hw.get(role) - - if not isinstance(item, dict): - raise RuntimeError( - f"camera_hardware.{role} ausente no module_params." - ) - - size = item.get("size") - - if not ( - isinstance(size, (list, tuple)) - and len(size) == 2 - ): - raise RuntimeError( - f"camera_hardware.{role}.size inválido: {size}" - ) - - mp_sig[role] = { - "socket": str(item.get("socket") or ""), - "sensor": str(item.get("sensor") or "").upper(), - "size": [ - int(size[0]), - int(size[1]), - ], - } - - if mp_sig != current_signature: - raise RuntimeError( - "camera_orientation pertence a um module_params cujo " - "camera_hardware não corresponde ao hardware conectado.\\n" - f"Atual: {current_signature}\\n" - f"MP : {mp_sig}" - ) - - return { - "available": True, - "valid": True, - "enabled": enabled, - "schema": schema, - "input_space": input_space, - "output_space": output_space, - "apply_stage": apply_stage, - "by_role": normalized, - "scientific_effect_in_this_tool": "none", - "runtime_owner": "RawProcessorCore", - } - - -def extract_radiometry_geometry_audit( +def build_payload( + args, + contract: dict, radiometry: dict, + rows: list[dict], + resolved: Dict[str, CameraInfo], + actual_mx: str, + usb_speed: Optional[str], + candidate_dir: Optional[Path], + active_json: Path, ) -> dict: - """ - Lê apenas metadados geométricos do artefato radiométrico. - - Eles são rastreabilidade. Não entram no module_params_fragment desta etapa. - """ - geometry = ( - radiometry.get("geometry_contract", {}) - if isinstance(radiometry, dict) - else {} - ) - - if not isinstance(geometry, dict) or not geometry: - return { - "available": False, - } - - return { - "available": True, - "radiometric_measurement_space": geometry.get( - "radiometric_measurement_space" - ), - "measurement_orientation": geometry.get( - "measurement_orientation" - ), - "preview_orientation_only": geometry.get( - "preview_orientation_only" - ), - "preview_orientation_by_role": geometry.get( - "preview_orientation_by_role" - ), - "scientific_orientation_effect": geometry.get( - "scientific_orientation_effect" - ), - } - - -def compare_existing_module_params( - module_params_path: Optional[str], - generated_fragment: dict, - current_signature: dict, -) -> dict: - if not module_params_path: - return { - "available": False, - "path": None, - } - - p = Path(module_params_path) - - if not p.is_file(): - return { - "available": False, - "path": str(p), - } - - current = load_json(p) - - current_camera = current.get("camera_settings") - current_rgb = current.get("rgb_calibration") - - generated_camera = generated_fragment["camera_settings"] - generated_rgb = generated_fragment["rgb_calibration"] - - orientation_audit = audit_camera_orientation_contract( - current, - current_signature, - ) - - return { - "available": True, - "path": str(p), - "schema": current.get("schema"), - "camera_settings_equal": current_camera == generated_camera, - "rgb_calibration_equal": current_rgb == generated_rgb, - "current_camera_settings": current_camera, - "generated_camera_settings": generated_camera, - "current_rgb_calibration": current_rgb, - "generated_rgb_calibration": generated_rgb, - "camera_orientation_audit": orientation_audit, - } - - -def main(): - parser = argparse.ArgumentParser( - description=( - "Gerador/auditor de produção para camera_settings e " - "política rgb_calibration do módulo multiespectral." - ), - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - - parser.add_argument( - "--mx-id", - default=None, - help="MX ID da OAK. Vazio usa o primeiro dispositivo disponível.", - ) - - parser.add_argument( - "--radiometry-json", - default="calibration/radiometry_calibration_v5.json", - help="Artefato radiométrico homologado.", - ) - - parser.add_argument( - "--out-json", - default="calibration/camera_startup_profile_v3.json", - help="Artefato ativo desta etapa.", - ) - - parser.add_argument( - "--compare-module-params", - default="calibration/module_params.json", - help="Opcional: audita diferenças contra module_params existente.", - ) - - parser.add_argument( - "--module-id", - default="", - ) - - parser.add_argument( - "--operator", - default="", - ) - - parser.add_argument( - "--notes", - default="", - ) - - parser.add_argument( - "--check-only", - action="store_true", - help="Valida e imprime o fragmento, mas não salva.", - ) - - args = parser.parse_args() - - rows, actual_mx, usb_speed = discover_hardware(args.mx_id) - resolved = validate_and_resolve_hardware(rows) - - current_signature = hardware_signature_from_resolved(resolved) - - radiometry = load_json(args.radiometry_json) - - rn = validate_radiometry_artifact( - radiometry, - current_signature, - ) - - reference_controls = rn["reference_controls"] - + profile = contract["profile"] + normalization = radiometry["normalization"] + reference_controls = normalization["reference_controls"] camera_settings = build_camera_settings(reference_controls) - rgb_calibration = build_rgb_calibration_policy() - - radiometry_geometry_audit = extract_radiometry_geometry_audit( - radiometry - ) - - module_params_fragment = { + rgb_policy = build_rgb_calibration_policy() + module_fragment = { "camera_settings": camera_settings, "rgb_calibration": { "enabled": False, - "gains": { - "R": 1.0, - "G": 1.0, - "B": 1.0, - }, + "gains": {"R": 1.0, "G": 1.0, "B": 1.0}, }, } - - audit = compare_existing_module_params( - args.compare_module_params, - module_params_fragment, - current_signature, - ) - - payload = { + orientations = { + role: normalize_orientation( + profile["preview_orientation_defaults"][role], role + ) + for role in ROLES + } + source_report = radiometry["report"] + return { "schema": SCHEMA, "created_at": now_str(), "status": "good", - "promoted": not args.check_only, - "runtime_effect": ( - "startup_camera_policy_and_explicit_rgb_gain_disable" - ), + "promoted": False, + "runtime_effect": "startup_camera_policy_and_explicit_rgb_gain_disable", "tool_version": TOOL_VERSION, + "module_profile_contract": profile_provenance(contract), + "device_mx_id": actual_mx, + "usb_speed": usb_speed, + "hardware_inventory": rows, + "hardware_signature": canonical_signature(profile["hardware_signature"]), + "resolved_setup": {role: asdict(resolved[role]) for role in ROLES}, + "rgb_decode_contract": dict(profile["rgb_decode"]), "geometry_contract": { "applies_pixel_transform": False, "camera_orientation_generated_here": False, "camera_orientation_stored_in_camera_settings": False, - "camera_orientation_runtime_owner": "RawProcessorCore", + "camera_orientation_runtime_owner": "RawProcessorCore/Homography", + "preview_orientation_by_role": orientations, "runtime_order": ( "decode -> radiometric_normalization -> flat_native -> " "camera_orientation -> homography" ), - "source_radiometry_geometry": radiometry_geometry_audit, - "existing_module_params_orientation_audit": ( - audit.get("camera_orientation_audit") - if isinstance(audit, dict) - else None - ), "note": ( - "This startup tool never rotates/flips frames. " - "camera_orientation is a separate geometric contract." + "Startup nunca rotaciona ou espelha frames; orientação é um " + "contrato geométrico separado." ), }, - "device_mx_id": actual_mx, - "usb_speed": usb_speed, - "hardware_inventory": rows, - "hardware_signature": current_signature, "source_radiometry": { - "path": str(args.radiometry_json), - "schema": radiometry.get("schema"), - "session_id": radiometry.get("session_id"), - "status": radiometry.get("status"), - "promoted": radiometry.get("promoted"), + "path": str(radiometry["path"]), + "sha256": radiometry["sha256"], + "schema": source_report.get("schema"), + "session_id": source_report.get("session_id"), + "status": source_report.get("status"), + "promoted": source_report.get("promoted"), + "method": normalization.get("method"), "reference_controls": reference_controls, + "inherited_prerequisites": source_report.get("prerequisites"), }, "startup_policy": { "mode": "ae_with_calibrated_manual_fallback", @@ -996,103 +648,153 @@ def main(): "manual_fallback_source": ( "radiometric_normalization.reference_controls" ), - "note": ( - "With AE enabled, manager stores exposure/gain as fallback " - "and does not force them manually at startup." - ), "camera_orientation_policy": ( - "not_part_of_camera_settings; audited_only; " - "applied_by_raw_processor_core" + "not_part_of_camera_settings; applied_by_raw_processor_core" ), }, - "rgb_calibration_policy": rgb_calibration, - "module_params_fragment": module_params_fragment, - "existing_module_params_audit": audit, + "rgb_calibration_policy": rgb_policy, + "module_params_fragment": module_fragment, "traceability": { - "module_id": args.module_id or None, - "operator": args.operator or None, + "module_id": profile.get("module_id"), + "operator": profile.get("operator"), }, "notes": args.notes or "", + "outputs": { + "candidate_dir": None if candidate_dir is None else str(candidate_dir), + "active_json": str(active_json), + }, } - print("=" * 82) + +def promote_payload(active_json: Path, candidate_report: Path, payload: dict): + ensure_dir(active_json.parent) + if active_json.exists(): + backup = active_json.with_name( + active_json.stem + f".backup_{stamp()}" + active_json.suffix + ) + shutil.copy2(active_json, backup) + payload["promoted"] = True + payload["promoted_at"] = now_str() + save_json_atomic(candidate_report, payload) + save_json_atomic(active_json, payload) + + +def print_summary( + profile: dict, + resolved: Dict[str, CameraInfo], + payload: dict, + check_only: bool, +): + camera_settings = payload["module_params_fragment"]["camera_settings"] + print("=" * 86) print("CAMERA STARTUP PROFILE - PRODUCTION") - print(f"MX ID : {actual_mx}") - print(f"USB : {usb_speed}") - print("-" * 82) - + print(f"Profile: {profile['profile_name']}") + print(f"MX ID : {payload['device_mx_id']}") + print(f"USB : {payload['usb_speed']}") + print("-" * 86) for role in ROLES: - c = resolved[role] - s = camera_settings[role] - + camera = resolved[role] + settings = camera_settings[role] print( - f"{c.socket} -> {role.upper():3s} | " - f"{c.sensor:8s} | {c.width}x{c.height} | " - f"AE={s['ae_enable']} AWB={s['awb_enable']} | " - f"fallback={s['exposure_time_us']}us gain={s['analogue_gain']:.3f}" + f"{camera.socket} -> {role.upper():3s} | " + f"{camera.sensor:8s} | {camera.width}x{camera.height} | " + f"AE={settings['ae_enable']} AWB={settings['awb_enable']} | " + f"fallback={settings['exposure_time_us']}us " + f"gain={settings['analogue_gain']:.3f}" + ) + print("-" * 86) + print("rgb_calibration: DISABLED | gains=1.0/1.0/1.0") + print("camera_orientation: NOT APPLIED HERE") + if check_only: + print("[CHECK-ONLY] Contratos validados; nenhum arquivo foi alterado.") + else: + print(f"[PASS] Artefato ativo: {payload['outputs']['active_json']}") + print(f"[CANDIDATE] {payload['outputs']['candidate_dir']}") + print("=" * 86) + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Gera camera_settings e a política rgb_calibration a partir do " + "Module Profile e da Radiometria promovida." + ), + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--module-profile", + default=None, + help="Profile explícito; se omitido, usa --active-profile.", + ) + parser.add_argument( + "--active-profile", + default="calibration/active_module_profile.json", + help="Seletor criado pelo Script 0.", + ) + parser.add_argument( + "--mx-id", + default=None, + help="Verificação opcional; deve coincidir com o MX ID do profile.", + ) + parser.add_argument("--notes", default="") + parser.add_argument( + "--check-only", + action="store_true", + help="Valida e imprime o fragmento sem criar candidate ou ativo.", + ) + args = parser.parse_args() + + contract = load_module_profile_contract( + args.module_profile, + args.active_profile, + ) + profile = contract["profile"] + if args.mx_id and str(args.mx_id) != str(profile["device_mx_id"]): + raise RuntimeError( + f"--mx-id={args.mx_id!r} diverge do Module Profile " + f"({profile['device_mx_id']!r})." ) - print("-" * 82) - print("rgb_calibration: DISABLED | gains = 1.0 / 1.0 / 1.0") - print( - "camera_orientation: NOT APPLIED HERE | " - "owner=RawProcessorCore" + rows, actual_mx, usb_speed = discover_hardware(profile["device_mx_id"]) + resolved = validate_and_resolve_hardware(rows, profile, actual_mx) + radiometry = validate_radiometry_artifact(contract) + + artifacts = profile["artifact_paths"] + active_json = resolve_output_path(artifacts["camera_startup_json"]) + candidate_dir = None + candidate_report = None + if not args.check_only: + candidate_dir = resolve_output_path( + artifacts["candidate_roots"]["camera_startup"] + ) / stamp() + candidate_report = candidate_dir / "report.json" + ensure_dir(candidate_dir) + + payload = build_payload( + args=args, + contract=contract, + radiometry=radiometry, + rows=rows, + resolved=resolved, + actual_mx=str(actual_mx), + usb_speed=usb_speed, + candidate_dir=candidate_dir, + active_json=active_json, ) - if audit.get("available"): - orientation_audit = ( - audit.get("camera_orientation_audit") - or {} + if not args.check_only: + assert candidate_dir is not None and candidate_report is not None + promote_payload(active_json, candidate_report, payload) + (candidate_dir / "PASS.txt").write_text( + ( + f"status={payload['status']}\n" + f"promoted={payload['promoted']}\n" + f"active_json={active_json}\n" + ), + encoding="utf-8", ) - print( - "module_params audit: " - f"camera_settings_equal={audit['camera_settings_equal']} | " - f"rgb_calibration_equal={audit['rgb_calibration_equal']} | " - f"orientation_valid={orientation_audit.get('valid')}" - ) - - if orientation_audit.get("available"): - for role in ROLES: - o = ( - orientation_audit.get("by_role", {}) - .get(role, {}) - ) - - print( - f" orientation {role.upper():3s}: " - f"rotate={o.get('rotate_deg')} " - f"flip_h={o.get('flip_horizontal')} " - f"flip_v={o.get('flip_vertical')} " - "(audit only)" - ) - else: - print("module_params audit: arquivo não disponível") - - if args.check_only: - print( - "[CHECK-ONLY] Nenhum arquivo alterado. " - "Nenhuma transformação geométrica foi aplicada." - ) - print("=" * 82) - return - - out = Path(args.out_json) - ensure_dir(out.parent) - - if out.exists(): - backup = out.with_name( - out.stem - + f".backup_{stamp()}" - + out.suffix - ) - shutil.copy2(out, backup) - - save_json_atomic(out, payload) - - print(f"[PASS] Artefato salvo: {out}") - print("[ASSEMBLER] Consumir module_params_fragment deste JSON.") - print("=" * 82) + print_summary(profile, resolved, payload, args.check_only) if __name__ == "__main__": diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py index 9f2bca06c..626ad4c8f 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py @@ -2,20 +2,19 @@ # -*- coding: utf-8 -*- """ -homography_calibration_production.py +_6_homography_calibration_tool.py ==================================== Calibrador FINAL de produção para alinhamento geométrico RGB / RE / NIR do módulo OAK-FFC-3P usando ChArUco. -Topologia oficial ------------------ - CAM_A = RGB = OV9782 1280x800 - OU - AR0234 1920x1200 - - CAM_B = RE = OV9282 1280x800 - CAM_C = NIR = OV9282 1280x800 +Contrato de configuração +------------------------ +O hardware, as resoluções, a orientação bootstrap e todos os caminhos de +artefatos vêm exclusivamente do Module Profile criado pelo Script 0. +Este calibrador não lê module_params.json e não mantém tabela paralela de +sensores. Focus, Intrinsics, Flat Field, Radiometria e Camera Startup precisam +pertencer à mesma versão do Module Profile antes desta etapa final. Princípio geométrico -------------------- @@ -26,12 +25,9 @@ A homografia é estimada DIRETAMENTE no espaço CANÔNICO PÓS-ORIENTAÇÃO: -> detecção ChArUco -> H_RE_to_RGB / H_NIR_to_RGB -No módulo final atual: - RGB AR0234 -> ROT180 - RE OV9282 -> identidade - NIR OV9282 -> identidade - -Logo a AR0234 é girada ANTES da detecção ChArUco e ANTES de calcular H. +A orientação exata de cada role vem de +Module Profile.preview_orientation_defaults e é aplicada ANTES da detecção +ChArUco e ANTES de calcular H. NÃO redimensionamos RE/NIR para o tamanho da RGB antes de detectar pontos. Isso seria geometricamente incorreto para uma calibração de produto. @@ -86,15 +82,15 @@ todas as amostras daquela sessão. Exemplos: --------- - python homography_calibration_production.py ^ + python -m utils._6_homography_calibration_tool ^ --profile-name media ^ --depth-cm 66 - python homography_calibration_production.py ^ + python -m utils._6_homography_calibration_tool ^ --profile-name baixa ^ --depth-cm 120 - python homography_calibration_production.py ^ + python -m utils._6_homography_calibration_tool ^ --profile-name alta ^ --depth-cm 36 @@ -116,7 +112,7 @@ Fluxo 4. PASS: - candidate auditável é salvo - - perfil é promovido para homography_calibration_v4.json + - perfil é promovido no artifact_paths.homography_json do Module Profile - outros perfis previamente aprovados são preservados 5. FAIL/CANCEL: @@ -174,44 +170,15 @@ import numpy as np ROLES = ("rgb", "re", "nir") SPEC_ROLES = ("re", "nir") -PRODUCT_TOPOLOGY = { - "rgb": { - "socket": "CAM_A", - "allowed_sensors": ("OV9782", "AR0234"), - }, - "re": { - "socket": "CAM_B", - "allowed_sensors": ("OV9282",), - }, - "nir": { - "socket": "CAM_C", - "allowed_sensors": ("OV9282",), - }, -} - -RGB_SENSOR_MODES = { - "OV9782": { - "resolution_enum": "THE_800_P", - "width": 1280, - "height": 800, - }, - "AR0234": { - "resolution_enum": "THE_1200_P", - "width": 1920, - "height": 1200, - }, -} - -MONO_SENSOR_MODES = { - "OV9282": { - "resolution_enum": "THE_800_P", - "width": 1280, - "height": 800, - }, -} - SCHEMA = "multispec_homography_calibration_v4" -TOOL_VERSION = "production_orientation_aware_v1_2026_08_31" +TOOL_VERSION = "module_profile_contract_v1_2026_09_09" +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +FOCUS_SCHEMA = "multispec_focus_qc_v3" +INTRINSICS_SCHEMA = "multispec_intrinsics_calibration_v1" +FLATFIELD_SCHEMA = "multispec_flatfield_production_v2" +RADIOMETRY_SCHEMA = "multispec_radiometric_calibration_v6" +CAMERA_STARTUP_SCHEMA = "multispec_camera_startup_profile_v3" CAMERA_ORIENTATION_SCHEMA = "multispec_camera_orientation_v1" NATIVE_COORDINATE_SPACE = "native_stream_no_external_undistort" @@ -219,25 +186,6 @@ DEFAULT_ORIENTED_COORDINATE_SPACE = ( "canonical_oriented_stream_no_external_undistort" ) -# Fallback mecânico do produto quando ainda não existe module_params montado. -# Se module_params.camera_orientation existir, ele tem precedência. -ORIENTATION_BY_SENSOR = { - "OV9782": { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, - "AR0234": { - "rotate_deg": 180, - "flip_horizontal": False, - "flip_vertical": False, - }, - "OV9282": { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, -} # ============================================================ @@ -367,6 +315,272 @@ def sha256_file(path: str | Path) -> str: return h.hexdigest() +def load_json_file(path: str | Path) -> dict: + p = Path(path) + if not p.is_file(): + raise RuntimeError(f"JSON obrigatório não encontrado: {p}") + try: + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + raise RuntimeError(f"JSON inválido em {p}: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"JSON raiz deve ser objeto: {p}") + return data + + +def resolve_existing_path(value: str | Path, anchor: Optional[Path] = None) -> Path: + raw = Path(value).expanduser() + candidates = [raw] + if not raw.is_absolute() and anchor is not None: + candidates.append(Path(anchor).parent / raw) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + tried = ", ".join(str(x) for x in candidates) + raise RuntimeError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def resolve_output_path(value: str | Path) -> Path: + """artifact_paths do Script 0 são relativos à raiz do projeto.""" + return Path(value).expanduser() + + +def canonical_hardware_signature(value: dict) -> dict: + if not isinstance(value, dict): + return {} + result = {} + for role in ROLES: + item = value.get(role) + if not isinstance(item, dict): + return {} + socket = item.get("socket") or item.get("socket_name") + sensor = item.get("sensor") or item.get("sensor_name") + size = item.get("size") + if size is None and item.get("width") is not None: + size = [item.get("width"), item.get("height")] + if not socket or not sensor or not isinstance(size, (list, tuple)) or len(size) != 2: + return {} + result[role] = { + "socket": str(socket), + "sensor": str(sensor).upper(), + "size": [int(size[0]), int(size[1])], + } + return result + + +def validate_module_profile(profile: dict, profile_path: Path): + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema de Module Profile inesperado em {profile_path}: " + f"{profile.get('schema')!r}" + ) + if str(profile.get("status", "")).lower() != "active": + raise RuntimeError(f"Module Profile não está ativo: {profile.get('status')!r}") + for key in ("profile_name", "profile_dir", "device_mx_id"): + if not str(profile.get(key) or "").strip(): + raise RuntimeError(f"Module Profile sem {key}.") + + signature = canonical_hardware_signature(profile.get("hardware_signature")) + resolved = profile.get("resolved_setup") + if len(signature) != len(ROLES) or not isinstance(resolved, dict): + raise RuntimeError("Module Profile sem hardware_signature/resolved_setup válido.") + for role in ROLES: + setup = resolved.get(role) + if not isinstance(setup, dict): + raise RuntimeError(f"resolved_setup sem role {role}.") + hw = signature[role] + if ( + str(setup.get("socket")) != hw["socket"] + or str(setup.get("sensor") or "").upper() != hw["sensor"] + or [int(setup.get("width", -1)), int(setup.get("height", -1))] != hw["size"] + or not str(setup.get("resolution_name") or "").strip() + ): + raise RuntimeError(f"resolved_setup/{role} diverge do hardware_signature.") + + orientation = profile.get("preview_orientation_defaults") + if not isinstance(orientation, dict): + raise RuntimeError("Module Profile sem preview_orientation_defaults.") + for role in ROLES: + item = orientation.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"Module Profile sem orientação bootstrap para {role}.") + normalize_orientation_role(role, item) + + artifacts = profile.get("artifact_paths") + roots = artifacts.get("candidate_roots") if isinstance(artifacts, dict) else None + if not isinstance(roots, dict) or not roots.get("homography"): + raise RuntimeError("Module Profile sem artifact_paths.candidate_roots.homography.") + for key in ( + "focus_qc_json", "intrinsics_json", "flatfield_npz", "flatfield_json", + "radiometry_json", "camera_startup_json", "homography_json", + ): + if not artifacts.get(key): + raise RuntimeError(f"Module Profile sem artifact_paths.{key}.") + + +def load_module_profile_contract( + module_profile_arg: Optional[str], active_selector_arg: str, +) -> dict: + selector = None + selector_path = None + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + try: + selector_path = resolve_existing_path(active_selector_arg) + except RuntimeError as exc: + raise RuntimeError( + "Active Module Profile não encontrado. Execute primeiro o " + "Script 0 ou informe --module-profile." + ) from exc + selector = load_json_file(selector_path) + if selector.get("schema") != ACTIVE_SELECTOR_SCHEMA: + raise RuntimeError( + f"Schema do seletor ativo inesperado: {selector.get('schema')!r}" + ) + declared = selector.get("profile_path") + if not declared: + raise RuntimeError("Seletor ativo sem profile_path.") + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json_file(profile_path) + validate_module_profile(profile, profile_path) + profile_sha = sha256_file(profile_path) + if selector is not None: + declared_sha = str(selector.get("profile_sha256") or "").lower() + if declared_sha and declared_sha != profile_sha.lower(): + raise RuntimeError( + "Hash do Module Profile diverge do active selector. " + "Execute novamente o Script 0." + ) + if str(selector.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError("MX ID do selector diverge do Module Profile.") + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha, + "selector": selector, + "selector_path": selector_path, + } + + +def module_profile_provenance(contract: dict) -> dict: + profile = contract["profile"] + return { + "profile_name": profile["profile_name"], + "profile_path": str(contract["profile_path"]), + "profile_sha256": contract["profile_sha256"], + "device_mx_id": profile["device_mx_id"], + "selector_path": ( + None if contract["selector_path"] is None else str(contract["selector_path"]) + ), + } + + +def validate_artifact_profile(report: dict, contract: dict, label: str): + provenance = report.get("module_profile_contract") + if not isinstance(provenance, dict): + raise RuntimeError( + f"{label} não possui module_profile_contract. Rode novamente o " + "calibrador revisado correspondente." + ) + if provenance.get("profile_sha256") != contract["profile_sha256"]: + raise RuntimeError(f"{label} foi gerado com outra versão do Module Profile.") + if provenance.get("profile_name") != contract["profile"]["profile_name"]: + raise RuntimeError(f"{label} pertence a outro profile.") + if canonical_hardware_signature(report.get("hardware_signature")) != ( + canonical_hardware_signature(contract["profile"].get("hardware_signature")) + ): + raise RuntimeError(f"Hardware de {label} diverge do Module Profile.") + + +def load_json_prerequisite( + contract: dict, artifact_key: str, label: str, schema: str, + allowed_status: set[str], require_promoted: bool = True, +) -> dict: + profile = contract["profile"] + try: + path = resolve_existing_path( + profile["artifact_paths"][artifact_key], anchor=contract["profile_path"] + ) + except RuntimeError as exc: + raise RuntimeError(f"{label} ativo não encontrado. Execute a etapa anterior.") from exc + report = load_json_file(path) + if report.get("schema") != schema: + raise RuntimeError(f"Schema de {label} inesperado: {report.get('schema')!r}") + status = str(report.get("status", "")).lower() + if status not in allowed_status: + raise RuntimeError(f"{label} não está aprovado: status={status!r}") + if require_promoted and report.get("promoted") is not True: + raise RuntimeError(f"{label} não foi promovido.") + validate_artifact_profile(report, contract, label) + return { + "path": str(path), "sha256": sha256_file(path), "status": status, + "report": report, + } + + +def load_homography_prerequisites(contract: dict) -> dict: + focus = load_json_prerequisite( + contract, "focus_qc_json", "Focus QC", FOCUS_SCHEMA, {"pass"} + ) + if focus["report"].get("normal_production_homologation") is not True: + raise RuntimeError("Focus QC não é homologação normal de produção.") + + intrinsics = load_json_prerequisite( + contract, "intrinsics_json", "Intrinsics", INTRINSICS_SCHEMA, + {"pass", "warning_promoted"}, + ) + intr_cfg = ( + intrinsics["report"].get("module_params_fragment", {}).get("intrinsics_config", {}) + ) + if intr_cfg.get("calibration_space") != NATIVE_COORDINATE_SPACE: + raise RuntimeError("Intrinsics não pertence ao espaço nativo esperado.") + if bool(intr_cfg.get("runtime_undistort", {}).get("enabled", False)): + raise RuntimeError( + "Intrinsics solicita undistort no runtime, mas esta Homography mede frames " + "sem undistort. Desative-o ou adapte explicitamente o contrato geométrico." + ) + + flat = load_json_prerequisite( + contract, "flatfield_json", "Flat Field", FLATFIELD_SCHEMA, {"good", "warning"} + ) + npz_path = resolve_existing_path( + contract["profile"]["artifact_paths"]["flatfield_npz"], + anchor=contract["profile_path"], + ) + npz_sha = sha256_file(npz_path) + declared = str(flat["report"].get("candidate_sha256") or "").lower() + if declared and declared != npz_sha.lower(): + raise RuntimeError("Hash do NPZ ativo diverge do report do Flat Field.") + flat["npz_path"] = str(npz_path) + flat["npz_sha256"] = npz_sha + + radiometry = load_json_prerequisite( + contract, "radiometry_json", "Radiometria", RADIOMETRY_SCHEMA, + {"good", "warning"}, + ) + startup = load_json_prerequisite( + contract, "camera_startup_json", "Camera Startup", CAMERA_STARTUP_SCHEMA, + {"good"}, + ) + + for item in (focus, flat, radiometry, startup): + item.pop("report", None) + item["scientific_geometry_dependency"] = False + intrinsics.pop("report", None) + intrinsics["scientific_geometry_dependency"] = True + intrinsics["reason"] = "locks_native_no_external_undistort_coordinate_space" + return { + "focus": focus, + "intrinsics": intrinsics, + "flatfield": flat, + "radiometry": radiometry, + "camera_startup": startup, + } + + def safe_int(v, default=None): try: if v is None: @@ -490,7 +704,7 @@ def feature_is_color(feature) -> bool: if any("MONO" in x for x in types): return False - return sensor in {"OV9782", "AR0234"} + return None def feature_is_mono(feature) -> bool: @@ -502,7 +716,7 @@ def feature_is_mono(feature) -> bool: if any("COLOR" in x for x in types): return False - return not feature_is_color(feature) + return None def resize_panel(img: np.ndarray, width: int, height: int) -> np.ndarray: @@ -808,368 +1022,45 @@ def orientation_signature( def resolve_orientation_contract( specs, - args, - module_params: Optional[dict] = None, + module_profile: dict, ): """ - Resolve a orientação científica da calibração. - - Precedência: - 1. default mecânico por sensor; - 2. module_params.camera_orientation; - 3. CLI explícita. - - Se existir MP e a CLI divergir dele, bloqueia por padrão. - Use --allow-orientation-override somente durante engenharia e atualize - o module_params antes de usar a Homography gerada. + A orientação bootstrap declarada pelo Script 0 é a única autoridade + científica pré-Homography. Não há fallback por sensor nem override CLI. """ - module_params = module_params or {} - - mp_orientation = module_params.get( - "camera_orientation", - {}, - ) - - has_mp_orientation = isinstance( - mp_orientation, - dict, - ) and bool(mp_orientation) - - mp_enabled = False - mp_by_role = {} - input_space = NATIVE_COORDINATE_SPACE - output_space = None - apply_stage = ( - "after_native_flat_before_fusion" - ) - - if has_mp_orientation: - schema = str( - mp_orientation.get("schema", "") - or "" - ) - - if ( - schema - and schema - != CAMERA_ORIENTATION_SCHEMA - ): - raise RuntimeError( - f"camera_orientation.schema inválido: " - f"{schema!r}; esperado " - f"{CAMERA_ORIENTATION_SCHEMA!r}." - ) - - mp_enabled = bool( - mp_orientation.get( - "enabled", - False, - ) - ) - - input_space = str( - mp_orientation.get( - "input_space", - NATIVE_COORDINATE_SPACE, - ) - or NATIVE_COORDINATE_SPACE - ) - - output_space = str( - mp_orientation.get( - "output_space", - "", - ) - or "" - ) - - apply_stage = str( - mp_orientation.get( - "apply_stage", - apply_stage, - ) - or apply_stage - ).lower() - - if ( - apply_stage - != "after_native_flat_before_fusion" - ): - raise RuntimeError( - "camera_orientation.apply_stage incompatível: " - f"{apply_stage!r}" - ) - - mp_by_role = ( - mp_orientation.get( - "by_role", - {}, - ) - or {} - ) - - if not isinstance( - mp_by_role, - dict, - ): - raise RuntimeError( - "camera_orientation.by_role inválido." - ) - - # Se o MP tem hardware, impede calibrar orientação de outro módulo. - mp_hw = module_params.get( - "camera_hardware" - ) - - if isinstance(mp_hw, dict): - for role in ROLES: - item = mp_hw.get(role) - - if not isinstance(item, dict): - raise RuntimeError( - f"camera_hardware.{role} ausente." - ) - - expected = specs[role] - - size = item.get("size") - - if ( - str(item.get("socket") or "") - != expected.socket_name - or str(item.get("sensor") or "").upper() - != expected.sensor_name - or not isinstance(size, (list, tuple)) - or len(size) != 2 - or [ - int(size[0]), - int(size[1]), - ] - != [ - expected.width, - expected.height, - ] - ): - raise RuntimeError( - f"module_params.camera_hardware.{role} " - "não corresponde ao hardware conectado." - ) - + declared = module_profile.get("preview_orientation_defaults") + if not isinstance(declared, dict): + raise RuntimeError("Module Profile sem preview_orientation_defaults.") resolved = {} - cli_diverged_from_mp = False - cli_used = False - for role in ROLES: - sensor = str( - specs[role].sensor_name - or "" - ).upper() - - base = dict( - ORIENTATION_BY_SENSOR.get( - sensor, - { - "rotate_deg": 0, - "flip_horizontal": False, - "flip_vertical": False, - }, - ) - ) - base["source"] = "sensor_default" - - mp_role = ( - mp_by_role.get(role) - if has_mp_orientation - else None - ) - - mp_norm = None - - if isinstance(mp_role, dict): - base.update({ - "rotate_deg": mp_role.get( - "rotate_deg", - base["rotate_deg"], - ), - "flip_horizontal": mp_role.get( - "flip_horizontal", - base["flip_horizontal"], - ), - "flip_vertical": mp_role.get( - "flip_vertical", - base["flip_vertical"], - ), - "source": "module_params", - }) - - mp_norm = normalize_orientation_role( - role, - base, - ) - - rotate_override = getattr( - args, - f"{role}_rotate_deg", - None, - ) - - role_cli = False - - if rotate_override is not None: - base["rotate_deg"] = int( - rotate_override - ) - role_cli = True - - if bool( - getattr( - args, - f"{role}_flip_horizontal", - False, - ) - ): - base["flip_horizontal"] = True - role_cli = True - - if bool( - getattr( - args, - f"{role}_flip_vertical", - False, - ) - ): - base["flip_vertical"] = True - role_cli = True - - if role_cli: - base["source"] = "cli_override" - cli_used = True - - norm = normalize_orientation_role( - role, - base, - ) - - if ( - role_cli - and mp_norm is not None - and any( - norm[k] != mp_norm[k] - for k in ( - "rotate_deg", - "flip_horizontal", - "flip_vertical", - ) - ) - ): - cli_diverged_from_mp = True - + item = declared.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"Orientação bootstrap ausente para {role}.") + norm = normalize_orientation_role(role, dict(item)) + norm["source"] = "module_profile" ow, oh = oriented_size_wh( - specs[role].width, - specs[role].height, - norm, + specs[role].width, specs[role].height, norm, ) - - norm["native_size"] = [ - int(specs[role].width), - int(specs[role].height), - ] - norm["oriented_size"] = [ - int(ow), - int(oh), - ] - + norm["native_size"] = [int(specs[role].width), int(specs[role].height)] + norm["oriented_size"] = [int(ow), int(oh)] resolved[role] = norm - - if ( - cli_diverged_from_mp - and not args.allow_orientation_override - ): - raise RuntimeError( - "Override de orientação diverge do module_params atual. " - "Isso produziria Homography incompatível com o runtime. " - "Corrija o MP ou use --allow-orientation-override somente " - "para engenharia." - ) - any_transform = any( - not ( - resolved[role]["rotate_deg"] == 0 - and not resolved[role]["flip_horizontal"] - and not resolved[role]["flip_vertical"] - ) + not orientation_is_identity(resolved[role]) for role in ROLES ) - - enabled = bool(any_transform) - - if has_mp_orientation and not cli_diverged_from_mp: - if mp_enabled != enabled: - raise RuntimeError( - "camera_orientation.enabled do module_params não corresponde " - "às transformações resolvidas." - ) - - if not output_space: - if enabled: - raise RuntimeError( - "camera_orientation.output_space ausente no module_params." - ) - output_space = input_space - - elif has_mp_orientation and cli_diverged_from_mp: - # Engenharia explícita: a geometria resolvida passa a ser a autoridade - # desta sessão e o relatório obriga atualização posterior do MP. - output_space = ( - DEFAULT_ORIENTED_COORDINATE_SPACE - if enabled - else input_space - ) - - else: - output_space = ( - DEFAULT_ORIENTED_COORDINATE_SPACE - if enabled - else input_space - ) - - if not enabled and output_space != input_space: - raise RuntimeError( - "Orientação identidade exige output_space == input_space." - ) - - if enabled and output_space == input_space: - raise RuntimeError( - "camera_orientation ativa exige output_space diferente do " - "input_space para não mascarar o domínio geométrico." - ) - + output_space = ( + DEFAULT_ORIENTED_COORDINATE_SPACE if any_transform else NATIVE_COORDINATE_SPACE + ) return { "schema": CAMERA_ORIENTATION_SCHEMA, - "enabled": enabled, - "input_space": input_space, + "enabled": bool(any_transform), + "input_space": NATIVE_COORDINATE_SPACE, "output_space": output_space, - "apply_stage": apply_stage, + "apply_stage": "after_native_flat_before_fusion", "by_role": resolved, - "source_module_params": bool( - has_mp_orientation - ), - "module_params_path": str( - args.module_params - ) if args.module_params else None, - "cli_override_used": bool( - cli_used - ), - "cli_diverged_from_module_params": bool( - cli_diverged_from_mp - ), - "requires_module_params_update": bool( - cli_diverged_from_mp - or ( - enabled - and not has_mp_orientation - ) - ), + "source": "module_profile.preview_orientation_defaults", + "bootstrap_only": True, + "becomes_final_runtime_orientation_when_promoted": True, } @@ -1243,7 +1134,7 @@ def discover_cameras(mx_id: Optional[str]): return rows, actual_mx, usb_speed -def validate_product_topology(camera_rows: list[dict]): +def validate_product_topology(camera_rows: list[dict], module_profile: dict): by_socket = { row["socket_name"]: row for row in camera_rows @@ -1251,9 +1142,10 @@ def validate_product_topology(camera_rows: list[dict]): errors = [] + signature = canonical_hardware_signature(module_profile["hardware_signature"]) for role in ROLES: - contract = PRODUCT_TOPOLOGY[role] - socket = contract["socket"] + expected = signature[role] + socket = expected["socket"] row = by_socket.get(socket) @@ -1263,20 +1155,25 @@ def validate_product_topology(camera_rows: list[dict]): ) continue - sensor = row["sensor_name"] - - if sensor not in contract["allowed_sensors"]: + sensor = str(row["sensor_name"]).upper() + if sensor != expected["sensor"]: errors.append( f"{role.upper()}: {socket} sensor={sensor}, " - f"esperado={contract['allowed_sensors']}" + f"esperado={expected['sensor']}" ) - if role == "rgb" and not row["is_color"]: + if row["width"] and row["height"] and [row["width"], row["height"]] != expected["size"]: + errors.append( + f"{role.upper()}: {socket}/{sensor} size={row['width']}x{row['height']}, " + f"esperado={expected['size'][0]}x{expected['size'][1]}" + ) + + if role == "rgb" and row["is_color"] is False: errors.append( f"RGB {socket}/{sensor} não anunciado COLOR" ) - if role in SPEC_ROLES and not row["is_mono"]: + if role in SPEC_ROLES and row["is_mono"] is False: errors.append( f"{role.upper()} {socket}/{sensor} não anunciado MONO" ) @@ -1293,7 +1190,7 @@ def validate_product_topology(camera_rows: list[dict]): } -def build_specs(camera_rows: list[dict]) -> Dict[str, CameraSpec]: +def build_specs(camera_rows: list[dict], module_profile: dict) -> Dict[str, CameraSpec]: by_socket = { row["socket_name"]: row for row in camera_rows @@ -1301,37 +1198,22 @@ def build_specs(camera_rows: list[dict]) -> Dict[str, CameraSpec]: specs = {} + signature = canonical_hardware_signature(module_profile["hardware_signature"]) + resolved = module_profile["resolved_setup"] for role in ROLES: - socket = PRODUCT_TOPOLOGY[role]["socket"] + expected = signature[role] + socket = expected["socket"] row = by_socket[socket] - sensor = row["sensor_name"] - - if role == "rgb": - cfg = RGB_SENSOR_MODES[sensor] - else: - cfg = MONO_SENSOR_MODES[sensor] - - if row["width"] and row["height"]: - if ( - row["width"], - row["height"], - ) != ( - cfg["width"], - cfg["height"], - ): - raise RuntimeError( - f"{socket}/{sensor}: dimensão anunciada " - f"{row['width']}x{row['height']} != " - f"{cfg['width']}x{cfg['height']}" - ) + sensor = expected["sensor"] + width, height = expected["size"] specs[role] = CameraSpec( role=role, socket_name=socket, sensor_name=sensor, - width=int(cfg["width"]), - height=int(cfg["height"]), - resolution_name=cfg["resolution_enum"], + width=int(width), + height=int(height), + resolution_name=str(resolved[role]["resolution_name"]), stream_name=f"hcal_{role}", control_name=f"hcal_ctrl_{role}", is_color=(role == "rgb"), @@ -1341,6 +1223,18 @@ def build_specs(camera_rows: list[dict]) -> Dict[str, CameraSpec]: return specs +def validate_profile_against_device(module_profile: dict, specs, actual_mx: str): + if str(actual_mx or "") != str(module_profile.get("device_mx_id") or ""): + raise RuntimeError( + "Câmera conectada não pertence ao Module Profile: " + f"esperado={module_profile.get('device_mx_id')!r}, detectado={actual_mx!r}." + ) + if canonical_hardware_signature(hardware_signature(specs)) != ( + canonical_hardware_signature(module_profile.get("hardware_signature")) + ): + raise RuntimeError("Assinatura do hardware conectado diverge do Module Profile.") + + def build_pipeline(specs: Dict[str, CameraSpec], args): pipeline = dai.Pipeline() @@ -4209,7 +4103,7 @@ def create_or_update_active( raise RuntimeError( f"Arquivo ativo existente usa schema " f"{active.get('schema')!r}, esperado {SCHEMA!r}. " - "Use outro --active-json ou arquive o antigo." + "Arquive o ativo incompatível declarado no Module Profile." ) if ( @@ -4243,6 +4137,17 @@ def create_or_update_active( "camera_orientation. Arquive/recalibre os perfis." ) + active_profile = active.get("module_profile_contract") + candidate_profile = candidate_report.get("module_profile_contract") + if not isinstance(active_profile, dict) or ( + active_profile.get("profile_sha256") + != candidate_profile.get("profile_sha256") + ): + raise RuntimeError( + "Homography ativa pertence a outra versão do Module Profile. " + "Não misture perfis de contrato diferentes no mesmo JSON ativo." + ) + else: active = { "schema": SCHEMA, @@ -4258,6 +4163,7 @@ def create_or_update_active( "profiles": {}, "recommended_profile": None, "module_params_fragment": {}, + "module_profile_contract": candidate_report["module_profile_contract"], } profile_name = args.profile_name @@ -4272,6 +4178,8 @@ def create_or_update_active( active["camera_orientation_signature"] = ( orientation_sig ) + active["module_profile_contract"] = candidate_report["module_profile_contract"] + active["prerequisites"] = candidate_report.get("prerequisites", {}) active["profiles"][profile_name] = ( profile_payload ) @@ -4326,7 +4234,7 @@ def main(): parser = argparse.ArgumentParser( description=( "Homography calibration de produção por ChArUco " - "para OAK-FFC-3P OV9782/AR0234 + 2x OV9282." + "para o hardware declarado pelo Module Profile." ), formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) @@ -4363,9 +4271,24 @@ def main(): ) # Hardware + parser.add_argument( + "--module-profile", + default=None, + help=( + "Module Profile explícito. Se omitido, usa --active-profile." + ), + ) + + parser.add_argument( + "--active-profile", + default="calibration/active_module_profile.json", + help="Seletor criado pelo Script 0.", + ) + parser.add_argument( "--mx-id", default=None, + help="Somente verificação; a identidade vem do Module Profile.", ) parser.add_argument( @@ -4374,49 +4297,6 @@ def main(): default=20.0, ) - parser.add_argument( - "--module-params", - default="calibration/module_params.json", - help=( - "Opcional, mas recomendado em produto. Lê camera_orientation " - "para garantir que a Homography nasce no mesmo espaço do runtime." - ), - ) - - # Aqui a orientação é CIENTÍFICA, não apenas preview. - for role in ROLES: - parser.add_argument( - f"--{role}-rotate-deg", - type=int, - default=None, - choices=[0, 90, 180, 270], - help=( - "Override da orientação pré-Homography. " - "Divergência do MP é bloqueada por padrão." - ), - ) - - parser.add_argument( - f"--{role}-flip-horizontal", - action="store_true", - help="Flip horizontal CIENTÍFICO antes do ChArUco.", - ) - - parser.add_argument( - f"--{role}-flip-vertical", - action="store_true", - help="Flip vertical CIENTÍFICO antes do ChArUco.", - ) - - parser.add_argument( - "--allow-orientation-override", - action="store_true", - help=( - "Engenharia: permite CLI divergir de camera_orientation do MP. " - "O relatório marcará que o module_params precisa ser atualizado." - ), - ) - parser.add_argument( "--isp-sharpness", type=int, @@ -4521,23 +4401,7 @@ def main(): default=0.45, ) - # Output - parser.add_argument( - "--candidate-root", - default="calibration/homography_candidates", - ) - - parser.add_argument( - "--active-json", - default="calibration/homography_calibration_v4.json", - ) - # Rastreabilidade - parser.add_argument( - "--module-id", - default="", - ) - parser.add_argument( "--operator", default="", @@ -4552,9 +4416,25 @@ def main(): qa = dict(DEFAULT_QA) + profile_contract = load_module_profile_contract( + args.module_profile, + args.active_profile, + ) + module_profile = profile_contract["profile"] + if args.mx_id and str(args.mx_id) != str(module_profile["device_mx_id"]): + raise RuntimeError( + f"--mx-id={args.mx_id!r} diverge do Module Profile " + f"({module_profile['device_mx_id']!r})." + ) + + prerequisites = load_homography_prerequisites(profile_contract) + artifacts = module_profile["artifact_paths"] + candidate_root = resolve_output_path(artifacts["candidate_roots"]["homography"]) + active_json = resolve_output_path(artifacts["homography_json"]) + sid = session_stamp() candidate_dir = ( - Path(args.candidate_root) + candidate_root / sid ) @@ -4600,7 +4480,6 @@ def main(): profile_eval = None actual_mx = None usb_speed = None - module_params = {} orientation_contract = {} calibration_coordinate_space = ( NATIVE_COORDINATE_SPACE @@ -4625,30 +4504,25 @@ def main(): actual_mx, usb_speed, ) = discover_cameras( - args.mx_id + module_profile["device_mx_id"] ) topology = validate_product_topology( - camera_rows + camera_rows, + module_profile, ) specs = build_specs( - camera_rows + camera_rows, + module_profile, ) - module_params = ( - load_json( - args.module_params - ) - if args.module_params - else {} - ) + validate_profile_against_device(module_profile, specs, actual_mx) orientation_contract = ( resolve_orientation_contract( specs, - args, - module_params=module_params, + module_profile, ) ) @@ -4662,6 +4536,7 @@ def main(): print("HOMOGRAPHY CALIBRATION - PRODUCTION") print(f"Profile : {args.profile_name}") print(f"Depth : {args.depth_cm} cm") + print(f"Module : {module_profile['profile_name']}") print(f"MX ID : {actual_mx}") print(f"USB : {usb_speed}") print("-" * 88) @@ -4755,6 +4630,8 @@ def main(): "hardware_signature": hardware_signature( specs ), + "module_profile_contract": module_profile_provenance(profile_contract), + "prerequisites": prerequisites, "native_coordinate_space": ( NATIVE_COORDINATE_SPACE ), @@ -4851,7 +4728,8 @@ def main(): "isp_measurement_settings": isp_settings, "qa_thresholds": qa, "traceability": { - "module_id": args.module_id or None, + "module_id": module_profile.get("module_id"), + "module_profile_name": module_profile["profile_name"], "operator": args.operator or None, }, "notes": args.notes or "", @@ -4863,20 +4741,9 @@ def main(): "candidate_dir": str( candidate_dir ), - "active_json": args.active_json, - "module_params_orientation_source": ( - args.module_params - if orientation_contract.get( - "source_module_params" - ) - else None - ), - "requires_module_params_update": bool( - orientation_contract.get( - "requires_module_params_update", - False, - ) - ), + "active_json": str(active_json), + "configuration_source": "module_profile_only", + "module_params_read": False, } with device_ctx as device: @@ -5641,6 +5508,17 @@ def main(): "locked_controls" ], "source_session_id": sid, + "module_profile_contract": module_profile_provenance( + profile_contract + ), + "prerequisite_hashes": { + name: { + key: value + for key, value in item.items() + if key in {"sha256", "json_sha256", "npz_sha256", "status"} + } + for name, item in prerequisites.items() + }, } report[ @@ -5679,7 +5557,7 @@ def main(): active = ( create_or_update_active( Path( - args.active_json + active_json ), report, profile_payload, @@ -5691,7 +5569,7 @@ def main(): report[ "active_json_sha256" ] = sha256_file( - args.active_json + active_json ) save_json_atomic( @@ -5717,7 +5595,7 @@ def main(): f"depth_cm={args.depth_cm}\n" f"coordinate_space={calibration_coordinate_space}\n" f"rgb_rotate_deg={orientation_contract['by_role']['rgb']['rotate_deg']}\n" - f"active_json={args.active_json}\n" + f"active_json={active_json}\n" ), encoding="utf-8", ) @@ -5731,7 +5609,7 @@ def main(): f"[PROFILE] {args.profile_name} @ {args.depth_cm} cm" ) print( - f"[ACTIVE] {args.active_json}" + f"[ACTIVE] {active_json}" ) print( f"[CANDIDATE] {candidate_dir}" @@ -5762,7 +5640,7 @@ def main(): f"NIR CV med={nir_model['cross_validation']['overall']['median_px']:.2f}px", f"NIR overlap={nir_model['overlap_fraction']*100:.1f}%", "", - f"Active: {args.active_json}", + f"Active: {active_json}", "", "Qualquer tecla fecha.", ] diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py b/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py index badbda043..fc75532fe 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """ -module_params_assembler_production.py +_8_build_module_params.py ===================================== Assembler FINAL de produção do module_params do módulo multiespectral. @@ -22,42 +22,10 @@ Ele somente: Linha oficial de entrada ------------------------ - Focus: - calibration/focus_qc_active.json - - Flat-Field: - calibration/flatfield_maps_v1.json - calibration/flatfield_maps_v1.npz - - Radiometry: - calibration/radiometry_calibration_v5.json - - Camera Startup: - calibration/camera_startup_profile_v3.json - - Intrinsics: - calibration/intrinsics_calibration_v1.json - - Homography: - calibration/homography_calibration_v4.json - - Runtime base opcional: - calibration/module_params.json - -O runtime base NÃO é fonte de calibração. -Ele só pode fornecer políticas não calibradas, por exemplo: - - onnx_model_path - - frame_type / capture mode / raw policy - - rgb_processing - - alguns knobs de fusion runtime: - use_remap_cache - use_remap_for_rgb - use_remap_for_spec - crop_valid_common - resize_after_crop - target_size - -Todo bloco calibrado é substituído pelos artefatos homologados. +O único ponto de entrada é o Module Profile ativo criado pelo Script 0. +Hardware, RGB decode, políticas runtime permitidas, os seis artefatos +homologados e os destinos finais vêm de seu contrato. O assembler nunca usa +um module_params anterior como fonte e não mantém caminhos/topologias paralelos. Política final -------------- @@ -92,10 +60,11 @@ Para AR0234: RE/NIR permanecem explicitamente registrados em: sensor_size_by_role -Bayer ------ -O bayer_pattern root é obtido do Flat-Field de produção, que já resolveu -o Bayer da câmera RGB durante a calibração. +Bayer / decode RGB +------------------ +bayer_pattern, rgb_processing.mode e demosaic_algorithm vêm exclusivamente +de Module Profile.rgb_decode. O Flat-Field é validado contra esse contrato, +mas não é autoridade de configuração. Fail-closed ----------- @@ -119,19 +88,19 @@ O assembler ABORTA se: Uso normal ---------- - python module_params_assembler_production.py + python -m calibration._8_build_module_params Selecionando perfil de homografia: - python module_params_assembler_production.py ^ + python -m calibration._8_build_module_params ^ --homography-profile media Somente auditoria: - python module_params_assembler_production.py --check-only + python -m calibration._8_build_module_params --check-only Saídas ------ - calibration/module_params.json - calibration/module_params_assembly_report.json + artifact_paths.module_params_json + artifact_paths.module_params_assembly_report_json """ from __future__ import annotations @@ -154,6 +123,8 @@ from typing import Any, Optional FINAL_SCHEMA = "multispec_module_params_v3" ASSEMBLER_SCHEMA = "multispec_module_params_assembly_v1" +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" EXPECTED_SCHEMAS = { "focus": "multispec_focus_qc_v3", @@ -166,27 +137,7 @@ EXPECTED_SCHEMAS = { ROLES = ("rgb", "re", "nir") CHANNELS = ("R", "G", "B", "RE", "NIR") - -EXPECTED_TOPOLOGY = { - "rgb": { - "socket": "CAM_A", - "allowed_sensors": ("OV9782", "AR0234"), - }, - "re": { - "socket": "CAM_B", - "allowed_sensors": ("OV9282",), - }, - "nir": { - "socket": "CAM_C", - "allowed_sensors": ("OV9282",), - }, -} - -EXPECTED_NATIVE_SIZES = { - "OV9782": [1280, 800], - "AR0234": [1920, 1200], - "OV9282": [1280, 800], -} +BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") CALIBRATION_SPACE_NATIVE = "native_stream_no_external_undistort" CALIBRATION_SPACE_ORIENTED = ( @@ -565,6 +516,178 @@ def normalize_signature( return out +def resolve_existing_path( + value: str | Path, + anchor: Optional[Path] = None, +) -> Path: + raw = Path(value).expanduser() + candidates = [raw] + if not raw.is_absolute() and anchor is not None: + candidates.append(Path(anchor).parent / raw) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + tried = ", ".join(str(x) for x in candidates) + raise RuntimeError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def resolve_output_path(value: str | Path) -> Path: + """artifact_paths do Script 0 são relativos à raiz do projeto.""" + return Path(value).expanduser() + + +def validate_module_profile(profile: dict, profile_path: Path): + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema de Module Profile inesperado em {profile_path}: " + f"{profile.get('schema')!r}" + ) + if str(profile.get("status", "")).lower() != "active": + raise RuntimeError(f"Module Profile não está ativo: {profile.get('status')!r}") + for key in ("profile_name", "profile_dir", "device_mx_id"): + if not str(profile.get(key) or "").strip(): + raise RuntimeError(f"Module Profile sem {key}.") + + signature = normalize_signature(profile.get("hardware_signature")) + resolved = profile.get("resolved_setup") + if not isinstance(resolved, dict): + raise RuntimeError("Module Profile sem resolved_setup.") + for role in ROLES: + item = resolved.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"resolved_setup sem role {role}.") + if normalize_role_item(item) != signature[role]: + raise RuntimeError(f"resolved_setup/{role} diverge do hardware_signature.") + if not str(item.get("resolution_name") or "").strip(): + raise RuntimeError(f"resolved_setup/{role}/resolution_name ausente.") + + decode = profile.get("rgb_decode") + if not isinstance(decode, dict): + raise RuntimeError("Module Profile sem rgb_decode.") + bayer = str(decode.get("bayer_pattern") or "").upper() + mode = str(decode.get("mode") or "").lower() + algorithm = str(decode.get("demosaic_algorithm") or "").lower() + if bayer not in BAYER_PATTERNS: + raise RuntimeError(f"rgb_decode.bayer_pattern inválido: {bayer!r}") + if mode not in {"linear_demosaic", "linear_demosaic_half", "bayer_planes"}: + raise RuntimeError(f"rgb_decode.mode inválido: {mode!r}") + if algorithm not in {"ea", "bilinear"}: + raise RuntimeError(f"rgb_decode.demosaic_algorithm inválido: {algorithm!r}") + + orientations = profile.get("preview_orientation_defaults") + if not isinstance(orientations, dict): + raise RuntimeError("Module Profile sem preview_orientation_defaults.") + for role in ROLES: + item = orientations.get(role) + if not isinstance(item, dict): + raise RuntimeError(f"Orientação bootstrap ausente para {role}.") + try: + rotate = int(item.get("rotate_deg")) % 360 + except Exception as exc: + raise RuntimeError(f"rotate_deg bootstrap inválido em {role}.") from exc + if rotate not in (0, 90, 180, 270): + raise RuntimeError(f"rotate_deg bootstrap inválido em {role}: {rotate}") + for key in ("flip_horizontal", "flip_vertical"): + if not isinstance(item.get(key), bool): + raise RuntimeError(f"{key} bootstrap deve ser bool em {role}.") + + artifacts = profile.get("artifact_paths") + if not isinstance(artifacts, dict): + raise RuntimeError("Module Profile sem artifact_paths.") + for key in ( + "focus_qc_json", "intrinsics_json", "flatfield_npz", "flatfield_json", + "radiometry_json", "camera_startup_json", "homography_json", + "module_params_json", "module_params_assembly_report_json", + ): + if not artifacts.get(key): + raise RuntimeError(f"Module Profile sem artifact_paths.{key}.") + + +def load_module_profile_contract( + module_profile_arg: Optional[str], + active_selector_arg: str, +) -> dict: + selector = None + selector_path = None + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + try: + selector_path = resolve_existing_path(active_selector_arg) + except RuntimeError as exc: + raise RuntimeError( + "Active Module Profile não encontrado. Execute primeiro o " + "Script 0 ou informe --module-profile." + ) from exc + selector = load_json(selector_path) + if selector.get("schema") != ACTIVE_SELECTOR_SCHEMA: + raise RuntimeError( + f"Schema do seletor ativo inesperado: {selector.get('schema')!r}" + ) + declared = selector.get("profile_path") + if not declared: + raise RuntimeError("Seletor ativo sem profile_path.") + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json(profile_path) + validate_module_profile(profile, profile_path) + profile_sha = sha256_file(profile_path) + if selector is not None: + declared_sha = str(selector.get("profile_sha256") or "").lower() + if declared_sha and declared_sha != profile_sha.lower(): + raise RuntimeError( + "Hash do Module Profile diverge do active selector. " + "Execute novamente o Script 0." + ) + if str(selector.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError("MX ID do selector diverge do Module Profile.") + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha, + "selector": selector, + "selector_path": selector_path, + } + + +def module_profile_provenance(contract: dict) -> dict: + profile = contract["profile"] + return { + "profile_name": profile["profile_name"], + "profile_path": norm_path(contract["profile_path"]), + "profile_sha256": contract["profile_sha256"], + "device_mx_id": profile["device_mx_id"], + "selector_path": ( + None if contract["selector_path"] is None + else norm_path(contract["selector_path"]) + ), + } + + +def validate_artifact_profile_contract( + name: str, + data: dict, + contract: dict, +): + provenance = data.get("module_profile_contract") + if not isinstance(provenance, dict): + raise RuntimeError( + f"{name}: artefato sem module_profile_contract. " + "Rode novamente a etapa revisada." + ) + profile = contract["profile"] + if provenance.get("profile_sha256") != contract["profile_sha256"]: + raise RuntimeError(f"{name}: outra versão do Module Profile.") + if provenance.get("profile_name") != profile["profile_name"]: + raise RuntimeError(f"{name}: pertence a outro profile.") + if str(provenance.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError(f"{name}: MX ID diverge do Module Profile.") + if normalize_signature(data.get("hardware_signature")) != normalize_signature( + profile.get("hardware_signature") + ): + raise RuntimeError(f"{name}: hardware diverge do Module Profile.") + + def signature_from_resolved_setup( resolved_setup: dict, ) -> dict: @@ -606,55 +729,6 @@ def signature_from_direct( ) -def validate_product_signature( - signature: dict, -): - for role in ROLES: - item = signature[ - role - ] - - contract = EXPECTED_TOPOLOGY[ - role - ] - - if ( - item["socket"] - != contract["socket"] - ): - raise RuntimeError( - f"{role}: socket={item['socket']}, " - f"esperado={contract['socket']}" - ) - - if ( - item["sensor"] - not in contract[ - "allowed_sensors" - ] - ): - raise RuntimeError( - f"{role}: sensor={item['sensor']}, " - f"permitidos={contract['allowed_sensors']}" - ) - - expected_size = ( - EXPECTED_NATIVE_SIZES[ - item["sensor"] - ] - ) - - if ( - item["size"] - != expected_size - ): - raise RuntimeError( - f"{role}/{item['sensor']}: " - f"size={item['size']}, " - f"esperado={expected_size}" - ) - - def compare_signatures( named_signatures: dict[str, dict], ) -> dict: @@ -700,10 +774,6 @@ def compare_signatures( f"Divergências: {mismatches}" ) - validate_product_signature( - reference - ) - return reference @@ -936,6 +1006,15 @@ def validate_flatfield( f"Flat-Field NPZ não encontrado: {npz_path}" ) + npz_sha256 = sha256_file(npz) + declared_sha256 = str(data.get("candidate_sha256") or "").lower() + if not declared_sha256: + raise RuntimeError("Flat-Field ativo não registra candidate_sha256.") + if declared_sha256 != npz_sha256.lower(): + raise RuntimeError( + "Hash do Flat-Field NPZ diverge do JSON homologado." + ) + required_keys = { f"gain_{ch}" for ch in CHANNELS @@ -1137,6 +1216,13 @@ def validate_radiometry( # O affine_v2 exige um offset preto para cada câmera. if method == "oak_ae_frame_controls_affine_v2": + if str(cfg.get("black_offset_model") or "").lower() != ( + "per_role_scalar_raw01" + ): + raise RuntimeError( + "Radiometry affine_v2 exige " + "black_offset_model='per_role_scalar_raw01'." + ) offsets = ( cfg.get("black_offset_by_role", {}) or {} @@ -1171,6 +1257,11 @@ def validate_radiometry( f"factor_model inesperado: {cfg.get('factor_model')}" ) + if str(cfg.get("apply_stage") or "").lower() != "after_dark_before_flat_gain": + raise RuntimeError( + f"apply_stage radiométrico inesperado: {cfg.get('apply_stage')!r}" + ) + refs = ( cfg.get( "reference_controls", @@ -1694,7 +1785,7 @@ def validate_camera_orientation_from_homography( - logo seu camera_orientation_signature define a transformação que o runtime precisa reproduzir antes de H. - O runtime-base antigo NUNCA é fonte desta calibração. + Um module_params anterior NUNCA é fonte desta calibração. """ signature = homography_data.get( "camera_orientation_signature" @@ -2501,18 +2592,31 @@ def validate_geometry_contract( # ============================================================ -# Runtime base, whitelist only +# Runtime policy rooted in Module Profile # ============================================================ -def runtime_defaults() -> dict: - return { +def runtime_policy_from_module_profile(profile: dict) -> dict: + """ + Configuração por módulo vem apenas do Module Profile. Os valores abaixo + são invariantes do contrato de produto do assembler, não inferências de + sensor nem herança de um module_params anterior. + """ + decode = profile["rgb_decode"] + declared = profile.get("runtime_policy") + if declared is None: + declared = {} + if not isinstance(declared, dict): + raise RuntimeError("Module Profile runtime_policy deve ser objeto.") + + out = { "onnx_model_path": None, "frame_type": "RAW_BRUTO", "capture_mode_requested": "AUTO", "capture_mode_effective": "AUTO", "raw_policy": "allow_single", "rgb_processing": { - "mode": "bayer_planes", + "mode": str(decode["mode"]).lower(), + "demosaic_algorithm": str(decode["demosaic_algorithm"]).lower(), }, "fusion_runtime": { "use_remap_cache": True, @@ -2524,29 +2628,6 @@ def runtime_defaults() -> dict: }, } - -def read_runtime_policy( - base: dict, -) -> dict: - """ - Extrai SOMENTE campos que não são calibração. - - `camera_orientation` é deliberadamente ignorada aqui. - Sua autoridade final vem da Homography homologada, pois H foi medida - no espaço já orientado. - """ - defaults = runtime_defaults() - - if not isinstance( - base, - dict, - ): - return defaults - - out = deepcopy( - defaults - ) - for key in ( "onnx_model_path", "frame_type", @@ -2554,36 +2635,25 @@ def read_runtime_policy( "capture_mode_effective", "raw_policy", ): - if key in base: - out[ - key - ] = deepcopy( - base[ - key - ] - ) + if key in declared: + out[key] = deepcopy(declared[key]) - if isinstance( - base.get( - "rgb_processing" - ), - dict, - ): - out[ - "rgb_processing" - ] = deepcopy( - base[ - "rgb_processing" - ] + rgb_declared = declared.get("rgb_processing", {}) or {} + if not isinstance(rgb_declared, dict): + raise RuntimeError("runtime_policy.rgb_processing deve ser objeto.") + out["rgb_processing"] = deep_merge(out["rgb_processing"], rgb_declared) + if str(out["rgb_processing"].get("mode") or "").lower() != str(decode["mode"]).lower(): + raise RuntimeError("runtime_policy.rgb_processing.mode diverge de rgb_decode.mode.") + if str(out["rgb_processing"].get("demosaic_algorithm") or "").lower() != str( + decode["demosaic_algorithm"] + ).lower(): + raise RuntimeError( + "runtime_policy.rgb_processing.demosaic_algorithm diverge de rgb_decode." ) - old_fusion = ( - base.get( - "fusion_config", - {} - ) - or {} - ) + fusion_declared = declared.get("fusion_runtime", {}) or {} + if not isinstance(fusion_declared, dict): + raise RuntimeError("runtime_policy.fusion_runtime deve ser objeto.") for key in ( "use_remap_cache", @@ -2593,68 +2663,41 @@ def read_runtime_policy( "resize_after_crop", "target_size", ): - if key in old_fusion: - out[ - "fusion_runtime" - ][ - key - ] = deepcopy( - old_fusion[ - key - ] - ) + if key in fusion_declared: + out["fusion_runtime"][key] = deepcopy(fusion_declared[key]) return out # ============================================================ -# Bayer from Flat-Field +# RGB decode from Module Profile # ============================================================ -def resolve_bayer_from_flatfield( - flatfield: dict, -) -> str: - setup = ( - flatfield.get( - "resolved_setup", - {} - ) - or {} - ) - - rgb = ( - setup.get( - "rgb", - {} - ) - or {} - ) - - bayer = rgb.get( - "bayer_pattern" - ) - - if not bayer: - raise RuntimeError( - "Flat-Field homologado não registra RGB bayer_pattern. " - "Rode novamente o Flat-Field de produção." - ) - - bayer = str( - bayer - ).upper() - - if bayer not in ( - "RGGB", - "BGGR", - "GRBG", - "GBRG", - ): - raise RuntimeError( - f"Bayer inválido no Flat-Field: {bayer!r}" - ) - - return bayer +def validate_rgb_decode_chain(profile: dict, artifacts: dict[str, dict]) -> dict: + expected = { + "bayer_pattern": str(profile["rgb_decode"]["bayer_pattern"]).upper(), + "mode": str(profile["rgb_decode"]["mode"]).lower(), + "demosaic_algorithm": str( + profile["rgb_decode"]["demosaic_algorithm"] + ).lower(), + } + for name, data in artifacts.items(): + declared = data.get("rgb_decode_contract") or data.get("rgb_decode_provenance") + if not isinstance(declared, dict): + continue + actual = { + "bayer_pattern": str(declared.get("bayer_pattern") or "").upper(), + "mode": str(declared.get("mode") or "").lower(), + "demosaic_algorithm": str( + declared.get("demosaic_algorithm") or "" + ).lower(), + } + if actual != expected: + raise RuntimeError( + f"{name}: rgb_decode diverge do Module Profile: " + f"atual={actual}, esperado={expected}" + ) + return expected # ============================================================ @@ -2718,6 +2761,9 @@ def build_final_module_params( result = { "schema": FINAL_SCHEMA, "saved_at": now_str(), + "module_profile_contract": deepcopy( + provenance["module_profile_contract"] + ), # Runtime/model policy "onnx_model_path": runtime_policy.get( @@ -2922,6 +2968,7 @@ def validate_final_module_params( "patch_normalization", "rgb_calibration", "flatfield_config", + "module_profile_contract", ) missing = [ @@ -2945,6 +2992,12 @@ def validate_final_module_params( "Schema final inválido." ) + profile_contract = mp.get("module_profile_contract") + if not isinstance(profile_contract, dict) or not profile_contract.get( + "profile_sha256" + ): + raise RuntimeError("module_params final sem vínculo ao Module Profile.") + if not is_positive_int( mp[ "sensor_width" @@ -2958,6 +3011,20 @@ def validate_final_module_params( "sensor_width/height inválidos." ) + if str(mp.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS: + raise RuntimeError("bayer_pattern final inválido.") + rgb_processing = mp.get("rgb_processing") + if not isinstance(rgb_processing, dict): + raise RuntimeError("rgb_processing final inválido.") + if str(rgb_processing.get("mode") or "").lower() not in { + "linear_demosaic", "linear_demosaic_half", "bayer_planes" + }: + raise RuntimeError("rgb_processing.mode final inválido.") + if str(rgb_processing.get("demosaic_algorithm") or "").lower() not in { + "ea", "bilinear" + }: + raise RuntimeError("rgb_processing.demosaic_algorithm final inválido.") + for role in ROLES: if role not in mp[ "camera_settings" @@ -3315,42 +3382,15 @@ def main(): ) parser.add_argument( - "--focus-json", - default="calibration/focus_qc_active.json", - ) - - parser.add_argument( - "--flatfield-json", - default="calibration/flatfield_maps_v1.json", - ) - - parser.add_argument( - "--flatfield-npz", + "--module-profile", default=None, - help=( - "Override opcional do NPZ. Normalmente é obtido " - "do próprio flatfield JSON." - ), + help="Module Profile explícito. Se omitido, usa --active-profile.", ) parser.add_argument( - "--radiometry-json", - default="calibration/radiometry_calibration_v5.json", - ) - - parser.add_argument( - "--startup-json", - default="calibration/camera_startup_profile_v3.json", - ) - - parser.add_argument( - "--intrinsics-json", - default="calibration/intrinsics_calibration_v1.json", - ) - - parser.add_argument( - "--homography-json", - default="calibration/homography_calibration_v4.json", + "--active-profile", + default="calibration/active_module_profile.json", + help="Seletor criado pelo Script 0.", ) parser.add_argument( @@ -3362,25 +3402,6 @@ def main(): ), ) - parser.add_argument( - "--runtime-base-json", - default="calibration/module_params.json", - help=( - "Fonte SOMENTE de políticas runtime não calibradas. " - "Se inexistente, usa defaults seguros." - ), - ) - - parser.add_argument( - "--out", - default="calibration/module_params.json", - ) - - parser.add_argument( - "--report", - default="calibration/module_params_assembly_report.json", - ) - parser.add_argument( "--check-only", action="store_true", @@ -3389,32 +3410,73 @@ def main(): args = parser.parse_args() + profile_contract = load_module_profile_contract( + args.module_profile, + args.active_profile, + ) + module_profile = profile_contract["profile"] + declared_paths = module_profile["artifact_paths"] + artifact_paths = { + "focus": resolve_existing_path( + declared_paths["focus_qc_json"], anchor=profile_contract["profile_path"] + ), + "flatfield": resolve_existing_path( + declared_paths["flatfield_json"], anchor=profile_contract["profile_path"] + ), + "flatfield_npz": resolve_existing_path( + declared_paths["flatfield_npz"], anchor=profile_contract["profile_path"] + ), + "radiometry": resolve_existing_path( + declared_paths["radiometry_json"], anchor=profile_contract["profile_path"] + ), + "startup": resolve_existing_path( + declared_paths["camera_startup_json"], anchor=profile_contract["profile_path"] + ), + "intrinsics": resolve_existing_path( + declared_paths["intrinsics_json"], anchor=profile_contract["profile_path"] + ), + "homography": resolve_existing_path( + declared_paths["homography_json"], anchor=profile_contract["profile_path"] + ), + } + output_path = resolve_output_path(declared_paths["module_params_json"]) + assembly_report_path = resolve_output_path( + declared_paths["module_params_assembly_report_json"] + ) + output_abs = output_path.resolve() + report_abs = assembly_report_path.resolve() + input_abs = {path.resolve() for path in artifact_paths.values()} + if output_abs == report_abs: + raise RuntimeError("module_params_json e assembly_report_json são o mesmo arquivo.") + if output_abs in input_abs or report_abs in input_abs: + raise RuntimeError("Saída do assembler colide com um artefato de calibração.") + # -------------------------------------------------------- # Load all official artifacts # -------------------------------------------------------- focus = load_json( - args.focus_json + artifact_paths["focus"] ) flatfield = load_json( - args.flatfield_json + artifact_paths["flatfield"] ) radiometry = load_json( - args.radiometry_json + artifact_paths["radiometry"] ) startup = load_json( - args.startup_json + artifact_paths["startup"] ) intrinsics = load_json( - args.intrinsics_json + artifact_paths["intrinsics"] ) homography = load_json( - args.homography_json + artifact_paths["homography"] ) artifacts = { @@ -3426,6 +3488,13 @@ def main(): "homography": homography, } + for name, data in artifacts.items(): + validate_artifact_profile_contract( + name, + data, + profile_contract, + ) + # -------------------------------------------------------- # Individual validation # -------------------------------------------------------- @@ -3437,8 +3506,8 @@ def main(): flatfield_cfg, flatfield_npz = ( validate_flatfield( flatfield, - args.flatfield_json, - args.flatfield_npz, + artifact_paths["flatfield"], + str(artifact_paths["flatfield_npz"]), ) ) @@ -3499,6 +3568,11 @@ def main(): signatures ) ) + profile_signature = normalize_signature(module_profile["hardware_signature"]) + if common_signature != profile_signature: + raise RuntimeError( + "Hardware dos artefatos diverge do Module Profile ativo." + ) camera_orientation = ( validate_camera_orientation_from_homography( @@ -3507,26 +3581,47 @@ def main(): common_signature, ) ) + for role in ROLES: + bootstrap = module_profile["preview_orientation_defaults"][role] + final_role = camera_orientation["by_role"][role] + expected = { + "rotate_deg": int(bootstrap["rotate_deg"]) % 360, + "flip_horizontal": bool(bootstrap["flip_horizontal"]), + "flip_vertical": bool(bootstrap["flip_vertical"]), + } + actual = {key: final_role[key] for key in expected} + if actual != expected: + raise RuntimeError( + f"Homography orientation/{role} diverge do bootstrap do " + f"Module Profile: atual={actual}, esperado={expected}" + ) mx_ids = extract_mx_ids( artifacts ) - common_mx = validate_single_value( + artifact_mx = validate_single_value( mx_ids, "MX ID", ) + common_mx = str(module_profile["device_mx_id"]) + if artifact_mx and artifact_mx != common_mx: + raise RuntimeError( + f"MX ID dos artefatos={artifact_mx!r} diverge do profile={common_mx!r}." + ) module_ids = extract_module_ids( artifacts ) - common_module_id = ( - validate_single_value( - module_ids, - "module_id", - ) - ) + artifact_module_id = validate_single_value(module_ids, "module_id") + common_module_id = str(module_profile.get("module_id") or "") or artifact_module_id + if ( + artifact_module_id + and common_module_id + and artifact_module_id != common_module_id + ): + raise RuntimeError("module_id dos artefatos diverge do Module Profile.") # -------------------------------------------------------- # Cross-contract checks @@ -3589,30 +3684,20 @@ def main(): ) # -------------------------------------------------------- - # Runtime policy whitelist + # Runtime policy + RGB decode: autoridade exclusiva do Module Profile # -------------------------------------------------------- - runtime_base = load_json( - args.runtime_base_json, - required=False, - ) - - runtime_policy = ( - read_runtime_policy( - runtime_base - ) - ) - - # Bayer comes from production Flat-Field hardware resolution. - bayer = resolve_bayer_from_flatfield( - flatfield - ) + runtime_policy = runtime_policy_from_module_profile(module_profile) + rgb_decode = validate_rgb_decode_chain(module_profile, artifacts) + bayer = rgb_decode["bayer_pattern"] # -------------------------------------------------------- # Provenance # -------------------------------------------------------- provenance = { + "module_profile_contract": module_profile_provenance(profile_contract), + "configuration_source": "module_profile_only", "hardware_signature": deepcopy( common_signature ), @@ -3648,12 +3733,12 @@ def main(): }, "artifacts": { "focus": artifact_meta( - args.focus_json, + artifact_paths["focus"], focus, ), "flatfield": { **artifact_meta( - args.flatfield_json, + artifact_paths["flatfield"], flatfield, ), "npz_path": norm_path( @@ -3664,20 +3749,20 @@ def main(): ), }, "radiometry": artifact_meta( - args.radiometry_json, + artifact_paths["radiometry"], radiometry, ), "startup": artifact_meta( - args.startup_json, + artifact_paths["startup"], startup, ), "intrinsics": artifact_meta( - args.intrinsics_json, + artifact_paths["intrinsics"], intrinsics, ), "homography": { **artifact_meta( - args.homography_json, + artifact_paths["homography"], homography, ), "selected_profile": ( @@ -3705,7 +3790,7 @@ def main(): camera_orientation=camera_orientation, fusion_config=fusion_cfg, provenance=provenance, - assembly_report_path=args.report, + assembly_report_path=str(assembly_report_path), ) validate_final_module_params( @@ -3720,14 +3805,12 @@ def main(): args.check_only ), "output": norm_path( - args.out - ), - "runtime_base_json": norm_path( - args.runtime_base_json - ), - "runtime_base_found": bool( - runtime_base + output_path ), + "module_profile_contract": module_profile_provenance(profile_contract), + "configuration_source": "module_profile_only", + "previous_module_params_read": False, + "rgb_decode_contract": deepcopy(rgb_decode), "hardware_signature": ( common_signature ), @@ -3752,6 +3835,9 @@ def main(): "runtime_policy": runtime_policy, "calibration_provenance": provenance, "final_checks": { + "module_profile_contract": "pass", + "artifact_profile_hashes": "pass", + "rgb_decode_contract": "pass", "focus_gate": "pass", "flatfield": "pass", "radiometry": "pass", @@ -3761,6 +3847,7 @@ def main(): "hardware_consistency": "pass", "geometry_consistency": "pass", "camera_orientation": "pass", + "orientation_bootstrap_consistency": "pass", "startup_radiometry_consistency": "pass", "flatfield_npz_integrity": "pass", "module_params_structure": "pass", @@ -3773,6 +3860,8 @@ def main(): print("=" * 92) print("MODULE PARAMS ASSEMBLER - PRODUCTION") + print(f"Profile : {module_profile['profile_name']}") + print(f"Profile SHA : {profile_contract['profile_sha256'][:16]}...") print(f"MX ID : {common_mx}") print(f"Module ID : {common_module_id}") print( @@ -3856,7 +3945,7 @@ def main(): # -------------------------------------------------------- out = Path( - args.out + output_path ) ensure_dir( @@ -3894,13 +3983,13 @@ def main(): ) save_json_atomic( - args.report, + assembly_report_path, report, ) print("-" * 92) print(f"[PASS] module_params : {out}") - print(f"[PASS] assembly report: {args.report}") + print(f"[PASS] assembly report: {assembly_report_path}") print(f"[SHA256] {report['output_sha256']}") print("=" * 92)