diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py index d38e6c6e1..d3c50e6ea 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py @@ -11,9 +11,6 @@ from .radiometric_controller import RadiometricController PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR") PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES) -OAK_FCC3_CLIENT_VERSION = "production_v1_2026_08_24" -PRODUCT_SCHEMA = "multispec_module_params_v3" -ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1" class OakFcc3Client: @@ -44,110 +41,83 @@ class OakFcc3Client: capture_mode="AUTO", raw_policy="allow_single", module_calibration_json=None, - sync_mode="best", - sync_tolerance_ms=25.0, mx_id=None, imu_modo="rotation_vector", imu_freq_hz=200, evaluate_quality=True, - require_product_contract=False, + + hardware_sync_enabled=None, + frame_sync_master=None, + sync_mode=None, + sync_tolerance_ms=None, + buffer_size=None, + **kwargs, ): - # width/height/bayer recebidos do caller ficam apenas como fallback legado. - # Em module_params de produção, hardware real + MP são a autoridade. - self.legacy_width = int(width) - self.legacy_height = int(height) - self.legacy_bayer = str(bayer or "BGGR").upper() - - self.fps = float(fps) - self.frame_type = str(frame_type).upper() - self.output_dtype = str(output_dtype).lower() - self.capture_mode = str(capture_mode).upper() - self.raw_policy = str(raw_policy).lower() + self.width = width + self.height = height + self.bayer = bayer + self.fps = fps + self.frame_type = frame_type + self.output_dtype = output_dtype + self.capture_mode = capture_mode + self.raw_policy = raw_policy self.module_calibration_json = module_calibration_json - self.require_product_contract = bool(require_product_contract) - - self.module_params = self._load_module_params( - module_calibration_json, - required=self.require_product_contract, - ) - self.product_contract = self._is_product_module_params(self.module_params) - - if self.require_product_contract and not self.product_contract: - raise RuntimeError( - "Contrato de produção obrigatório, mas module_params não foi " - "gerado pelo assembler oficial." - ) - + self.module_params = self._load_module_params(module_calibration_json) self.fusion_config = self.module_params.get("fusion_config", {}) or {} - self.camera_hardware = self._resolve_camera_hardware() - self.sensor_size_by_role = self._resolve_sensor_size_by_role() - - rgb_size = self.sensor_size_by_role.get( - "rgb", - [self.legacy_width, self.legacy_height], - ) - self.rgb_native_width = int(rgb_size[0]) - self.rgb_native_height = int(rgb_size[1]) - - # Mantidos por compatibilidade, mas agora significam RGB de referência. - self.width = self.rgb_native_width - self.height = self.rgb_native_height - - self.bayer = self._resolve_bayer_pattern() - self.default_target_size = self._resolve_default_target_size() self.imu_modo = str(imu_modo).strip().lower() self.imu_freq_hz = int(imu_freq_hz) + + # Auditoria radiométrica completa do Raw5. + # + # True mantém o comportamento histórico e é útil para captura científica, + # normalize/auditoria e ferramentas offline. + # + # No runtime em tempo real deve ficar False: evaluate_frame_quality() + # calcula estatísticas/percentis pesados e não faz parte da montagem + # necessária para a inferência. self.evaluate_quality = bool(evaluate_quality) self.mx_id = str(mx_id) if mx_id else None - # Validação antecipada do modo produto. O Manager também repete estes - # checks antes de abrir o hardware, de propósito. - self._validate_static_product_contract() - self.svc = OakFcc3Service( timeout=10, - fps=self.fps, - width=self.width, - height=self.height, - frame_type=self.frame_type, - output_dtype=self.output_dtype, - capture_mode=self.capture_mode, - raw_policy=self.raw_policy, - sync_mode=sync_mode, - sync_tolerance_ms=sync_tolerance_ms, + fps=fps, + width=width, + height=height, + frame_type=frame_type, + output_dtype=output_dtype, + capture_mode=capture_mode, + raw_policy=raw_policy, mx_id=self.mx_id, module_calibration_json=module_calibration_json, - module_params=self.module_params, - require_product_contract=self.require_product_contract, + imu_modo=self.imu_modo, imu_freq_hz=self.imu_freq_hz, + + hardware_sync_enabled=hardware_sync_enabled, + frame_sync_master=frame_sync_master, + sync_mode=sync_mode, + sync_tolerance_ms=sync_tolerance_ms, + buffer_size=buffer_size, + **kwargs, ) self.applied_camera_controls = {} self.radiometric_controller = None - self.radiometric_controller_enabled = False - self.core = RawProcessorCore( - sensor_width=self.rgb_native_width, - sensor_height=self.rgb_native_height, - bayer_pattern=self.bayer, + sensor_width=width, + sensor_height=height, + bayer_pattern=bayer, calibration_json_path=module_calibration_json, ) - - # Preview é apenas visual, porém também precisa usar o Bayer/raster - # reais do RGB para não mentir sobre a AR0234. self.preview = RawProcessorPreview( - sensor_width=self.rgb_native_width, - sensor_height=self.rgb_native_height, - bayer_pattern=self.bayer, + sensor_width=width, + sensor_height=height, + bayer_pattern=bayer, ) - self._preview_cache = { - (self.rgb_native_width, self.rgb_native_height, self.bayer): self.preview, - } def __enter__(self): self.start() @@ -156,247 +126,19 @@ class OakFcc3Client: def __exit__(self, exc_type, exc, tb): self.stop() - def _load_module_params(self, path, required=False): - if not path: - if required: - raise FileNotFoundError( - "module_params obrigatório no contrato de produção." - ) - return {} - - if not os.path.isfile(path): - if required: - raise FileNotFoundError( - f"module_params não encontrado: {path}" - ) + def _load_module_params(self, path): + if not path or not os.path.isfile(path): return {} with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - - if not isinstance(data, dict): - raise RuntimeError( - f"module_params root deve ser dict/object: {path}" - ) - - return data - - @staticmethod - def _is_product_module_params(module_params): - mp = module_params or {} - assembly = mp.get("assembly_metadata", {}) or {} - return bool( - mp.get("schema") == PRODUCT_SCHEMA - and assembly.get("schema") == ASSEMBLY_SCHEMA - and isinstance(mp.get("camera_hardware"), dict) - and isinstance(mp.get("sensor_size_by_role"), dict) - and isinstance(mp.get("calibration_provenance"), dict) - ) - - @staticmethod - def _normalize_size(value, label): - if not (isinstance(value, (list, tuple)) and len(value) == 2): - raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}") - - w = int(value[0]) - h = int(value[1]) - if w <= 0 or h <= 0: - raise RuntimeError(f"{label} inválido: {value!r}") - return [w, h] - - def _resolve_camera_hardware(self): - raw = self.module_params.get("camera_hardware", {}) or {} - - if not self.product_contract: - return {} - - out = {} - expected = { - "rgb": ("CAM_A", ("OV9782", "AR0234")), - "re": ("CAM_B", ("OV9282",)), - "nir": ("CAM_C", ("OV9282",)), - } - - for role, (expected_socket, allowed_sensors) in expected.items(): - item = raw.get(role) - if not isinstance(item, dict): - raise RuntimeError(f"camera_hardware sem role={role}") - - socket = str(item.get("socket") or item.get("socket_name") or "").upper() - sensor = str(item.get("sensor") or item.get("sensor_name") or "").upper() - size = self._normalize_size(item.get("size"), f"camera_hardware.{role}.size") - - if socket != expected_socket: - raise RuntimeError( - f"camera_hardware.{role}.socket={socket!r}, esperado={expected_socket!r}" - ) - - if sensor not in allowed_sensors: - raise RuntimeError( - f"camera_hardware.{role}.sensor={sensor!r}, permitidos={allowed_sensors}" - ) - - out[role] = { - "role": role, - "socket": socket, - "sensor": sensor, - "size": size, - } - - return out - - def _resolve_sensor_size_by_role(self): - if not self.product_contract: - return { - "rgb": [self.legacy_width, self.legacy_height], - "re": [self.legacy_width, self.legacy_height], - "nir": [self.legacy_width, self.legacy_height], - } - - raw = self.module_params.get("sensor_size_by_role", {}) or {} - out = {} - - for role in ("rgb", "re", "nir"): - size = self._normalize_size( - raw.get(role), - f"sensor_size_by_role.{role}", - ) - expected = self.camera_hardware[role]["size"] - if size != expected: - raise RuntimeError( - f"sensor_size_by_role.{role}={size} != camera_hardware={expected}" - ) - out[role] = size - - return out - - def _resolve_bayer_pattern(self): - value = self.module_params.get("bayer_pattern") if self.product_contract else None - bayer = str(value or self.legacy_bayer).upper() - - if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"): - raise RuntimeError(f"bayer_pattern inválido: {bayer!r}") - - return bayer - - def _resolve_default_target_size(self): - value = (self.fusion_config or {}).get("target_size") - if value is None: - return None - return self._normalize_size(value, "fusion_config.target_size") - - def _resolve_target_size(self, target_size): - value = target_size if target_size is not None else self.default_target_size - if value is None: - return None - size = self._normalize_size(value, "target_size") - - if self.product_contract and self.default_target_size is not None: - if size != self.default_target_size: - raise RuntimeError( - "target_size solicitado diverge do module_params homologado: " - f"requested={size}, calibrated_runtime={self.default_target_size}" - ) - - return size - - def _validate_static_product_contract(self): - if not self.product_contract: - return - - if self.frame_type != "RAW_BRUTO": - raise RuntimeError( - "OakFcc3Client de produção aceita somente frame_type='RAW_BRUTO'." - ) - - if self.raw_policy != "require_triple": - raise RuntimeError( - "OakFcc3Client de produção exige raw_policy='require_triple'." - ) - - if self.capture_mode not in ("TRIPLE", "AUTO"): - raise RuntimeError( - "OakFcc3Client de produção exige capture_mode TRIPLE/AUTO." - ) - - expected_mx = ( - (self.module_params.get("calibration_provenance", {}) or {}) - .get("device_mx_id") - ) - if expected_mx and self.mx_id and str(expected_mx) != self.mx_id: - raise RuntimeError( - "MX ID solicitado diverge da calibração homologada: " - f"requested={self.mx_id}, calibrated={expected_mx}" - ) - - def _sync_contract_from_manager_status(self, status): - if not isinstance(status, dict): - return - - if self.product_contract and not bool(status.get("product_contract", False)): - raise RuntimeError( - "Client carregou module_params produto, mas Manager não reconheceu o contrato." - ) - - sizes = status.get("sensor_size_by_role") - if isinstance(sizes, dict): - normalized = { - role: self._normalize_size(sizes.get(role), f"manager.sensor_size_by_role.{role}") - for role in ("rgb", "re", "nir") - } - if self.product_contract and normalized != self.sensor_size_by_role: - raise RuntimeError( - "Manager e Client discordam sobre sensor_size_by_role: " - f"manager={normalized}, client={self.sensor_size_by_role}" - ) - self.sensor_size_by_role = normalized - - bayer = status.get("bayer_pattern") - if bayer: - bayer = str(bayer).upper() - if self.product_contract and bayer != self.bayer: - raise RuntimeError( - f"Manager Bayer={bayer} != Client/module_params={self.bayer}" - ) - - def get_contract(self): - return { - "client_version": OAK_FCC3_CLIENT_VERSION, - "product_contract": bool(self.product_contract), - "require_product_contract": bool(self.require_product_contract), - "sensor_size_by_role": { - role: list(size) - for role, size in self.sensor_size_by_role.items() - }, - "camera_hardware": json.loads(json.dumps(self.camera_hardware)), - "bayer_pattern": self.bayer, - "default_target_size": ( - None if self.default_target_size is None - else list(self.default_target_size) - ), - "evaluate_quality": bool(self.evaluate_quality), - "radiometric_controller_enabled": bool(self.radiometric_controller_enabled), - } + return json.load(f) def apply_module_camera_settings(self): camera_settings = self.module_params.get("camera_settings", {}) or {} - if self.product_contract: - missing = [ - role - for role in ("rgb", "re", "nir") - if not isinstance(camera_settings.get(role), dict) - ] - if missing: - raise RuntimeError( - f"module_params produto sem camera_settings para: {missing}" - ) - applied = {} - roles = ("rgb", "re", "nir") if self.product_contract else tuple(camera_settings.keys()) - for role in roles: - settings = camera_settings.get(role) + for role, settings in camera_settings.items(): if not isinstance(settings, dict): continue @@ -415,43 +157,17 @@ class OakFcc3Client: } self.applied_camera_controls = applied - - if self.product_contract: - failed = { - role: value - for role, value in applied.items() - if not bool((value or {}).get("ok", False)) - } - if failed: - raise RuntimeError( - f"Falha reaplicando camera_settings homologado: {failed}" - ) - return applied def enable_radiometric_controller(self): - cfg = self.module_params.get("radiometric_config", {}) or {} - enabled = bool(cfg.get("enabled", False)) - - # No produto atual este controller de exposição em campo é OFF. - # Não instanciamos um segundo piloto para ficar parado dentro do loop. - if not enabled: - self.radiometric_controller = None - self.radiometric_controller_enabled = False - return None - self.radiometric_controller = RadiometricController( client=self, config_json_path=self.module_calibration_json, ) - - self.radiometric_controller.sync_from_camera_controls( - self.applied_camera_controls - ) + + self.radiometric_controller.sync_from_camera_controls(self.applied_camera_controls) self.radiometric_controller.sync_from_actual_camera_controls() - self.radiometric_controller_enabled = bool( - getattr(self.radiometric_controller, "enabled", True) - ) + return self.radiometric_controller def update_radiometry(self, decoded, meta=None): @@ -491,45 +207,17 @@ class OakFcc3Client: ) try: - status = self.svc.get_status() - self.mx_id = str(status.get("mx_id") or self.mx_id or "") or None - self._sync_contract_from_manager_status(status) + self.mx_id = self.svc.manager.mx_id except Exception: - # Se a validação de contrato falhar, fecha hardware antes de propagar. - try: - self.svc.disconnect() - except Exception: - pass - raise + pass - # O Manager de produção já aplicou estes controles via initialControl. - # Reaplicamos após start como confirmação operacional e para manter - # compatibilidade com Managers legados durante a migração. applied = self.apply_module_camera_settings() - self.enable_radiometric_controller() - if print_debug: print("[OAK CLIENT] START:", resp) - print("[OAK CLIENT] CONTRACT:", self.get_contract()) print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied) - if isinstance(resp, dict): - resp = dict(resp) - resp["client_version"] = OAK_FCC3_CLIENT_VERSION - resp["product_contract"] = bool(self.product_contract) - resp["sensor_size_by_role"] = { - role: list(size) - for role, size in self.sensor_size_by_role.items() - } - resp["bayer_pattern"] = self.bayer - resp["default_target_size"] = ( - None if self.default_target_size is None - else list(self.default_target_size) - ) - resp["radiometric_controller_enabled"] = bool( - self.radiometric_controller_enabled - ) + self.enable_radiometric_controller() return resp @@ -541,29 +229,7 @@ class OakFcc3Client: return self.svc.get_device_metrics() def get_status(self): - status = self.svc.get_status() - if not isinstance(status, dict): - status = {} - else: - status = dict(status) - - status.update({ - "client_version": OAK_FCC3_CLIENT_VERSION, - "client_product_contract": bool(self.product_contract), - "client_require_product_contract": bool(self.require_product_contract), - "client_sensor_size_by_role": { - role: list(size) - for role, size in self.sensor_size_by_role.items() - }, - "client_bayer_pattern": self.bayer, - "client_default_target_size": ( - None if self.default_target_size is None - else list(self.default_target_size) - ), - "evaluate_quality": bool(self.evaluate_quality), - "radiometric_controller_enabled": bool(self.radiometric_controller_enabled), - }) - return status + return self.svc.get_status() def get_next_raw_frame(self, timeout=1.0): return self.svc.capture_frame(timeout=timeout) @@ -578,12 +244,6 @@ class OakFcc3Client: frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper() meta = dict(raw_meta) - if self.product_contract and frame_type != "RAW_BRUTO": - raise RuntimeError( - "OakFcc3Client produto recebeu frame_type não canônico: " - f"{frame_type!r}. Esperado='RAW_BRUTO'." - ) - if frame_type == "RAW_BRUTO": decoded = self.decode_stream_cameras(raw_frame, raw_meta) @@ -680,25 +340,6 @@ class OakFcc3Client: def build_infer_tensor(self, frame, meta, channels_expected, target_size=None): channels_expected = self._validate_physical_channel_count(channels_expected) - target_size = self._resolve_target_size(target_size) - - frame_type = str( - (meta or {}).get("frame_type", self.frame_type) - if isinstance(meta, dict) - else self.frame_type - ).upper() - - # No runtime quente evitamos o quality audit pesado. Decodificamos e - # usamos exatamente o mesmo caminho do CameraMultispectral. - if not self.evaluate_quality and frame_type == "RAW_BRUTO": - decoded = self.core.decode_stream_cameras(frame, meta) - return self.build_infer_tensor_from_decoded( - decoded=decoded, - meta=meta, - channels_expected=channels_expected, - target_size=target_size, - ) - return self.core.build_infer_tensor_from_stream( frame, meta, @@ -714,49 +355,41 @@ class OakFcc3Client: target_size=None, evaluate_quality=None, ): + """ + Monta o Raw5 físico a partir das câmeras já decodificadas. + + evaluate_quality: + - None -> usa self.evaluate_quality + - True -> executa evaluate_frame_quality() e atualiza + core.last_frame_quality_result + - False -> não executa a auditoria pesada e limpa + core.last_frame_quality_result + + A flag altera somente a auditoria de qualidade. Não altera decode, + radiometria, flat-field, homografia, crop/resize ou patch normalization. + """ channels_expected = self._validate_physical_channel_count(channels_expected) - target_size = self._resolve_target_size(target_size) if evaluate_quality is None: evaluate_quality = self.evaluate_quality evaluate_quality = bool(evaluate_quality) - # Core novo faz a geometria source->target em uma única etapa. - # NÃO redimensionar novamente depois da fusão. - tensor = self.core.fuse_multispec_cameras( - decoded, - meta, - channels_expected, - target_size=target_size, - ) - - patch_cfg = getattr( - self.core, - "patch_normalization_config", - {}, - ) or {} + tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected) + tensor = self.core.resize_tensor_chw(tensor, target_size=target_size) + # Mantém paridade com build_infer_tensor_from_stream: se a calibração + # habilitar patch normalization, ela também vale no caminho decoded. + patch_cfg = getattr(self.core, "patch_normalization_config", {}) or {} if bool(patch_cfg.get("enabled", False)): - if self.product_contract: - raise RuntimeError( - "patch_normalization não é permitido no contrato produto." - ) tensor = self.core.apply_patch_normalization_to_tensor(tensor) if evaluate_quality: self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor) else: - # Evita deixar resultado antigo no objeto e evita percentis no hot path. - self.core.last_frame_quality_result = { - "status": "skipped", - "usable_for_training": None, - "reason": "disabled_by_oak_fcc3_client", - "client_version": OAK_FCC3_CLIENT_VERSION, - } + # Evita deixar um resultado antigo parecer referente ao frame atual. + self.core.last_frame_quality_result = None - return np.ascontiguousarray( - tensor.astype(np.float32, copy=False) - ) + return tensor def decode_stream_cameras(self, frame, meta): if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW": @@ -872,14 +505,10 @@ class OakFcc3Client: arr = arr[:, :, 0] if bit_depth == 10 and arr.ndim == 2: - role_size = self.sensor_size_by_role.get( - str(role).lower(), - [self.rgb_native_width, self.rgb_native_height], - ) raw16 = self.core.unpack_raw10_packed( arr, - sensor_width=int(info.get("width", role_size[0])), - sensor_height=int(info.get("height", role_size[1])), + sensor_width=int(info.get("width", self.width)), + sensor_height=int(info.get("height", self.height)), ) if role == "rgb": @@ -941,7 +570,7 @@ class OakFcc3Client: or cam_meta.get("bayer") or stream_meta.get("bayer_pattern") or bayer_pattern - or self.bayer + or "RGGB" ) bayer = str(bayer).upper() @@ -973,17 +602,19 @@ class OakFcc3Client: real_w = int(cam_meta.get("width", sensor_width)) real_h = int(cam_meta.get("height", sensor_height)) - cache_key = (real_w, real_h, bayer) - preview = self._preview_cache.get(cache_key) - if preview is None: - preview = RawProcessorPreview( - sensor_width=real_w, - sensor_height=real_h, - bayer_pattern=bayer, - ) - self._preview_cache[cache_key] = preview + core = RawProcessorCore( + sensor_width=real_w, + sensor_height=real_h, + bayer_pattern=bayer, + ) - raw16 = self.core.unpack_raw10_packed( + preview = RawProcessorPreview( + sensor_width=real_w, + sensor_height=real_h, + bayer_pattern=bayer, + ) + + raw16 = core.unpack_raw10_packed( packed, sensor_width=real_w, sensor_height=real_h, @@ -1050,11 +681,6 @@ class OakFcc3Client: def decode_oak_aligned_multispec(self, frame, meta): - if self.product_contract: - raise RuntimeError( - "MULTISPEC alinhado pela OAK é legado e não faz parte do contrato produto." - ) - """ Decodifica frames já alinhados pela OAK. @@ -1103,11 +729,6 @@ class OakFcc3Client: return decoded def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None): - if self.product_contract: - raise RuntimeError( - "Tensor MULTISPEC pré-alinhado pela OAK é legado no contrato produto." - ) - """ Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia. """ diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py index a2f75e506..3588af9bc 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py @@ -12,60 +12,27 @@ import numpy as np from itertools import product -OAK_FCC3_MANAGER_VERSION = "production_v1_2026_08_24" - - class OakFcc3Manager: """ - Hardware manager de produção para OAK-FFC-3 multiespectral. + Manager OAK-FFC-3 com dois fluxos principais: - Contrato oficial: - CAM_A = RGB = OV9782 1280x800 OU AR0234 1920x1200 - CAM_B = RE = OV9282 1280x800 - CAM_C = NIR = OV9282 1280x800 + 1) RAW_BRUTO + - Mantém o comportamento antigo. + - CAM_A/CAM_B/CAM_C enviam RAW10 packed direto para o PC. + - O PC faz decode, flat/radiometric, homografia/fusão/crop/resize. - Caminho oficial de produto: - RAW_BRUTO -> RAW10 packed nativo por câmera -> RawProcessorCore. - - O Manager não calibra imagem. Ele abre/valida hardware, aplica a política - inicial da câmera, sincroniza RAW e publica metadata fiel por câmera. - - PREVIEW/MULTISPEC permanecem apenas para compatibilidade legada. - Um module_params produzido pelo assembler oficial exige RAW_BRUTO. + 2) MULTISPEC + - Câmeras sempre em 800p nativo. + - OAK aplica homografia/crop/resize via ImageManip. + - PC recebe frames já alinhados: + CAM_A/rgb -> BGR uint8 + CAM_B/re -> GRAY uint8 + CAM_C/nir -> GRAY uint8 + - O Client deve montar o tensor sem reaplicar homografia. """ - PRODUCT_SCHEMA = "multispec_module_params_v3" - ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1" - - LEGACY_SENSOR_W = 1280 - LEGACY_SENSOR_H = 800 - - 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_PROFILES = { - "OV9782": { - "kind": "color", - "resolution_name": "THE_800_P", - "width": 1280, - "height": 800, - }, - "AR0234": { - "kind": "color", - "resolution_name": "THE_1200_P", - "width": 1920, - "height": 1200, - }, - "OV9282": { - "kind": "mono", - "resolution_name": "THE_800_P", - "width": 1280, - "height": 800, - }, - } + SENSOR_W = 1280 + SENSOR_H = 800 def __init__( self, @@ -77,75 +44,41 @@ class OakFcc3Manager: capture_mode="AUTO", raw_policy="allow_single", roles=None, - sync_mode="best", - hardware_sync_enabled=True, - frame_sync_master="CAM_A", - sync_tolerance_ms=12.0, - buffer_size=8, + sync_mode=None, + hardware_sync_enabled=None, + frame_sync_master=None, + sync_tolerance_ms=None, + buffer_size=None, only_camera=None, mx_id=None, module_calibration_json=None, module_params=None, - require_product_contract=False, imu_modo="rotation_vector", imu_freq_hz=200, ): - self.hardware_sync_enabled = bool(hardware_sync_enabled) - self.frame_sync_master = str(frame_sync_master).upper() - self.fps = float(fps) + self.fps = fps - # width/height são apenas saída LEGADA. RAW_BRUTO usa resolução nativa. + # Para compatibilidade, mantemos width/height. + # No RAW_BRUTO isso não muda o sensor, pois usamos 800p fixo. + # No MULTISPEC isso representa a saída final alinhada da OAK. self.width = int(width) self.height = int(height) self.size = (self.width, self.height) + self.sensor_width = self.SENSOR_W + self.sensor_height = self.SENSOR_H + self.frame_type = str(frame_type).upper() self.output_dtype = output_dtype - self.capture_mode = str(capture_mode).upper() - self.raw_policy = str(raw_policy).lower() + self.capture_mode = capture_mode + self.raw_policy = raw_policy self.only_camera = only_camera - self.module_calibration_json = module_calibration_json - self.module_params = ( - copy.deepcopy(module_params) - if isinstance(module_params, dict) - else self._load_module_params(module_calibration_json) - ) - self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {} - self.require_product_contract = bool(require_product_contract) - - self.product_contract = self._is_product_module_params() - self.expected_device_mx_id = self._expected_device_mx_id() - - if self.product_contract: - self.roles = { - contract["socket"]: role - for role, contract in self.PRODUCT_TOPOLOGY.items() - } - else: - self.roles = roles or { - "CAM_A": "rgb", - "CAM_B": "re", - "CAM_C": "nir", - } - - self.expected_camera_hardware = self._resolve_expected_camera_hardware() - self.sensor_size_by_role = self._resolve_sensor_size_by_role() - - rgb_size = self.sensor_size_by_role.get( - "rgb", - [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], - ) - self.sensor_width = int(rgb_size[0]) - self.sensor_height = int(rgb_size[1]) - - self.bayer_pattern = str( - (self.module_params or {}).get("bayer_pattern", "BGGR") - ).upper() - - self.sync_mode = str(sync_mode).lower() - self.sync_tolerance_ms = float(sync_tolerance_ms) - self.buffer_size = int(buffer_size) + self.roles = roles or { + "CAM_A": "rgb", + "CAM_B": "re", + "CAM_C": "nir", + } self.mx_id = str(mx_id) if mx_id else None self.dev_info = None @@ -157,12 +90,14 @@ class OakFcc3Manager: self.imu_modo = self._validate_imu_modo(imu_modo) self.imu_freq_hz = int(imu_freq_hz) + if self.imu_freq_hz <= 0: raise ValueError( f"imu_freq_hz deve ser maior que zero: {self.imu_freq_hz}" ) self.imu_sensor_type = None + self.has_imu_pipeline = False self.tem_imu = False self.q_imu = None @@ -170,18 +105,32 @@ class OakFcc3Manager: self.running = False self.frame_id = 0 + # Serializa start/stop e leituras nativas de telemetria. + # Evita getChipTemperature/getUsbSpeed concorrendo com device.close(). self._device_lock = threading.RLock() self.control_queues = {} - self._last_raw_dims = {} - self.aligned_geometry = None - - # Startup Profile entra ANTES do pipeline existir. self.camera_controls = { cam_id: self._default_controls_for_role(role) for cam_id, role in self.roles.items() } - self._hydrate_camera_controls_from_module_params() - self._validate_static_product_contract() + self._last_raw_dims = {} + + self.module_calibration_json = module_calibration_json + self.module_params = module_params if isinstance(module_params, dict) else self._load_module_params(module_calibration_json) + self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {} + self.aligned_geometry = None + + sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {}) + if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False) + if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A") + if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best") + if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0) + if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8) + self.hardware_sync_enabled = bool(hardware_sync_enabled) + self.frame_sync_master = str(frame_sync_master) + self.sync_mode = str(sync_mode) + self.sync_tolerance_ms = float(sync_tolerance_ms) + self.buffer_size = int(buffer_size) self.async_capture_enabled = True self.async_capture_mode = "latest" # latest | queue @@ -228,327 +177,11 @@ class OakFcc3Manager: # ============================================================ def _load_module_params(self, path): - if not path: + if not path or not os.path.isfile(path): return {} - if not os.path.isfile(path): - raise FileNotFoundError( - f"module_params não encontrado: {path}" - ) - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - - if not isinstance(data, dict): - raise RuntimeError( - f"module_params root deve ser dict/object: {path}" - ) - - return data - - def _is_product_module_params(self): - mp = self.module_params or {} - assembly = mp.get("assembly_metadata", {}) or {} - - return bool( - mp.get("schema") == self.PRODUCT_SCHEMA - and assembly.get("schema") == self.ASSEMBLY_SCHEMA - and isinstance(mp.get("camera_hardware"), dict) - and isinstance(mp.get("sensor_size_by_role"), dict) - and isinstance(mp.get("calibration_provenance"), dict) - ) - - def _expected_device_mx_id(self): - prov = (self.module_params or {}).get("calibration_provenance", {}) or {} - value = prov.get("device_mx_id") - return str(value) if value else None - - def _normalize_size(self, value, *, label): - if not (isinstance(value, (list, tuple)) and len(value) == 2): - raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}") - - w = int(value[0]) - h = int(value[1]) - - if w <= 0 or h <= 0: - raise RuntimeError(f"{label} inválido: {value!r}") - - return [w, h] - - def _resolve_expected_camera_hardware(self): - if not self.product_contract: - return {} - - raw = (self.module_params or {}).get("camera_hardware", {}) or {} - out = {} - - for role, contract in self.PRODUCT_TOPOLOGY.items(): - item = raw.get(role) - - if not isinstance(item, dict): - raise RuntimeError( - f"module_params.camera_hardware sem role={role}" - ) - - socket = str(item.get("socket") or "").upper() - sensor = str(item.get("sensor") or "").upper() - size = self._normalize_size( - item.get("size"), - label=f"camera_hardware.{role}.size", - ) - - if socket != contract["socket"]: - raise RuntimeError( - f"camera_hardware.{role}.socket={socket!r}, " - f"esperado={contract['socket']!r}" - ) - - if sensor not in contract["allowed_sensors"]: - raise RuntimeError( - f"camera_hardware.{role}.sensor={sensor!r}, " - f"permitidos={contract['allowed_sensors']}" - ) - - profile = self.SENSOR_PROFILES.get(sensor) - - if profile is None: - raise RuntimeError( - f"Sensor sem profile de runtime: {sensor}" - ) - - native = [int(profile["width"]), int(profile["height"])] - - if size != native: - raise RuntimeError( - f"camera_hardware.{role}.size={size} " - f"não corresponde ao nativo de {sensor}: {native}" - ) - - out[role] = { - "role": role, - "socket": socket, - "sensor": sensor, - "size": size, - "kind": profile["kind"], - "resolution_name": profile["resolution_name"], - } - - return out - - def _resolve_sensor_size_by_role(self): - if self.product_contract: - raw = (self.module_params or {}).get("sensor_size_by_role", {}) or {} - out = {} - - for role in ("rgb", "re", "nir"): - size = self._normalize_size( - raw.get(role), - label=f"sensor_size_by_role.{role}", - ) - expected = self.expected_camera_hardware[role]["size"] - - if size != expected: - raise RuntimeError( - f"sensor_size_by_role.{role}={size} != " - f"camera_hardware={expected}" - ) - - out[role] = size - - return out - - return { - "rgb": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], - "re": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], - "nir": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], - } - - def _validate_static_product_contract(self): - if self.require_product_contract and not self.product_contract: - raise RuntimeError( - "Contrato de produção obrigatório, mas o module_params " - "não foi gerado pelo assembler oficial." - ) - - if not self.product_contract: - return - - if self.frame_type != "RAW_BRUTO": - raise RuntimeError( - "module_params de produção aceita somente frame_type=RAW_BRUTO. " - f"Recebido={self.frame_type!r}" - ) - - if self.capture_mode not in ("TRIPLE", "AUTO"): - raise RuntimeError( - "Produto multiespectral exige capture_mode TRIPLE " - f"(ou AUTO com require_triple). Recebido={self.capture_mode!r}" - ) - - if self.raw_policy != "require_triple": - raise RuntimeError( - "module_params de produção exige raw_policy='require_triple'. " - f"Recebido={self.raw_policy!r}" - ) - - if self.only_camera is not None: - raise RuntimeError( - "only_camera não é permitido no contrato de produção." - ) - - if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"): - raise RuntimeError( - f"bayer_pattern inválido no module_params: {self.bayer_pattern!r}" - ) - - if ( - self.mx_id is not None - and self.expected_device_mx_id is not None - and self.mx_id != self.expected_device_mx_id - ): - raise RuntimeError( - "MX ID solicitado diverge da calibração homologada: " - f"requested={self.mx_id}, calibrated={self.expected_device_mx_id}" - ) - - def _role_to_cam_id(self, role): - role = str(role).lower() - - for cam_id, mapped_role in self.roles.items(): - if str(mapped_role).lower() == role: - return cam_id - - return None - - def _hydrate_camera_controls_from_module_params(self): - settings = (self.module_params or {}).get("camera_settings", {}) or {} - - if not isinstance(settings, dict): - if self.product_contract: - raise RuntimeError( - "module_params de produção sem camera_settings." - ) - return - - for role, cfg in settings.items(): - if not isinstance(cfg, dict): - continue - - cam_id = self._role_to_cam_id(role) - - if cam_id is None: - continue - - state = self.camera_controls.setdefault( - cam_id, - self._default_controls_for_role(role), - ) - - for key in ( - "ae_enable", - "awb_enable", - "exposure_time_us", - "analogue_gain", - "colour_gains", - ): - if key in cfg: - state[key] = copy.deepcopy(cfg[key]) - - if self.product_contract: - for role in ("rgb", "re", "nir"): - cam_id = self._role_to_cam_id(role) - - if cam_id is None or cam_id not in self.camera_controls: - raise RuntimeError( - f"camera_settings não resolveu role={role}" - ) - - def _sensor_profile(self, sensor_name, role=None): - sensor = str(sensor_name or "").upper() - profile = self.SENSOR_PROFILES.get(sensor) - - if profile is None: - raise RuntimeError( - f"Sensor não suportado pelo runtime: {sensor!r}" - ) - - if role is not None: - role = str(role).lower() - expected_kind = "color" if role == "rgb" else "mono" - - if profile["kind"] != expected_kind: - raise RuntimeError( - f"Sensor {sensor} é {profile['kind']}, " - f"mas role={role} exige {expected_kind}." - ) - - return profile - - def _feature_rows(self, features): - rows = [] - - for f in features: - socket = f.socket.name - sensor = str(f.sensorName or "").upper() - - rows.append({ - "socket": socket, - "sensor": sensor, - "width": int(getattr(f, "width", 0) or 0), - "height": int(getattr(f, "height", 0) or 0), - "role": self.roles.get(socket, "unknown"), - }) - - return rows - - def _validate_connected_hardware(self, features): - rows = self._feature_rows(features) - by_socket = {row["socket"]: row for row in rows} - - if not self.product_contract: - return rows - - if ( - self.expected_device_mx_id is not None - and self.mx_id is not None - and self.mx_id != self.expected_device_mx_id - ): - raise RuntimeError( - "OAK conectada não corresponde ao módulo calibrado: " - f"connected={self.mx_id}, calibrated={self.expected_device_mx_id}" - ) - - errors = [] - - for role, expected in self.expected_camera_hardware.items(): - row = by_socket.get(expected["socket"]) - - if row is None: - errors.append(f"{role}: {expected['socket']} ausente") - continue - - if row["sensor"] != expected["sensor"]: - errors.append( - f"{role}: {expected['socket']} sensor={row['sensor']}, " - f"esperado={expected['sensor']}" - ) - - if row["width"] > 0 and row["height"] > 0: - advertised = [row["width"], row["height"]] - - if advertised != expected["size"]: - errors.append( - f"{role}: {expected['socket']}/{row['sensor']} anunciou " - f"{advertised}, esperado={expected['size']}" - ) - - if errors: - raise RuntimeError( - "Hardware OAK não corresponde ao module_params homologado:\n - " - + "\n - ".join(errors) - ) - - return rows + return json.load(f) def _default_controls_for_role(self, role: str): role = str(role).lower() @@ -579,31 +212,12 @@ class OakFcc3Manager: with dai.Device(dev_info) as dev: result = [] - features = dev.getConnectedCameraFeatures() - - for row in self._feature_rows(features): - role = str(row.get("role", "unknown")).lower() - expected = self.expected_camera_hardware.get(role) - + for f in dev.getConnectedCameraFeatures(): result.append({ - **row, - "expected": copy.deepcopy(expected), - "matches_product_contract": ( - None - if not self.product_contract - else bool( - expected is not None - and row["socket"] == expected["socket"] - and row["sensor"] == expected["sensor"] - and ( - row["width"] <= 0 - or row["height"] <= 0 - or [row["width"], row["height"]] == expected["size"] - ) - ) - ), + "socket": f.socket.name, + "sensor": f.sensorName, + "role": self.roles.get(f.socket.name, "unknown"), }) - return result def _device_id_from_info(self, dev_info): @@ -677,48 +291,35 @@ class OakFcc3Manager: def _create_camera_node_classic(self, socket, sensor_name: str, role: str): """ - RAW/PREVIEW sensor-aware. + Fluxo clássico. - Produto: - OV9782 -> ColorCamera THE_800_P 1280x800 - AR0234 -> ColorCamera THE_1200_P 1920x1200 - OV9282 -> MonoCamera THE_800_P 1280x800 + RGB/OV9782: + ColorCamera raw para RAW_BRUTO. - No produto não existe fallback silencioso de resolução. + MONO/OV9282: + MonoCamera raw quando disponível. """ - sensor = str(sensor_name or "").upper() - role = str(role or "").lower() + sensor_name_u = str(sensor_name or "").upper() + role_u = str(role or "").lower() - if self.product_contract: - profile = self._sensor_profile(sensor, role=role) - else: - profile = self.SENSOR_PROFILES.get(sensor) + is_rgb = ( + role_u == "rgb" + or "OV9782" in sensor_name_u + or socket == dai.CameraBoardSocket.CAM_A + ) - if profile is None: - profile = { - "kind": "color" if role == "rgb" else "mono", - "resolution_name": "THE_800_P", - "width": self.LEGACY_SENSOR_W, - "height": self.LEGACY_SENSOR_H, - } - - if profile["kind"] == "color": + if is_rgb: cam = self.pipeline.createColorCamera() cam.setBoardSocket(socket) - resolution = getattr( - dai.ColorCameraProperties.SensorResolution, - profile["resolution_name"], - None, - ) + try: + cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_800_P) + except Exception: + try: + cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_1080_P) + except Exception: + pass - if resolution is None: - raise RuntimeError( - f"DepthAI não expõe ColorCamera " - f"{profile['resolution_name']} para {sensor}." - ) - - cam.setResolution(resolution) cam.setInterleaved(False) cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.RGB) cam.setFps(float(self.fps)) @@ -730,42 +331,31 @@ class OakFcc3Manager: except Exception: return cam, cam.preview - if not hasattr(cam, "raw"): - raise RuntimeError( - f"ColorCamera {sensor} não expõe raw output." - ) - return cam, cam.raw mono = self.pipeline.create(dai.node.MonoCamera) mono.setBoardSocket(socket) - resolution = getattr( - dai.MonoCameraProperties.SensorResolution, - profile["resolution_name"], - None, - ) + try: + mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_800_P) + except Exception: + try: + mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_720_P) + except Exception: + try: + mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P) + except Exception: + pass - if resolution is None: - raise RuntimeError( - f"DepthAI não expõe MonoCamera " - f"{profile['resolution_name']} para {sensor}." - ) - - mono.setResolution(resolution) mono.setFps(float(self.fps)) if self._is_preview_mode(): return mono, mono.out - if not hasattr(mono, "raw"): - if self.product_contract: - raise RuntimeError( - f"MonoCamera {sensor} não expõe raw output." - ) - return mono, mono.out + if hasattr(mono, "raw"): + return mono, mono.raw - return mono, mono.raw + return mono, mono.out def _create_imu_node(self, pipeline): self.has_imu_pipeline = False @@ -1306,13 +896,6 @@ class OakFcc3Manager: self.pipeline = dai.Pipeline() features = self.device.getConnectedCameraFeatures() - self._validate_connected_hardware(features) - - if self.product_contract and self._is_multispec_mode(): - raise RuntimeError( - "MULTISPEC alinhado na OAK é legado e não faz parte do " - "contrato de produção. Use RAW_BRUTO." - ) self.queues.clear() self.buffers.clear() @@ -1360,44 +943,11 @@ class OakFcc3Manager: self.buffers[cam_id] = deque(maxlen=self.buffer_size) self.control_queues[cam_id] = None - sensor_u = str(f.sensorName or "").upper() - if self.product_contract or sensor_u in self.SENSOR_PROFILES: - profile = self._sensor_profile(sensor_u, role=role) - else: - profile = { - "kind": "unknown", - "width": int(getattr(f, "width", 0) or self.LEGACY_SENSOR_W), - "height": int(getattr(f, "height", 0) or self.LEGACY_SENSOR_H), - "resolution_name": None, - } - self.camera_info[cam_id] = { "id": cam_id, "socket": socket_name, - "sensor": sensor_u, + "sensor": f.sensorName, "role": role, - "native_width": int(profile["width"]), - "native_height": int(profile["height"]), - "native_size": [ - int(profile["width"]), - int(profile["height"]), - ], - "sensor_kind": profile["kind"], - "resolution_mode": profile.get("resolution_name"), - "bayer_pattern": ( - self.bayer_pattern - if str(role).lower() == "rgb" - else None - ), - "product_expected": ( - copy.deepcopy( - self.expected_camera_hardware.get( - str(role).lower() - ) - ) - if self.product_contract - else None - ), } self._validate_capture_mode() @@ -1599,14 +1149,6 @@ class OakFcc3Manager: "height": self.height, "sensor_width": self.sensor_width, "sensor_height": self.sensor_height, - "sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role), - "camera_hardware_expected": copy.deepcopy(self.expected_camera_hardware), - "bayer_pattern": self.bayer_pattern, - "product_contract": bool(self.product_contract), - "require_product_contract": bool(self.require_product_contract), - "manager_version": OAK_FCC3_MANAGER_VERSION, - "module_params_schema": (self.module_params or {}).get("schema"), - "module_calibration_json": self.module_calibration_json, "frame_type": self.frame_type, "output_dtype": self.output_dtype, "capture_mode": self.capture_mode, @@ -2184,32 +1726,6 @@ class OakFcc3Manager: item["shape"] = list(arr.shape) item["dtype"] = str(arr.dtype) item["packed"] = True - item["native_width"] = int( - item.get("native_width", item["width"]) - ) - item["native_height"] = int( - item.get("native_height", item["height"]) - ) - item["native_size"] = [ - item["native_width"], - item["native_height"], - ] - - if str(item.get("role", "")).lower() == "rgb": - item["bayer_pattern"] = self.bayer_pattern - - if self.product_contract: - role = str(item.get("role", "")).lower() - expected = self.expected_camera_hardware.get(role) - - if expected is not None: - actual = [int(item["width"]), int(item["height"])] - - if actual != expected["size"]: - raise RuntimeError( - f"{cam_id}/{role}: RAW frame {actual} != " - f"hardware homologado {expected['size']}" - ) camera_info[cam_id] = item @@ -2217,18 +1733,6 @@ class OakFcc3Manager: "frame_id": self.frame_id, "backend": "oak_fcc3", "frame_type": self.frame_type, - "product_contract": bool(self.product_contract), - "module_params_schema": (self.module_params or {}).get("schema"), - "module_calibration_json": self.module_calibration_json, - "device_mx_id": self.mx_id, - "reference_camera": "rgb", - "sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role), - "camera_hardware": copy.deepcopy( - self.expected_camera_hardware - if self.product_contract - else {} - ), - "bayer_pattern": self.bayer_pattern, "capture_mode": self.capture_mode, "output_dtype": self.output_dtype, "dtype": self.output_dtype, @@ -2417,65 +1921,18 @@ class OakFcc3Manager: return result def apply_initial_camera_controls_to_node(self, cam, cam_id): - """ - Aplica Startup Profile antes de startPipeline(). - - O Client pode reaplicar depois do start como confirmação, mas os - primeiros frames já nascem sob a política homologada. - """ ctrl_state = self.camera_controls.get(cam_id, {}) ae = bool(ctrl_state.get("ae_enable", False)) - awb = bool(ctrl_state.get("awb_enable", False)) exp_us = int(ctrl_state.get("exposure_time_us") or 15000) gain = float(ctrl_state.get("analogue_gain") or 1.0) - if ae: - fn = getattr(cam.initialControl, "setAutoExposureEnable", None) - - if callable(fn): - fn() - elif self.product_contract: - raise RuntimeError( - f"{cam_id}: DepthAI sem setAutoExposureEnable no initialControl." - ) - else: + if not ae: cam.initialControl.setManualExposure( exp_us, self._gain_to_iso(gain), ) - role = str(self.roles.get(cam_id, "")).lower() - - if role == "rgb": - if awb: - fn = getattr( - cam.initialControl, - "setAutoWhiteBalanceLock", - None, - ) - - if callable(fn): - fn(False) - else: - try: - cam.initialControl.setAutoWhiteBalanceMode( - dai.CameraControl.AutoWhiteBalanceMode.AUTO - ) - except Exception: - if self.product_contract: - raise RuntimeError( - f"{cam_id}: não foi possível habilitar AWB inicial." - ) - else: - fn = getattr( - cam.initialControl, - "setAutoWhiteBalanceLock", - None, - ) - if callable(fn): - fn(True) - def _send_control(self, cam_id, ctrl): if cam_id not in self.control_queues: raise RuntimeError(f"Fila de controle não existe para {cam_id}") diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py index dbf3982dc..e4c053860 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py @@ -1,726 +1,689 @@ -# camera_worker/oak_fcc3_core/raw_processor_preview.py +#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -RawProcessorPreview - Production -================================ +RawProcessorPreview +=================== -Conversor EXCLUSIVAMENTE visual para RAW Bayer da câmera RGB. +Mini-ISP exclusivamente visual para gerar previews de anotação a partir do +RAW Bayer da câmera RGB. -Este módulo: - - NÃO participa do tensor científico; - - NÃO altera RAW salvo; - - NÃO aplica Flat-Field; - - NÃO aplica Radiometric Normalization; - - NÃO aplica Homography; - - NÃO deve ser usado para treino ou inferência. +Contrato: + - entrada científica permanece imutável; + - saída sempre BGR uint8 para OpenCV; + - preserva exatamente HxW do raster RGB; + - não aplica resize, crop, rotate/flip, undistort, Flat-Field ou Homography; + - não deve ser usado para formar o tensor de treino/inferência. -Contrato oficial: - CAM_A = RGB - OV9782 -> 1280x800 - AR0234 -> 1920x1200 +Pipeline visual padrão: + RAW10/RAW16 -> níveis robustos -> gamma -> demosaico EA/BGR -> + WB por regiões claras/neutras -> CLAHE suave em luminância -> + saturação leve -> contraste -> unsharp suave. -A resolução e o Bayer devem vir do module_params/Client já validados. - -Pipeline de preview: - RAW Bayer uint16 - -> robust display levels - -> uint8 - -> demosaic BGR - -> gray-world opcional - -> contraste opcional - -> JPEG/preview - -Importante: - O resultado desta classe é BGR, compatível com OpenCV. +As APIs antigas foram preservadas. Chamadas que antes usavam +raw16_to_preview_bgr() ou raw16_to_preview_jpg_bytes() continuam válidas. """ from __future__ import annotations -from typing import Optional +from copy import deepcopy +from typing import Any, Optional import cv2 import numpy as np -RAW_PROCESSOR_PREVIEW_VERSION = "production_v1_2026_08_24" - -SUPPORTED_BAYER_PATTERNS = ( - "RGGB", - "BGGR", - "GRBG", - "GBRG", -) - -SUPPORTED_BIT_DEPTHS = ( - 8, - 10, - 12, - 16, -) - - class RawProcessorPreview: - """ - Preview RAW Bayer para debug/stream/salvamento visual. + VERSION = "raw_processor_preview_v2_2026_09_09" + CONTRACT_SCHEMA = "rgb_raw_annotation_preview_v2" + BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") - Parâmetros - ---------- - sensor_width: - Largura NATIVA do RGB de referência. - - sensor_height: - Altura NATIVA do RGB de referência. - - bayer_pattern: - Bayer real da CAM_A, vindo do contrato homologado. - - sensor_name: - Opcional, somente rastreabilidade. - - strict_shape: - Se True, RAW recebido deve possuir exatamente HxW nativo. - Recomendado e default no produto. - """ + DEFAULT_CONFIG = { + "bit_depth": 10, + "levels": { + "mode": "robust_percentile", + "black_percentile": 0.20, + "white_percentile": 99.80, + "sample_max_pixels": 500_000, + "minimum_span_codes": 16.0, + }, + "gamma": 2.20, + "demosaic": { + "algorithm": "edge_aware", + "output_color_order": "BGR", + }, + "white_balance": { + "enabled": True, + "method": "neutral_bright_with_gray_world_fallback", + "strength": 0.65, + "gain_min": 0.60, + "gain_max": 1.70, + "bright_percentile": 55.0, + "neutral_chroma_percentile": 40.0, + "min_neutral_pixels": 256, + }, + "clahe": { + "enabled": True, + "clip_limit": 1.55, + "tile_grid_size": 8, + }, + "saturation": { + "enabled": True, + "factor": 1.04, + }, + "contrast": { + "enabled": True, + "alpha": 1.04, + "beta": 0.0, + }, + "sharpen": { + "enabled": True, + "sigma": 0.85, + "amount": 0.55, + "threshold": 2.0, + }, + } def __init__( self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG", - sensor_name: Optional[str] = None, - strict_shape: bool = True, + config: Optional[dict] = None, ): self.sensor_width = int(sensor_width) self.sensor_height = int(sensor_height) - - if self.sensor_width <= 0 or self.sensor_height <= 0: - raise ValueError( - "Resolução inválida para preview: " - f"{self.sensor_width}x{self.sensor_height}" - ) - - self.bayer_pattern = str( - bayer_pattern - ).strip().upper() - - if self.bayer_pattern not in SUPPORTED_BAYER_PATTERNS: - raise ValueError( - f"Padrão Bayer não suportado: {self.bayer_pattern}. " - f"Suportados={SUPPORTED_BAYER_PATTERNS}" - ) - - self.sensor_name = ( - str(sensor_name).strip().upper() - if sensor_name - else None - ) - - self.strict_shape = bool( - strict_shape - ) - - # ============================================================ - # Contrato - # ============================================================ - - def get_contract(self) -> dict: - return { - "preview_version": RAW_PROCESSOR_PREVIEW_VERSION, - "sensor_name": self.sensor_name, - "sensor_width": int( - self.sensor_width - ), - "sensor_height": int( - self.sensor_height - ), - "sensor_size": [ - int( - self.sensor_width - ), - int( - self.sensor_height - ), - ], - "bayer_pattern": ( - self.bayer_pattern - ), - "strict_shape": bool( - self.strict_shape - ), - "output_color_order": "BGR", - "scientific_effect": "none", - } - - # ============================================================ - # Validação - # ============================================================ - - def _validate_raw( - self, - raw16: np.ndarray, - bit_depth: int, - ) -> np.ndarray: - if raw16 is None: - raise ValueError( - "RAW de preview é None." - ) - - raw = np.asarray( - raw16 - ) - - if raw.ndim != 2: - raise ValueError( - "RAW Bayer deve ser 2D HxW; " - f"shape={raw.shape}" - ) - - if self.strict_shape: - expected = ( - self.sensor_height, - self.sensor_width, - ) - - if raw.shape != expected: - raise ValueError( - "RAW Bayer com shape incompatível: " - f"recebido={raw.shape}, esperado={expected}" - ) - - bit_depth = int( - bit_depth - ) - - if bit_depth not in SUPPORTED_BIT_DEPTHS: - raise ValueError( - f"bit_depth não suportado: {bit_depth}. " - f"Suportados={SUPPORTED_BIT_DEPTHS}" - ) - - if not np.issubdtype( - raw.dtype, - np.integer, - ): - raise ValueError( - "RAW Bayer para preview deve ser inteiro; " - f"dtype={raw.dtype}" - ) - - return raw + self.bayer_pattern = str(bayer_pattern).upper() + self.config = self._deep_merge(self.DEFAULT_CONFIG, config or {}) + self.last_preview_report: dict[str, Any] = {} + self._validate_contract() @staticmethod - def _validate_levels( - black_level, - white_level, - max_val, - ): - black = ( - None - if black_level is None - else float( - black_level - ) - ) + def _deep_merge(base: dict, override: dict) -> dict: + result = deepcopy(base) + if not isinstance(override, dict): + raise TypeError("config do RawProcessorPreview deve ser dict.") + for key, value in override.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = RawProcessorPreview._deep_merge(result[key], value) + else: + result[key] = deepcopy(value) + return result - white = ( - None - if white_level is None - else float( - white_level - ) - ) - - if ( - black is not None - and not np.isfinite( - black - ) - ): + def _validate_contract(self): + if self.sensor_width <= 0 or self.sensor_height <= 0: raise ValueError( - f"black_level inválido: {black_level}" + f"Dimensão de sensor inválida: {self.sensor_width}x{self.sensor_height}" ) - - if ( - white is not None - and not np.isfinite( - white - ) - ): + if self.sensor_width % 4 != 0: raise ValueError( - f"white_level inválido: {white_level}" + f"RAW10 packed exige largura múltipla de 4: {self.sensor_width}" ) + if self.bayer_pattern not in self.BAYER_PATTERNS: + raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}") - if black is not None: - black = float( - np.clip( - black, - 0.0, - max_val, - ) - ) + bit_depth = int(self.config.get("bit_depth", 10)) + if bit_depth < 8 or bit_depth > 16: + raise ValueError(f"bit_depth inválido: {bit_depth}") - if white is not None: - white = float( - np.clip( - white, - 0.0, - max_val, - ) - ) + levels = self.config["levels"] + low = float(levels["black_percentile"]) + high = float(levels["white_percentile"]) + if not (0.0 <= low < high <= 100.0): + raise ValueError(f"Percentis de níveis inválidos: {low}, {high}") - return black, white + gamma = float(self.config["gamma"]) + if not np.isfinite(gamma) or gamma <= 0.0: + raise ValueError(f"Gamma inválido: {gamma}") - # ============================================================ - # RAW -> display uint8 - # ============================================================ + wb = self.config["white_balance"] + if float(wb["gain_min"]) <= 0.0 or float(wb["gain_max"]) < float(wb["gain_min"]): + raise ValueError("Limites de ganho do white balance inválidos.") + + def get_preview_contract(self) -> dict: + """Contrato serializável para registrar no meta do dataset.""" + return { + "schema": self.CONTRACT_SCHEMA, + "version": self.VERSION, + "purpose": "human_annotation_only", + "geometry": "rgb_sensor_native_no_geometric_transform", + "sensor_size": [self.sensor_width, self.sensor_height], + "bayer_pattern": self.bayer_pattern, + "output": { + "layout": "HWC", + "color_order": "BGR", + "dtype": "uint8", + "range": [0, 255], + }, + "calibrations_applied": [], + "config": deepcopy(self.config), + } + + def get_last_preview_report(self) -> dict: + return deepcopy(self.last_preview_report) + + # Nome explícito e amigável para integrações que só precisam registrar + # a configuração, sem necessariamente gerar um frame antes. + def describe_config(self) -> dict: + return self.get_preview_contract() + + @staticmethod + def _finite_float(value, name: str) -> float: + result = float(value) + if not np.isfinite(result): + raise ValueError(f"{name} deve ser finito: {value!r}") + return result + + @staticmethod + def _sample_for_stats(raw: np.ndarray, max_pixels: int) -> np.ndarray: + total = int(raw.size) + if total <= max_pixels: + return raw.reshape(-1) + stride = max(1, int(np.ceil(np.sqrt(total / float(max_pixels))))) + return raw[::stride, ::stride].reshape(-1) + + def _resolve_levels( + self, + raw16: np.ndarray, + black_level: Optional[float], + white_level: Optional[float], + bit_depth: int, + black_percentile: Optional[float], + white_percentile: Optional[float], + ) -> tuple[float, float, dict]: + max_code = float((1 << int(bit_depth)) - 1) + levels = self.config["levels"] + + low_pct = float( + levels["black_percentile"] + if black_percentile is None + else black_percentile + ) + high_pct = float( + levels["white_percentile"] + if white_percentile is None + else white_percentile + ) + if not (0.0 <= low_pct < high_pct <= 100.0): + raise ValueError(f"Percentis inválidos: {low_pct}, {high_pct}") + + sample = self._sample_for_stats( + raw16, + max(1, int(levels["sample_max_pixels"])), + ).astype(np.float32, copy=False) + sample = sample[np.isfinite(sample)] + if sample.size == 0: + raise RuntimeError("RAW não contém pixels finitos para calcular níveis.") + + black_source = "explicit" + white_source = "explicit" + if black_level is None: + black = float(np.percentile(sample, low_pct)) + black_source = "percentile" + else: + black = self._finite_float(black_level, "black_level") + if white_level is None: + white = float(np.percentile(sample, high_pct)) + white_source = "percentile" + else: + white = self._finite_float(white_level, "white_level") + + black = float(np.clip(black, 0.0, max_code)) + white = float(np.clip(white, 0.0, max_code)) + minimum_span = float(levels["minimum_span_codes"]) + fallback = False + + if white <= black + minimum_span: + black = float(np.clip(np.min(sample), 0.0, max_code)) + white = float(np.clip(np.max(sample), 0.0, max_code)) + fallback = True + if white <= black: + black, white = 0.0, max_code + fallback = True + + return black, white, { + "black_level": black, + "white_level": white, + "black_source": black_source, + "white_source": white_source, + "black_percentile": low_pct, + "white_percentile": high_pct, + "fallback_to_range": fallback, + "sample_pixels": int(sample.size), + } def raw16_to_vis8( self, raw16: np.ndarray, - black_level: Optional[float] = None, - white_level: Optional[float] = None, - gamma: float = 2.2, - bit_depth: int = 10, - auto_low_percentile: float = 0.5, - auto_high_percentile: float = 99.5, + black_level: Optional[int] = None, + white_level: Optional[int] = None, + gamma: Optional[float] = None, + bit_depth: Optional[int] = None, + black_percentile: Optional[float] = None, + white_percentile: Optional[float] = None, ) -> np.ndarray: - """ - Converte RAW Bayer para uint8 de DISPLAY. - - Quando níveis não são informados, usa percentis robustos em vez de - min/max para evitar que hot pixels ou pequenos pontos saturados lavem - todo o preview. - - Isso é somente estética visual. - """ - raw = self._validate_raw( - raw16, - bit_depth, - ) - - max_val = float( - (1 << int(bit_depth)) - - 1 - ) - - black, white = ( - self._validate_levels( - black_level, - white_level, - max_val, - ) - ) - - low_p = float( - auto_low_percentile - ) - - high_p = float( - auto_high_percentile - ) - - if not ( - 0.0 <= low_p - < high_p - <= 100.0 - ): + """Converte mosaico RAW16 para mosaico uint8 de visualização.""" + raw = np.asarray(raw16) + if raw.ndim != 2: + raise ValueError(f"RAW16 deve ser 2D; shape={raw.shape}") + if raw.shape != (self.sensor_height, self.sensor_width): raise ValueError( - "Percentis automáticos inválidos: " - f"{low_p}, {high_p}" + f"RAW16 shape={raw.shape}; esperado=" + f"{(self.sensor_height, self.sensor_width)}" ) + if not np.issubdtype(raw.dtype, np.integer): + raise TypeError(f"RAW16 deve possuir dtype inteiro; recebido={raw.dtype}") - raw_f = raw.astype( - np.float32, - copy=False, + bit_depth = int( + self.config["bit_depth"] if bit_depth is None else bit_depth ) - - # Amostragem determinística para previews grandes. - # Evita percentil full-frame de 2.3M pixels em todo snapshot. - total = raw_f.size - max_samples = 250_000 - - if total > max_samples: - step = max( - 1, - total // max_samples, - ) - - sample = raw_f.reshape( - -1 - )[::step] - else: - sample = raw_f.reshape( - -1 - ) - - if black is None: - black = float( - np.percentile( - sample, - low_p, - ) - ) - - if white is None: - white = float( - np.percentile( - sample, - high_p, - ) - ) - - if white <= black: - # Caso frame praticamente constante. - if max_val <= 0.0: - norm = np.zeros_like( - raw_f, - dtype=np.float32, - ) - else: - norm = raw_f / max_val - - else: - norm = ( - raw_f - black - ) / ( - white - black - ) - - norm = np.clip( - norm, - 0.0, - 1.0, + gamma_value = self._finite_float( + self.config["gamma"] if gamma is None else gamma, + "gamma", ) + if gamma_value <= 0.0: + raise ValueError("gamma deve ser > 0.") - if gamma is not None: - gamma = float( - gamma - ) - - if not np.isfinite( - gamma - ) or gamma <= 0.0: - raise ValueError( - f"gamma inválido: {gamma}" - ) - - norm = np.power( - norm, - 1.0 / gamma, - ) - - return np.clip( - norm * 255.0, - 0.0, - 255.0, - ).astype( - np.uint8 + black, white, level_report = self._resolve_levels( + raw, + black_level, + white_level, + bit_depth, + black_percentile, + white_percentile, ) + norm = (raw.astype(np.float32) - black) / max(white - black, 1.0) + np.clip(norm, 0.0, 1.0, out=norm) + if gamma_value != 1.0: + norm = np.power(norm, 1.0 / gamma_value) - # ============================================================ - # Bayer - # ============================================================ + self.last_preview_report = { + "stage": "raw16_to_vis8", + "bit_depth": bit_depth, + "gamma": gamma_value, + "levels": level_report, + } + return np.clip(norm * 255.0 + 0.5, 0, 255).astype(np.uint8) def _debayer_code(self): - """ - Retorna código OpenCV que produz BGR. - - A versão antiga usava COLOR_Bayer*2RGB_EA e em seguida tratava o - resultado como BGR, podendo trocar vermelho/azul no preview. - """ - # IMPORTANTE: - # Os aliases Bayer do OpenCV são contraintuitivos em relação ao - # nome físico 2x2 que usamos no produto. O mapeamento abaixo foi - # validado com mosaicos sintéticos de cor conhecida e produz BGR: - # - # físico RGGB -> OpenCV BayerBG2BGR - # físico BGGR -> OpenCV BayerRG2BGR - # físico GRBG -> OpenCV BayerGB2BGR - # físico GBRG -> OpenCV BayerGR2BGR - # - # Não "simplifique" este mapa pela semelhança dos nomes. + # A API desta classe promete BGR para cv2.imshow/cv2.imwrite. mapping = { - "RGGB": ( - cv2.COLOR_BayerBG2BGR_EA - ), - "BGGR": ( - cv2.COLOR_BayerRG2BGR_EA - ), - "GRBG": ( - cv2.COLOR_BayerGB2BGR_EA - ), - "GBRG": ( - cv2.COLOR_BayerGR2BGR_EA - ), + "RGGB": ("COLOR_BayerRG2BGR_EA", "COLOR_BayerRG2BGR"), + "BGGR": ("COLOR_BayerBG2BGR_EA", "COLOR_BayerBG2BGR"), + "GRBG": ("COLOR_BayerGR2BGR_EA", "COLOR_BayerGR2BGR"), + "GBRG": ("COLOR_BayerGB2BGR_EA", "COLOR_BayerGB2BGR"), } + preferred, fallback = mapping[self.bayer_pattern] + return getattr(cv2, preferred, getattr(cv2, fallback)) - return mapping[ - self.bayer_pattern - ] - - # ============================================================ - # Ajustes VISUAIS - # ============================================================ - - @staticmethod def apply_preview_white_balance( + self, bgr: np.ndarray, strength: float = 1.0, - ) -> np.ndarray: + method: Optional[str] = None, + gain_min: Optional[float] = None, + gain_max: Optional[float] = None, + return_report: bool = False, + ): """ - Gray-world simples somente para deixar o preview legível. + WB visual robusto para campo agrícola. - Não usar no RAW científico. + Primeiro procura pixels claros e de baixo croma, reduzindo o risco de + o gray-world neutralizar toda a vegetação verde. Se não houver amostra + neutra suficiente, usa gray-world com ganhos limitados. """ - img = np.asarray( - bgr - ) + image = np.asarray(bgr) + if image.ndim != 3 or image.shape[2] != 3: + raise ValueError(f"WB requer BGR HWC; shape={image.shape}") - if ( - img.ndim != 3 - or img.shape[2] != 3 - ): - raise ValueError( - "Preview WB exige BGR HxWx3; " - f"shape={img.shape}" - ) + cfg = self.config["white_balance"] + method = str(method or cfg["method"]).lower() + strength = float(np.clip(self._finite_float(strength, "wb_strength"), 0.0, 1.0)) + gain_min = float(cfg["gain_min"] if gain_min is None else gain_min) + gain_max = float(cfg["gain_max"] if gain_max is None else gain_max) + if gain_min <= 0.0 or gain_max < gain_min: + raise ValueError("Limites de ganho WB inválidos.") - strength = float( - strength - ) + sample = image[::4, ::4].astype(np.float32) + flat = sample.reshape(-1, 3) + intensity = flat.mean(axis=1) + chroma = (flat.max(axis=1) - flat.min(axis=1)) / np.maximum(intensity, 1.0) - if not np.isfinite( - strength - ): - raise ValueError( - f"strength inválido: {strength}" - ) - - strength = float( - np.clip( - strength, - 0.0, - 1.0, - ) - ) - - work = img.astype( - np.float32, - copy=True, - ) - - # Amostragem reduz custo em 1920x1200. - h, w = work.shape[:2] - sample_step = max( - 1, - int( - np.sqrt( - (h * w) - / 200_000.0 + selected = flat + source = "gray_world" + if method.startswith("neutral_bright") and flat.shape[0] > 0: + bright_limit = float(np.percentile(intensity, float(cfg["bright_percentile"]))) + bright_mask = intensity >= bright_limit + bright_chroma = chroma[bright_mask] + if bright_chroma.size: + neutral_limit = float( + np.percentile( + bright_chroma, + float(cfg["neutral_chroma_percentile"]), + ) ) - ), - ) + neutral_mask = bright_mask & (chroma <= neutral_limit) + neutral = flat[neutral_mask] + if neutral.shape[0] >= int(cfg["min_neutral_pixels"]): + selected = neutral + source = "neutral_bright" - sample = work[ - ::sample_step, - ::sample_step, - ] + means = selected.mean(axis=0) + target = float(means.mean()) + gains = target / np.maximum(means, 1e-6) + gains = np.clip(gains, gain_min, gain_max) + gains = 1.0 + (gains - 1.0) * strength - mean_b = float( - sample[:, :, 0].mean() - ) + out = np.clip( + image.astype(np.float32) * gains[None, None, :], + 0, + 255, + ).astype(np.uint8) + report = { + "method_requested": method, + "source_used": source, + "strength": strength, + "means_bgr": [float(x) for x in means], + "gains_bgr": [float(x) for x in gains], + "selected_pixels": int(selected.shape[0]), + } + return (out, report) if return_report else out - mean_g = float( - sample[:, :, 1].mean() - ) - - mean_r = float( - sample[:, :, 2].mean() - ) - - mean_gray = ( - mean_b - + mean_g - + mean_r - ) / 3.0 - - eps = 1e-6 - - gain_b = mean_gray / max( - mean_b, - eps, - ) - - gain_g = mean_gray / max( - mean_g, - eps, - ) - - gain_r = mean_gray / max( - mean_r, - eps, - ) - - gain_b = 1.0 + ( - gain_b - 1.0 - ) * strength - - gain_g = 1.0 + ( - gain_g - 1.0 - ) * strength - - gain_r = 1.0 + ( - gain_r - 1.0 - ) * strength - - work[:, :, 0] *= gain_b - work[:, :, 1] *= gain_g - work[:, :, 2] *= gain_r - - return np.clip( - work, - 0.0, - 255.0, - ).astype( - np.uint8 - ) - - @staticmethod def apply_preview_contrast( + self, bgr: np.ndarray, alpha: float = 1.08, beta: float = 0.0, ) -> np.ndarray: - """ - Contraste/brilho de DISPLAY. - """ - alpha = float( - alpha + """Compatibilidade: contraste/brilho visual sem alterar geometria.""" + alpha = self._finite_float(alpha, "contrast_alpha") + beta = self._finite_float(beta, "contrast_beta") + return cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta) + + @staticmethod + def _apply_clahe_luminance( + bgr: np.ndarray, + clip_limit: float, + tile_grid_size: int, + ) -> np.ndarray: + lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) + luma, a_ch, b_ch = cv2.split(lab) + clahe = cv2.createCLAHE( + clipLimit=float(clip_limit), + tileGridSize=(int(tile_grid_size), int(tile_grid_size)), + ) + return cv2.cvtColor( + cv2.merge([clahe.apply(luma), a_ch, b_ch]), + cv2.COLOR_LAB2BGR, ) - beta = float( - beta + @staticmethod + def _apply_saturation(bgr: np.ndarray, factor: float) -> np.ndarray: + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + sat = hsv[:, :, 1].astype(np.float32) * float(factor) + hsv[:, :, 1] = np.clip(sat, 0, 255).astype(np.uint8) + return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + + @staticmethod + def _apply_unsharp( + bgr: np.ndarray, + sigma: float, + amount: float, + threshold: float, + ) -> np.ndarray: + image = bgr.astype(np.float32) + blurred = cv2.GaussianBlur( + image, + (0, 0), + sigmaX=float(sigma), + sigmaY=float(sigma), ) - - if ( - not np.isfinite( - alpha - ) - or alpha <= 0.0 - ): - raise ValueError( - f"alpha inválido: {alpha}" - ) - - if not np.isfinite( - beta - ): - raise ValueError( - f"beta inválido: {beta}" - ) - - return cv2.convertScaleAbs( - bgr, - alpha=alpha, - beta=beta, - ) - - # ============================================================ - # Preview completo - # ============================================================ + sharpened = image + float(amount) * (image - blurred) + if threshold > 0.0: + mask = np.max(np.abs(image - blurred), axis=2) >= float(threshold) + result = image.copy() + result[mask] = sharpened[mask] + else: + result = sharpened + return np.clip(result, 0, 255).astype(np.uint8) def raw16_to_preview_bgr( self, raw16: np.ndarray, - gamma: float = 2.2, - wb_strength: float = 0.8, + gamma: Optional[float] = None, + wb_strength: Optional[float] = None, apply_wb: bool = True, apply_contrast: bool = True, - bit_depth: int = 10, + bit_depth: Optional[int] = None, + *, black_level: Optional[float] = None, white_level: Optional[float] = None, - ) -> np.ndarray: - """ - Pipeline visual: - 1. robust levels + gamma no mosaico - 2. demosaic BGR - 3. gray-world opcional - 4. contraste opcional - """ + black_percentile: Optional[float] = None, + white_percentile: Optional[float] = None, + apply_clahe: Optional[bool] = None, + apply_saturation: Optional[bool] = None, + apply_sharpen: Optional[bool] = None, + return_report: bool = False, + ): + """Gera preview BGR bonito preservando exatamente o raster de entrada.""" + source = np.asarray(raw16) + expected_shape = (self.sensor_height, self.sensor_width) + if source.shape != expected_shape: + raise ValueError(f"RAW16 shape={source.shape}; esperado={expected_shape}") + + effective_gamma = float( + self.config["gamma"] if gamma is None else gamma + ) + effective_bit_depth = int( + self.config["bit_depth"] if bit_depth is None else bit_depth + ) + effective_wb_strength = float( + self.config["white_balance"]["strength"] + if wb_strength is None + else wb_strength + ) + vis8 = self.raw16_to_vis8( - raw16, + source, black_level=black_level, white_level=white_level, - gamma=gamma, - bit_depth=bit_depth, - ) - - bgr = cv2.cvtColor( - vis8, - self._debayer_code(), + gamma=effective_gamma, + bit_depth=effective_bit_depth, + black_percentile=black_percentile, + white_percentile=white_percentile, ) + level_report = deepcopy(self.last_preview_report.get("levels", {})) + bgr = cv2.cvtColor(vis8, self._debayer_code()) + wb_report = {"enabled": False} if apply_wb: - bgr = ( - self.apply_preview_white_balance( - bgr, - strength=wb_strength, - ) + bgr, wb_report = self.apply_preview_white_balance( + bgr, + strength=effective_wb_strength, + return_report=True, + ) + wb_report["enabled"] = True + + clahe_cfg = self.config["clahe"] + clahe_enabled = bool(clahe_cfg["enabled"] if apply_clahe is None else apply_clahe) + if clahe_enabled: + bgr = self._apply_clahe_luminance( + bgr, + float(clahe_cfg["clip_limit"]), + int(clahe_cfg["tile_grid_size"]), ) - if apply_contrast: - bgr = ( - self.apply_preview_contrast( - bgr, - alpha=1.08, - beta=0.0, - ) - ) - - return np.ascontiguousarray( - bgr, - dtype=np.uint8, + saturation_cfg = self.config["saturation"] + saturation_enabled = bool( + saturation_cfg["enabled"] + if apply_saturation is None + else apply_saturation ) + if saturation_enabled: + bgr = self._apply_saturation(bgr, float(saturation_cfg["factor"])) + + contrast_cfg = self.config["contrast"] + if apply_contrast: + bgr = self.apply_preview_contrast( + bgr, + alpha=float(contrast_cfg["alpha"]), + beta=float(contrast_cfg["beta"]), + ) + + sharpen_cfg = self.config["sharpen"] + sharpen_enabled = bool( + sharpen_cfg["enabled"] if apply_sharpen is None else apply_sharpen + ) + if sharpen_enabled: + bgr = self._apply_unsharp( + bgr, + sigma=float(sharpen_cfg["sigma"]), + amount=float(sharpen_cfg["amount"]), + threshold=float(sharpen_cfg["threshold"]), + ) + + if bgr.shape != (self.sensor_height, self.sensor_width, 3): + raise RuntimeError( + f"Preview alterou geometria: {bgr.shape}; esperado=" + f"{(self.sensor_height, self.sensor_width, 3)}" + ) + if bgr.dtype != np.uint8: + raise RuntimeError(f"Preview dtype={bgr.dtype}; esperado=uint8") + + self.last_preview_report = { + **self.get_preview_contract(), + "effective": { + "bit_depth": effective_bit_depth, + "gamma": effective_gamma, + "levels": level_report, + "white_balance": wb_report, + "clahe_enabled": clahe_enabled, + "saturation_enabled": saturation_enabled, + "contrast_enabled": bool(apply_contrast), + "sharpen_enabled": sharpen_enabled, + }, + } + result = np.ascontiguousarray(bgr) + return (result, self.get_last_preview_report()) if return_report else result + + def unpack_raw10_packed( + self, + packed_frame, + sensor_width: Optional[int] = None, + sensor_height: Optional[int] = None, + stride: Optional[int] = None, + ) -> np.ndarray: + """ + Desempacota RAW10 MIPI (4 pixels/5 bytes), aceitando ndarray 1D/2D, + bytes e stride/padding por linha. + """ + width = self.sensor_width if sensor_width is None else int(sensor_width) + height = self.sensor_height if sensor_height is None else int(sensor_height) + if width != self.sensor_width or height != self.sensor_height: + raise ValueError( + "RawProcessorPreview preserva o raster configurado; dimensão " + f"solicitada={width}x{height}, configurada=" + f"{self.sensor_width}x{self.sensor_height}." + ) + if width % 4 != 0: + raise ValueError(f"RAW10 exige largura múltipla de 4: {width}") + + if isinstance(packed_frame, (bytes, bytearray, memoryview)): + packed = np.frombuffer(packed_frame, dtype=np.uint8) + else: + packed = np.asarray(packed_frame) + if packed.ndim == 3 and packed.shape[2] == 1: + packed = packed[:, :, 0] + + useful_width = (width // 4) * 5 + + if packed.dtype != np.uint8: + raise TypeError( + f"RAW10 packed deve ser uint8; shape={packed.shape}, dtype={packed.dtype}" + ) + + if packed.ndim == 1: + if stride is None: + if packed.size % height != 0: + raise ValueError( + f"RAW10 1D possui {packed.size} bytes, não divisível por " + f"height={height}; informe stride." + ) + stride = packed.size // height + stride = int(stride) + expected_bytes = stride * height + if stride < useful_width or packed.size < expected_bytes: + raise ValueError( + f"RAW10 1D curto/inválido: bytes={packed.size}, stride={stride}, " + f"esperado>={expected_bytes}, payload/linha={useful_width}." + ) + packed = packed[:expected_bytes].reshape(height, stride) + elif packed.ndim == 2: + if stride is not None and int(stride) != packed.shape[1]: + raise ValueError( + f"stride={stride} diverge da largura do array={packed.shape[1]}." + ) + else: + raise TypeError( + f"RAW10 packed deve ser 1D ou 2D; shape={packed.shape}." + ) + + if packed.shape[0] != height or packed.shape[1] < useful_width: + raise ValueError( + f"RAW10 packed shape={packed.shape}; esperado >=({height}, {useful_width})" + ) + padding = int(packed.shape[1] - useful_width) + if padding > max(4096, useful_width): + raise ValueError( + f"Padding RAW10 implausível: {padding} bytes/linha; " + "verifique width/height/stride." + ) + + payload = np.ascontiguousarray(packed[:, :useful_width]) + groups = payload.reshape(height, width // 4, 5) + high = groups[:, :, :4].astype(np.uint16) + low = groups[:, :, 4].astype(np.uint16) + + out = np.empty((height, width // 4, 4), dtype=np.uint16) + out[:, :, 0] = (high[:, :, 0] << 2) | (low & 0x03) + out[:, :, 1] = (high[:, :, 1] << 2) | ((low >> 2) & 0x03) + out[:, :, 2] = (high[:, :, 2] << 2) | ((low >> 4) & 0x03) + out[:, :, 3] = (high[:, :, 3] << 2) | ((low >> 6) & 0x03) + return np.ascontiguousarray(out.reshape(height, width)) + + def packed_raw10_to_preview_bgr(self, packed_frame: np.ndarray, **kwargs): + """Atalho oficial RAW10 packed -> preview BGR.""" + raw16 = self.unpack_raw10_packed(packed_frame) + return self.raw16_to_preview_bgr(raw16, **kwargs) def raw16_to_preview_jpg_bytes( self, raw16: np.ndarray, jpeg_quality: int = 95, - **preview_kwargs, ) -> bytes: - quality = int( - jpeg_quality - ) - - if not ( - 1 <= quality <= 100 - ): - raise ValueError( - f"jpeg_quality inválido: {quality}" - ) - - bgr = ( - self.raw16_to_preview_bgr( - raw16, - **preview_kwargs, - ) - ) - - ok, enc = cv2.imencode( + quality = int(np.clip(int(jpeg_quality), 1, 100)) + bgr = self.raw16_to_preview_bgr(raw16) + ok, encoded = cv2.imencode( ".jpg", bgr, - [ - int( - cv2.IMWRITE_JPEG_QUALITY - ), - quality, - ], + [int(cv2.IMWRITE_JPEG_QUALITY), quality], ) - if not ok: - raise RuntimeError( - "Falha ao codificar preview JPG." - ) + raise RuntimeError("Falha ao codificar preview JPG") + return encoded.tobytes() - return enc.tobytes() + def raw16_to_preview_png_bytes( + self, + raw16: np.ndarray, + compression: int = 3, + ) -> bytes: + level = int(np.clip(int(compression), 0, 9)) + bgr = self.raw16_to_preview_bgr(raw16) + ok, encoded = cv2.imencode( + ".png", + bgr, + [int(cv2.IMWRITE_PNG_COMPRESSION), level], + ) + if not ok: + raise RuntimeError("Falha ao codificar preview PNG") + return encoded.tobytes() diff --git a/Python/OAK/datasets/oak-fcc-3/_0_capture.py b/Python/OAK/datasets/oak-fcc-3/_0_capture.py index 4edc51d1f..5b383d80b 100644 --- a/Python/OAK/datasets/oak-fcc-3/_0_capture.py +++ b/Python/OAK/datasets/oak-fcc-3/_0_capture.py @@ -1,19 +1,93 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +_capture_dataset.py +=================== + +Captura de dataset multiespectral em campo. + +Contrato de configuração +------------------------ +O hardware não é descrito por argumentos duplicados nem por config.json. +O script carrega o Module Profile ativo criado pelo Script 0 e, por ele, +resolve o module_params final correspondente ao módulo físico. + +Obrigatórios para o operador: + --cana + --horario + +Opcionais de seleção: + --module-profile usa diretamente um profile específico + --active-profile seletor produzido pelo Script 0 + +Aquisição científica +-------------------- +O payload oficial é sempre RAW_BRUTO nativo e empacotado, uma câmera por +arquivo. Intrinsics, Flat-Field, orientação e Homography não são aplicados aos +bytes salvos. O module_params é carregado pelo OakFcc3Client para que startup, +exposição e diagnóstico sigam o mesmo contrato da aplicação de produção. + +O PNG de anotação é sempre reconstruído pelo RawProcessorPreview a partir do +mesmo RAW10 da CAM_A: auto-level, gamma, demosaico EA, gray-world e contraste. +Depois do mini-ISP visual, o script aplica somente camera_orientation da role +RGB, vinda do module_params. Assim o RAW salvo continua nativo, enquanto o PNG +e a máscara desenhada sobre ele pertencem ao mesmo espaço canônico usado pelo +tensor final. A tecla M apenas alterna essa versão também na tela. + +Isso permite reprocessar o dataset no futuro com calibrações novas. Não permite +recriar calibrações a partir de imagens agrícolas comuns: foco, intrínsecos, +flat-field, radiometria e homografia continuam exigindo seus alvos e protocolos. +""" + +from __future__ import annotations + import os import time import json import argparse +import hashlib from datetime import datetime +from pathlib import Path +from typing import Optional import cv2 import numpy as np from core.oak_fcc3_client import OakFcc3Client as MultiSpectralClient -with open("config.json", "r", encoding="utf-8") as f: - config = json.load(f) +MODULE_PROFILE_SCHEMA = "multispec_module_profile_v1" +ACTIVE_SELECTOR_SCHEMA = "multispec_active_module_profile_v1" +MODULE_PARAMS_SCHEMA = "multispec_module_params_v3" +ROLES = ("rgb", "re", "nir") +BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") -RAW_SIZE = config.get("raw_size") # [W, H] -MODULE_PARAMS = config.get("module_params_json") +ANNOTATION_PREVIEW_CONFIG = { + "schema": "rgb_raw_annotation_preview_v2", + "version": "raw_processor_preview_v2_plus_canonical_orientation_2026_09_10", + "purpose": "human_annotation_only", + "implementation": "core.raw_processor_preview.RawProcessorPreview", + "levels": { + "mode": "robust_percentile", + "black_percentile": 0.20, + "white_percentile": 99.80, + }, + "gamma": 2.2, + "demosaic": { + "algorithm": "opencv_edge_aware", + "output_color_order": "BGR", + }, + "white_balance": { + "method": "neutral_bright_with_gray_world_fallback", + "strength": 0.65, + "gain_range": [0.60, 1.70], + }, + "clahe": {"clip_limit": 1.55, "tile_grid_size": 8}, + "saturation_factor": 1.04, + "contrast": {"alpha": 1.04, "beta": 0.0}, + "sharpen": {"sigma": 0.85, "amount": 0.55, "threshold": 2.0}, + "output_format": "PNG_BGR_U8", +} # ========================= @@ -24,6 +98,342 @@ def ts_name() -> str: return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] +def sha256_file(path: str | Path) -> str: + 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 sha256_array(arr: np.ndarray) -> str: + value = np.ascontiguousarray(arr) + return hashlib.sha256(value.view(np.uint8)).hexdigest() + + +def load_json(path: str | Path) -> dict: + p = Path(path) + if not p.is_file(): + raise FileNotFoundError(f"JSON obrigatório não encontrado: {p}") + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise RuntimeError(f"JSON root deve ser objeto: {p}") + return data + + +def resolve_rgb_annotation_orientation( + module_params: dict, + expected_native_size: tuple[int, int], +) -> dict: + """Resolve exatamente a mesma orientação RGB que o RawProcessorCore usa.""" + root = module_params.get("camera_orientation") + if not isinstance(root, dict): + raise RuntimeError("module_params sem camera_orientation.") + + by_role = root.get("by_role") + if not isinstance(by_role, dict) or not isinstance(by_role.get("rgb"), dict): + raise RuntimeError("camera_orientation.by_role.rgb ausente.") + + role_cfg = by_role["rgb"] + try: + rotate_deg = int(role_cfg.get("rotate_deg", 0)) % 360 + except Exception as exc: + raise RuntimeError("camera_orientation RGB rotate_deg inválido.") from exc + if rotate_deg not in (0, 90, 180, 270): + raise RuntimeError(f"camera_orientation RGB rotate_deg inválido: {rotate_deg}") + + flip_h = role_cfg.get("flip_horizontal", False) + flip_v = role_cfg.get("flip_vertical", False) + if not isinstance(flip_h, bool) or not isinstance(flip_v, bool): + raise RuntimeError("camera_orientation RGB flips devem ser bool.") + + enabled = root.get("enabled", False) + if not isinstance(enabled, bool): + raise RuntimeError("camera_orientation.enabled deve ser bool.") + + transform_declared = rotate_deg != 0 or flip_h or flip_v + if transform_declared and not enabled: + raise RuntimeError( + "camera_orientation RGB declara transformação, mas enabled=false." + ) + + native_w, native_h = map(int, expected_native_size) + declared_native = role_cfg.get("native_size") + if declared_native is not None: + if list(map(int, declared_native)) != [native_w, native_h]: + raise RuntimeError( + "camera_orientation RGB native_size diverge do hardware: " + f"{declared_native} != {[native_w, native_h]}" + ) + + if enabled and rotate_deg in (90, 270): + oriented_size = [native_h, native_w] + else: + oriented_size = [native_w, native_h] + + declared_oriented = role_cfg.get("oriented_size") + if declared_oriented is not None: + if list(map(int, declared_oriented)) != oriented_size: + raise RuntimeError( + "camera_orientation RGB oriented_size inconsistente: " + f"{declared_oriented} != {oriented_size}" + ) + + return { + "enabled": enabled, + "rotate_deg": rotate_deg if enabled else 0, + "flip_horizontal": flip_h if enabled else False, + "flip_vertical": flip_v if enabled else False, + "native_size": [native_w, native_h], + "oriented_size": oriented_size, + "input_space": str(root.get("input_space") or "native_stream_no_external_undistort"), + "output_space": str(root.get("output_space") or "canonical_oriented_stream_no_external_undistort"), + "apply_stage": str(root.get("apply_stage") or "after_native_flat_before_fusion"), + } + + +def apply_rgb_annotation_orientation( + image: np.ndarray, + orientation: dict, +) -> np.ndarray: + """Aplica rotate e flips na mesma ordem usada pelo RawProcessorCore.""" + out = np.asarray(image) + rotate_deg = int(orientation.get("rotate_deg", 0)) % 360 + + if rotate_deg == 90: + out = cv2.rotate(out, cv2.ROTATE_90_CLOCKWISE) + elif rotate_deg == 180: + out = cv2.rotate(out, cv2.ROTATE_180) + elif rotate_deg == 270: + out = cv2.rotate(out, cv2.ROTATE_90_COUNTERCLOCKWISE) + elif rotate_deg != 0: + raise RuntimeError(f"rotate_deg RGB inválido: {rotate_deg}") + + if bool(orientation.get("flip_horizontal", False)): + out = cv2.flip(out, 1) + if bool(orientation.get("flip_vertical", False)): + out = cv2.flip(out, 0) + + return np.ascontiguousarray(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.extend(parent / raw for parent in anchor.resolve().parents) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + tried = ", ".join(str(x) for x in candidates) + raise FileNotFoundError(f"Arquivo de contrato não encontrado. Tentativas: {tried}") + + +def canonical_hardware_signature(value: dict) -> dict: + if not isinstance(value, dict): + raise RuntimeError("hardware_signature inválida no Module Profile.") + + 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", item.get("socket_name")) + sensor = item.get("sensor", item.get("sensor_name")) + size = item.get("size") + if size is None: + width = item.get("width", item.get("configured_width")) + height = item.get("height", item.get("configured_height")) + if width is not None and height is not None: + size = [width, 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 load_module_profile_contract( + module_profile_arg: Optional[str], + active_profile_arg: str, +) -> dict: + selector = None + selector_path = None + + if module_profile_arg: + profile_path = resolve_existing_path(module_profile_arg) + else: + selector_path = resolve_existing_path(active_profile_arg) + 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("Active Module Profile sem profile_path.") + profile_path = resolve_existing_path(declared, anchor=selector_path) + + profile = load_json(profile_path) + if profile.get("schema") != MODULE_PROFILE_SCHEMA: + raise RuntimeError( + f"Schema do Module Profile inesperado: {profile.get('schema')!r}" + ) + if str(profile.get("status") or "").lower() != "active": + raise RuntimeError(f"Module Profile não está ativo: {profile.get('status')!r}") + for key in ("profile_name", "device_mx_id", "artifact_paths", "rgb_decode"): + if not profile.get(key): + raise RuntimeError(f"Module Profile sem {key}.") + + signature = canonical_hardware_signature(profile.get("hardware_signature")) + decode = profile["rgb_decode"] + bayer = str(decode.get("bayer_pattern") or "").upper() + if bayer not in BAYER_PATTERNS: + raise RuntimeError(f"rgb_decode.bayer_pattern inválido: {bayer!r}") + + module_params_declared = profile["artifact_paths"].get("module_params_json") + if not module_params_declared: + raise RuntimeError("Module Profile sem artifact_paths.module_params_json.") + + profile_sha = sha256_file(profile_path) + if selector is not None: + selector_sha = str(selector.get("profile_sha256") or "").lower() + if selector_sha and selector_sha != profile_sha.lower(): + raise RuntimeError( + "Hash do Module Profile diverge do seletor ativo. Rode novamente o Script 0." + ) + if str(selector.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError("MX ID do seletor diverge do Module Profile.") + + module_params_path = resolve_existing_path( + module_params_declared, + anchor=profile_path, + ) + module_params = load_json(module_params_path) + if module_params.get("schema") != MODULE_PARAMS_SCHEMA: + raise RuntimeError( + f"Schema do module_params inesperado: {module_params.get('schema')!r}" + ) + + provenance = module_params.get("module_profile_contract") + if not isinstance(provenance, dict): + raise RuntimeError( + "module_params sem module_profile_contract. Gere-o novamente com o assembler novo." + ) + if str(provenance.get("profile_sha256") or "").lower() != profile_sha.lower(): + raise RuntimeError( + "module_params foi montado a partir de outra versão do Module Profile." + ) + if str(provenance.get("profile_name") or "") != str(profile["profile_name"]): + raise RuntimeError("profile_name do module_params diverge do Module Profile.") + if str(provenance.get("device_mx_id") or "") != str(profile["device_mx_id"]): + raise RuntimeError("MX ID do module_params diverge do Module Profile.") + + mp_signature = canonical_hardware_signature(module_params.get("camera_hardware")) + if mp_signature != signature: + raise RuntimeError("camera_hardware do module_params diverge do Module Profile.") + if str(module_params.get("bayer_pattern") or "").upper() != bayer: + raise RuntimeError("Bayer do module_params diverge do Module Profile.") + mp_rgb = module_params.get("rgb_processing") + if not isinstance(mp_rgb, dict): + raise RuntimeError("module_params sem rgb_processing.") + for key in ("mode", "demosaic_algorithm"): + expected = str(decode.get(key) or "").lower() + actual = str(mp_rgb.get(key) or "").lower() + if actual != expected: + raise RuntimeError( + f"rgb_processing.{key}={actual!r} diverge do Module Profile ({expected!r})." + ) + + return { + "profile": profile, + "profile_path": profile_path, + "profile_sha256": profile_sha, + "selector_path": selector_path, + "hardware_signature": signature, + "bayer_pattern": bayer, + "module_params": module_params, + "module_params_path": module_params_path, + "module_params_sha256": sha256_file(module_params_path), + } + + +def validate_raw_triplet( + packed_by_camera: dict, + meta: dict, + hardware_signature: dict, +) -> dict: + """Valida que o bundle contém exatamente uma fonte útil por role esperada.""" + if not isinstance(packed_by_camera, dict): + raise RuntimeError("RAW_BRUTO multiespectral não veio como dict por câmera.") + + camera_info = meta.get("camera_info", {}) or {} + if not isinstance(camera_info, dict): + raise RuntimeError("stream_meta.camera_info inválido.") + + resolved = {} + for cam_id, arr in packed_by_camera.items(): + info = camera_info.get(cam_id, {}) or {} + role = str(info.get("role") or "").lower() + if role not in ROLES: + for candidate, expected in hardware_signature.items(): + if str(cam_id) == expected["socket"]: + role = candidate + break + if role not in ROLES: + continue + if role in resolved: + raise RuntimeError(f"Bundle RAW possui duas fontes para a role {role}.") + + expected = hardware_signature[role] + expected_w, expected_h = expected["size"] + declared_w = info.get("width") + declared_h = info.get("height") + declared_sensor = info.get("sensor", info.get("sensor_name")) + if declared_w is not None and int(declared_w) != expected_w: + raise RuntimeError(f"{role}: width={declared_w}, esperado={expected_w}.") + if declared_h is not None and int(declared_h) != expected_h: + raise RuntimeError(f"{role}: height={declared_h}, esperado={expected_h}.") + if declared_sensor and str(declared_sensor).upper() != expected["sensor"]: + raise RuntimeError( + f"{role}: sensor={declared_sensor}, esperado={expected['sensor']}." + ) + + value = np.asarray(arr) + if value.dtype != np.uint8: + raise RuntimeError( + f"{role}: payload RAW empacotado deve ser uint8; recebido={value.dtype}." + ) + minimum_bytes = expected_w * expected_h * 10 // 8 + if value.nbytes < minimum_bytes: + raise RuntimeError( + f"{role}: RAW10 curto ({value.nbytes} bytes; mínimo={minimum_bytes})." + ) + resolved[role] = { + "camera_id": str(cam_id), + "socket": expected["socket"], + "sensor": expected["sensor"], + "size": [expected_w, expected_h], + "bytes": int(value.nbytes), + } + + missing = [role for role in ROLES if role not in resolved] + if missing: + raise RuntimeError(f"Bundle RAW incompleto; roles ausentes: {missing}") + return resolved + + def overlay_hud( img_bgr: np.ndarray, lines: list[str], @@ -71,50 +481,72 @@ def save_sample( raise ValueError(f"raw_payload não pode ser None quando frame_type='{frame_type}'") payload_path = os.path.join(base_dir, f"{name}.raw") - raw_payload.astype(np.float32).tofile(payload_path) + payload_value = raw_payload.astype(np.float32) + payload_tmp = payload_path + ".tmp" + payload_value.tofile(payload_tmp) + os.replace(payload_tmp, payload_path) meta["saved_payload_type"] = frame_type.lower() meta["saved_payload_path"] = os.path.basename(payload_path) meta["saved_payload_dtype"] = "float32" meta["saved_payload_shape"] = list(raw_payload.shape) + meta["saved_payload_sha256"] = sha256_array(payload_value) elif frame_type == "RAW_BRUTO": if packed_raw_by_camera is not None: payload_files = {} payload_shapes = {} payload_dtypes = {} + payload_hashes = {} for cam_id, arr in packed_raw_by_camera.items(): path = os.path.join(base_dir, f"{name}_{cam_id}.bin") - arr.tofile(path) + value = np.ascontiguousarray(arr) + tmp = path + ".tmp" + value.tofile(tmp) + os.replace(tmp, path) payload_files[cam_id] = os.path.basename(path) - payload_shapes[cam_id] = list(arr.shape) - payload_dtypes[cam_id] = str(arr.dtype) + payload_shapes[cam_id] = list(value.shape) + payload_dtypes[cam_id] = str(value.dtype) + payload_hashes[cam_id] = sha256_array(value) meta["saved_payload_type"] = "raw_native_multi" meta["saved_payload_paths"] = payload_files meta["saved_payload_shapes"] = payload_shapes meta["saved_payload_dtypes"] = payload_dtypes + meta["saved_payload_sha256"] = payload_hashes else: if packed_raw is None: raise ValueError("packed_raw não pode ser None quando frame_type='RAW_BRUTO'") payload_path = os.path.join(base_dir, f"{name}.bin") - packed_raw.tofile(payload_path) + packed_value = np.ascontiguousarray(packed_raw) + payload_tmp = payload_path + ".tmp" + packed_value.tofile(payload_tmp) + os.replace(payload_tmp, payload_path) meta["saved_payload_type"] = "raw_native_single" meta["saved_payload_path"] = os.path.basename(payload_path) - meta["saved_payload_dtype"] = str(packed_raw.dtype) - meta["saved_payload_shape"] = list(packed_raw.shape) + meta["saved_payload_dtype"] = str(packed_value.dtype) + meta["saved_payload_shape"] = list(packed_value.shape) + meta["saved_payload_sha256"] = sha256_array(packed_value) else: raise ValueError(f"frame_type não suportado para save: {frame_type}") - cv2.imwrite(png_path, preview_bgr) + png_tmp = os.path.join(base_dir, f"{name}.tmp.png") + if not cv2.imwrite(png_tmp, preview_bgr): + raise RuntimeError(f"Falha ao salvar preview: {png_tmp}") + os.replace(png_tmp, png_path) - with open(json_path, "w", encoding="utf-8") as f: + json_tmp = json_path + ".tmp" + with open(json_tmp, "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(json_tmp, json_path) return png_path, json_path @@ -126,26 +558,171 @@ def get_camera_map_from_status(status: dict) -> dict: return result -def build_preview_to_save(cam, frame_type_save, last_preview_bgr, last_packed_raw_by_camera, last_meta_stream, raw_w, raw_h, bayer): - preview_to_save = last_preview_bgr - method = "last_screen_preview" +def build_preview_to_save( + cam, + packed_raw_by_camera: dict, + meta_stream: dict, + raw_integrity: dict, + expected_size: tuple[int, int], + bayer_pattern: str, + rgb_orientation: dict, +) -> tuple[np.ndarray, str, dict]: + """ + Gera o PNG beauty somente a partir do RAW RGB/CAM_A e o leva ao + espaço canônico de anotação. - if frame_type_save == "RAW_BRUTO" and last_packed_raw_by_camera is not None: - rebuilt_preview = cam.build_save_preview_from_cam_a( - packed_raw_by_camera=last_packed_raw_by_camera, - meta_stream=last_meta_stream, - sensor_width=raw_w, - sensor_height=raw_h, - bayer_pattern=bayer, + O RawProcessorPreview pertence ao sensor RGB e, portanto, nunca deve + receber os RAWs mono RE/NIR (que possuem outro raster). + """ + rgb_info = raw_integrity.get("rgb") or {} + cam_id = rgb_info.get("camera_id") + if not cam_id or cam_id not in packed_raw_by_camera: + raise RuntimeError("Não foi possível localizar o RAW RGB para gerar o preview.") + + preview_processor = getattr(cam, "preview", None) + if preview_processor is None: + raise RuntimeError("Cliente OAK não expõe o RawProcessorPreview RGB.") + + build_rgb_preview = getattr( + preview_processor, + "packed_raw10_to_preview_bgr", + None, + ) + if not callable(build_rgb_preview): + raise RuntimeError( + "RawProcessorPreview não expõe packed_raw10_to_preview_bgr()." ) - if rebuilt_preview is not None: - preview_to_save = rebuilt_preview - method = "cam_a_reconstructed_raw10" - else: - method = "last_screen_preview_fallback" + # Fundamental: processar apenas CAM_A. Não passe o dicionário completo, + # pois CAM_B/C são OV9282 1280x800 e este processor foi configurado para + # o raster Bayer do RGB (por exemplo, AR0234 1920x1200). + preview = build_rgb_preview(packed_raw_by_camera[cam_id]) - return preview_to_save, method + preview = np.asarray(preview) + expected_w, expected_h = map(int, expected_size) + if preview.ndim != 3 or preview.shape[2] != 3 or preview.dtype != np.uint8: + raise RuntimeError( + f"Preview RGB deve ser HWC/BGR/uint8; shape={preview.shape}, dtype={preview.dtype}." + ) + if preview.shape[:2] != (expected_h, expected_w): + raise RuntimeError( + "RawProcessorPreview alterou o raster RGB: " + f"{preview.shape[:2]} != {(expected_h, expected_w)}. " + "O mini-ISP visual deve preservar o raster nativo." + ) + + preview = apply_rgb_annotation_orientation(preview, rgb_orientation) + + oriented_w, oriented_h = map(int, rgb_orientation["oriented_size"]) + if preview.shape != (oriented_h, oriented_w, 3): + raise RuntimeError( + "Orientação RGB produziu raster inesperado: " + f"{preview.shape} != {(oriented_h, oriented_w, 3)}" + ) + + orientation_applied = { + "rotate_deg": int(rgb_orientation["rotate_deg"]), + "flip_horizontal": bool(rgb_orientation["flip_horizontal"]), + "flip_vertical": bool(rgb_orientation["flip_vertical"]), + } + transformed = any(( + orientation_applied["rotate_deg"] != 0, + orientation_applied["flip_horizontal"], + orientation_applied["flip_vertical"], + )) + + report = { + **ANNOTATION_PREVIEW_CONFIG, + "camera_id": str(cam_id), + "bayer_pattern": str(bayer_pattern).upper(), + "geometry": "rgb_canonical_oriented_no_external_undistort", + "source_space": rgb_orientation["input_space"], + "coordinate_space": rgb_orientation["output_space"], + "source_image_size": [expected_w, expected_h], + "image_size": [oriented_w, oriented_h], + "orientation_applied": orientation_applied, + "orientation_apply_stage": rgb_orientation["apply_stage"], + "pixel_correspondence": "deterministic_orientation_of_rgb_sensor_raster", + "calibrations_applied": ["camera_orientation"] if transformed else [], + "mask_contract": { + "coordinate_space": rgb_orientation["output_space"], + "already_oriented": True, + "normalize_must_not_reapply_camera_orientation": True, + "downstream_geometry": ["common_crop", "final_resize_nearest"], + }, + } + return ( + np.ascontiguousarray(preview), + "raw_processor_preview_rgb_beauty_canonical_v2", + report, + ) + + +def build_capture_metadata( + *, + args, + contract: dict, + stream_meta: dict, + cam, + effective_capture_mode: str, + raw_policy: str, + raw_integrity: dict, + preview_source_id: str, + annotation_preview: dict, + note: str, +) -> dict: + profile = contract["profile"] + signature = contract["hardware_signature"] + rgb_w, rgb_h = signature["rgb"]["size"] + return { + "schema": "multispec_raw_dataset_sample_v2", + "ts": datetime.now().isoformat(timespec="milliseconds"), + "cana": args.cana, + "horario": args.horario, + "sensor_width": rgb_w, + "sensor_height": rgb_h, + "sensor_size_by_role": { + role: list(signature[role]["size"]) + for role in ROLES + }, + "camera_hardware": signature, + "bayer_pattern": contract["bayer_pattern"], + "fps_target": args.fps, + "frame_type": "RAW_BRUTO", + "capture_mode_requested": effective_capture_mode, + "capture_mode_effective": effective_capture_mode, + "raw_policy": raw_policy, + "raw_integrity": raw_integrity, + "stream_meta": stream_meta, + "startup_camera_controls": cam.applied_camera_controls, + "actual_camera_controls": cam.get_current_camera_controls(), + "radiometric_last_result": cam.get_radiometric_last_result(), + "module_profile_contract": { + "profile_name": profile["profile_name"], + "profile_path": contract["profile_path"].as_posix(), + "profile_sha256": contract["profile_sha256"], + "device_mx_id": profile["device_mx_id"], + }, + "module_params_contract": { + "path": contract["module_params_path"].as_posix(), + "sha256": contract["module_params_sha256"], + "schema": contract["module_params"].get("schema"), + }, + "rgb_decode_contract": dict(profile["rgb_decode"]), + "calibration_application": { + "payload": "none_raw_native_preserved", + "preview_only": True, + "note": ( + "Module params governa controles de aquisição. O PNG de anotação " + "usa RawProcessorPreview no RAW RGB e depois aplica somente a " + "camera_orientation canônica. Calibrações radiométricas, Flat-Field, " + "Intrinsics e Homography não alteram o PNG nem os bytes RAW_BRUTO." + ), + }, + "annotation_preview": annotation_preview, + "note": note, + "raw_preview_reference_camera": preview_source_id, + } # ========================= @@ -154,31 +731,68 @@ def build_preview_to_save(cam, frame_type_save, last_preview_bgr, last_packed_ra def main(): parser = argparse.ArgumentParser( - description="Captura de dataset usando módulo multispectral Pi + StreamReceiver.", + description=( + "Captura RAW_BRUTO de dataset usando o Module Profile ativo e " + "o module_params final correspondente." + ), formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("--cana", required=True, choices=["baixa", "media", "alta"], help="Estado da cana no momento da coleta.") parser.add_argument("--horario", required=True, choices=["cedo", "meio_dia", "entardecer", "nublado"], help="Janela de iluminação / horário da coleta.") - parser.add_argument("--out_root", default="dataset", help="Pasta raiz do dataset.") - parser.add_argument("--fps", type=int, default=20, help="FPS desejado.") - parser.add_argument("--width", type=int, default=RAW_SIZE[0], help="Largura óptica da câmera.") - parser.add_argument("--height", type=int, default=RAW_SIZE[1], help="Altura óptica da câmera.") + parser.add_argument( + "--module-profile", + default=None, + help="Override opcional do Module Profile. Sem ele, usa o seletor ativo.", + ) + parser.add_argument( + "--active-profile", + default="calibration/active_module_profile.json", + help="Seletor ativo criado pelo Script 0.", + ) + parser.add_argument("--out-root", default="dataset", help="Pasta raiz do dataset.") + parser.add_argument("--fps", type=float, default=20.0, help="FPS de aquisição; política operacional, não identidade do sensor.") parser.add_argument("--interval", type=float, default=1.0, help="Intervalo em segundos para auto-save quando ligado.") - parser.add_argument("--preview_upscale", type=int, default=2, help="Fator de upscale visual do preview.") - parser.add_argument("--bayer", default="BGGR", choices=["GBRG", "GRBG", "RGGB", "BGGR"], help="Padrão Bayer das câmeras.") - parser.add_argument("--output_dtype", default="float32", choices=["uint8", "uint16", "float32"], help="Dtype do payload processado no Pi.") - parser.add_argument("--frame_type", default="RAW_BRUTO", choices=["RAW_BRUTO", "RGB", "MULTISPEC"], help="Tipo de payload pedido ao Pi.") - parser.add_argument("--capture_mode", default="AUTO", choices=["AUTO", "SINGLE", "DOUBLE", "TRIPLE"], help="Modo de captura desejado no módulo.") - parser.add_argument("--raw_policy", default="allow_single", choices=["allow_single", "require_triple"], help="Quando frame_type=RAW_BRUTO, define se o script aceita 1 câmera ou exige 3.") - parser.add_argument("--module_calibration_json", default=MODULE_PARAMS, help="JSON salvo pelo calibrador de sensores com parâmetros fixos por câmera.") + parser.add_argument("--preview-upscale", type=int, default=2, help="Fator de upscale exclusivamente visual.") args = parser.parse_args() - effective_capture_mode = args.capture_mode + if args.fps <= 0: + raise ValueError("--fps deve ser > 0.") + if args.interval <= 0: + raise ValueError("--interval deve ser > 0.") + if args.preview_upscale < 1: + raise ValueError("--preview-upscale deve ser >= 1.") - raw_w = args.width - raw_h = args.height + contract = load_module_profile_contract( + args.module_profile, + args.active_profile, + ) + profile = contract["profile"] + module_params = contract["module_params"] + hardware_signature = contract["hardware_signature"] + + # Dataset científico: não salvamos frames processados nem bundles parciais. + frame_type_requested = "RAW_BRUTO" + output_dtype = "float32" # Não altera o payload quando frame_type=RAW_BRUTO. + effective_capture_mode = str( + module_params.get("capture_mode_effective") + or module_params.get("capture_mode_requested") + or "AUTO" + ).upper() + if effective_capture_mode not in {"AUTO", "SINGLE", "DOUBLE", "TRIPLE"}: + raise RuntimeError( + f"capture_mode_effective inválido no module_params: {effective_capture_mode!r}" + ) + raw_policy = "require_triple" + + raw_w, raw_h = hardware_signature["rgb"]["size"] + bayer_pattern = contract["bayer_pattern"] + module_params_path = contract["module_params_path"] + rgb_orientation = resolve_rgb_annotation_orientation( + module_params, + expected_native_size=(raw_w, raw_h), + ) session_dir = os.path.join( args.out_root, @@ -194,10 +808,27 @@ def main(): print(f"Cana : {args.cana}") print(f"Horário : {args.horario}") print(f"Saída : {session_dir}") - print(f"Sensor : {raw_w}x{raw_h} | Bayer={args.bayer}") - print(f"FrameType : {args.frame_type}") - print(f"CaptureMode : {args.capture_mode} -> efetivo={effective_capture_mode}") - print(f"RAW policy : {args.raw_policy}") + print(f"Profile : {profile['profile_name']}") + print(f"MX ID : {profile['device_mx_id']}") + print(f"Module Params: {module_params_path}") + for role in ROLES: + hw = hardware_signature[role] + print( + f"{role.upper():3s} : {hw['socket']} | {hw['sensor']} | " + f"{hw['size'][0]}x{hw['size'][1]}" + ) + print(f"RGB Bayer : {bayer_pattern}") + print( + "RGB Preview : CANONICAL | " + f"ROT={rgb_orientation['rotate_deg']} " + f"FH={int(rgb_orientation['flip_horizontal'])} " + f"FV={int(rgb_orientation['flip_vertical'])} | " + f"{rgb_orientation['native_size']} -> " + f"{rgb_orientation['oriented_size']}" + ) + print(f"FrameType : {frame_type_requested} (fixo científico)") + print(f"CaptureMode : {effective_capture_mode} (module_params)") + print(f"RAW policy : {raw_policy} (fixo de dataset)") print("============================================") beauty_preview = False @@ -222,109 +853,69 @@ def main(): cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) last_frame_id = -1 - last_payload_float = None - last_packed_raw = None last_packed_raw_by_camera = None last_preview_bgr = None last_meta_stream = None + last_raw_integrity = None + last_saved_frame_id = None + last_valid_frame_t = 0.0 try: with MultiSpectralClient( - #mx_id="194430108133AC2F00", + mx_id=str(profile["device_mx_id"]), width=raw_w, height=raw_h, - bayer=args.bayer, + bayer=bayer_pattern, fps=args.fps, - frame_type=args.frame_type, - output_dtype=args.output_dtype, + frame_type=frame_type_requested, + output_dtype=output_dtype, capture_mode=effective_capture_mode, - raw_policy=args.raw_policy, - module_calibration_json=args.module_calibration_json, + raw_policy=raw_policy, + module_calibration_json=str(module_params_path), ) as cam: + rad = getattr(cam, "radiometric_controller", None) + radiometric_ae = bool(rad is not None and rad.enabled) while True: t0 = time.time() - frame, meta, decoded = cam.get_next_decoded(timeout=1.0) + frame, meta, _decoded = cam.get_next_decoded(timeout=1.0) if meta is not None and frame is not None and meta.get("frame_id") != last_frame_id: last_frame_id = meta["frame_id"] try: - frame_type = meta.get("frame_type", "RAW_BRUTO") - dtype_str = meta.get("dtype") or meta.get("output_dtype", "uint8") - preview_source_id = "rgb" + frame_type = str(meta.get("frame_type") or "").upper() + if frame_type != "RAW_BRUTO": + raise RuntimeError( + f"Servidor retornou {frame_type!r}; este capturador aceita somente RAW_BRUTO." + ) + raw_integrity = validate_raw_triplet( + frame, + meta, + hardware_signature, + ) + packed_by_camera = frame + preview_bgr, _, _ = ( + cam.build_preview_from_raw_payload(frame=frame, meta=meta) + ) - if frame_type == "RAW_BRUTO": - if isinstance(frame, dict): - packed_by_camera = frame + if preview_bgr is None: + raise RuntimeError("OakFcc3Client não conseguiu montar o preview RAW.") - preview_bgr, raw3_preview, preview_source_id = cam.build_preview_from_raw_payload(frame=frame, meta=meta) - - if beauty_preview: - rgb_preview = None - previews = cam.build_visual_preview_from_raw(frame, meta) - camera_info = meta.get("camera_info", {}) or {} - for cam_id, img in previews.items(): - role = camera_info.get(cam_id, {}).get("role") - if role == "rgb": - rgb_preview = img - preview_source_id = cam_id - break - if rgb_preview is not None: - preview_bgr = rgb_preview - - last_packed_raw = None - last_packed_raw_by_camera = {cam_id: arr.copy() for cam_id, arr in packed_by_camera.items()} - last_payload_float = raw3_preview.copy() - - elif frame_type == "RGB": - rgb_chw = frame - if not isinstance(rgb_chw, np.ndarray) or rgb_chw.ndim != 3: - raise RuntimeError(f"Frame RGB inválido: type={type(rgb_chw)}") - - if dtype_str == "uint8": - payload_float = rgb_chw.astype(np.float32) / 255.0 - elif dtype_str == "float32": - payload_float = rgb_chw.astype(np.float32) - elif dtype_str == "uint16": - payload_float = rgb_chw.astype(np.float32) / 65535.0 - else: - raise RuntimeError(f"dtype RGB não suportado: {dtype_str}") - - preview_rgb = np.transpose(payload_float, (1, 2, 0)) - preview_bgr = cv2.cvtColor( - np.clip(preview_rgb * 255.0, 0, 255).astype(np.uint8), - cv2.COLOR_RGB2BGR + if beauty_preview: + preview_bgr, _, _ = build_preview_to_save( + cam=cam, + packed_raw_by_camera=packed_by_camera, + meta_stream=meta, + raw_integrity=raw_integrity, + expected_size=(raw_w, raw_h), + bayer_pattern=bayer_pattern, + rgb_orientation=rgb_orientation, ) - last_payload_float = payload_float.copy() - last_packed_raw = None - last_packed_raw_by_camera = None - - elif frame_type == "MULTISPEC": - multispec_chw = frame - if not isinstance(multispec_chw, np.ndarray) or multispec_chw.ndim != 3 or multispec_chw.shape[0] not in (4, 5): - raise RuntimeError(f"Frame MULTISPEC inválido: shape={getattr(multispec_chw, 'shape', None)}") - - if dtype_str == "uint8": - payload_float = multispec_chw.astype(np.float32) / 255.0 - elif dtype_str == "float32": - payload_float = multispec_chw.astype(np.float32) - elif dtype_str == "uint16": - payload_float = multispec_chw.astype(np.float32) / 65535.0 - else: - raise RuntimeError(f"dtype MULTISPEC não suportado: {dtype_str}") - - preview_rgb = np.transpose(payload_float[:3], (1, 2, 0)) - preview_bgr = cv2.cvtColor( - np.clip(preview_rgb * 255.0, 0, 255).astype(np.uint8), - cv2.COLOR_RGB2BGR - ) - - last_payload_float = payload_float.copy() - last_packed_raw = None - last_packed_raw_by_camera = None - - else: - raise RuntimeError(f"frame_type não suportado neste script: {frame_type}") + last_packed_raw_by_camera = { + cam_id: np.ascontiguousarray(arr).copy() + for cam_id, arr in packed_by_camera.items() + } + last_raw_integrity = raw_integrity if preview_upscale and preview_upscale > 1: preview_show = cv2.resize( @@ -389,11 +980,11 @@ def main(): ) lines = [ f"CANA: {args.cana} | HORA: {args.horario} | Pasta: {os.path.basename(session_dir)}", - f"Type={meta.get('frame_type')} | CaptureMode={effective_capture_mode} | RAW policy={args.raw_policy}", + f"Type={meta.get('frame_type')} | CaptureMode={effective_capture_mode} | RAW policy={raw_policy}", f"Sources={active_sources} | FPS_STREAM={fps_stream:.1f} | FPS_VIEW={fps_view:.1f}", f"frame_id={meta.get('frame_id')} | layout={meta.get('output_layout')} | dtype={meta.get('dtype') or meta.get('output_dtype')}", f"codec={meta.get('codec_name', meta.get('codec_family', '-'))} | comp={meta.get('dt_comp', 0):.4f}s | send={meta.get('dt_send_payload_prev', 0):.4f}s", - f"CAM_PARAMS={os.path.basename(args.module_calibration_json)}", + f"PROFILE={profile['profile_name']} | MP={module_params_path.name}", line_ae, line_rad, "Keys: C/SPACE=save | A=auto-save | M=preview | R=rad | Q/Esc=quit" @@ -408,8 +999,12 @@ def main(): last_preview_bgr = preview_bgr.copy() last_meta_stream = dict(meta) + last_meta_stream["frame_type"] = frame_type + last_valid_frame_t = time.time() except Exception as e: + last_packed_raw_by_camera = None + last_raw_integrity = None err = np.zeros((500, 1200, 3), dtype=np.uint8) cv2.putText(err, f"Erro ao processar frame: {e}", (20, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, cv2.LINE_AA) @@ -420,45 +1015,44 @@ def main(): can_save = ( last_meta_stream is not None and last_preview_bgr is not None and - ( - (last_meta_stream.get("frame_type") in ("RGB", "MULTISPEC") and last_payload_float is not None) or - (last_meta_stream.get("frame_type") == "RAW_BRUTO" and (last_packed_raw is not None or last_packed_raw_by_camera is not None)) - ) + last_meta_stream.get("frame_type") == "RAW_BRUTO" and + last_packed_raw_by_camera is not None and + last_raw_integrity is not None and + (now - last_valid_frame_t) <= 2.0 ) - if auto_save and can_save and (now - last_auto_t) >= args.interval: + if ( + auto_save + and can_save + and (now - last_auto_t) >= args.interval + and last_meta_stream.get("frame_id") != last_saved_frame_id + ): frame_type_save = last_meta_stream.get("frame_type") + frame_id_save = last_meta_stream.get("frame_id") - meta_save = { - "ts": datetime.now().isoformat(timespec="milliseconds"), - "cana": args.cana, - "horario": args.horario, - "sensor_width": raw_w, - "sensor_height": raw_h, - "bayer_pattern": args.bayer, - "fps_target": args.fps, - "frame_type": frame_type_save, - "capture_mode_requested": args.capture_mode, - "capture_mode_effective": effective_capture_mode, - "raw_policy": args.raw_policy, - "stream_meta": last_meta_stream, - "startup_camera_controls": cam.applied_camera_controls, - "actual_camera_controls": cam.get_current_camera_controls(), - "radiometric_last_result": cam.get_radiometric_last_result(), - "camera_params_json": args.module_calibration_json, - "note": "autosave", - "raw_preview_reference_camera": preview_source_id, - } - - preview_to_save, preview_method = build_preview_to_save( + preview_to_save, preview_method, preview_report = ( + build_preview_to_save( + cam=cam, + packed_raw_by_camera=last_packed_raw_by_camera, + meta_stream=last_meta_stream, + raw_integrity=last_raw_integrity, + expected_size=(raw_w, raw_h), + bayer_pattern=bayer_pattern, + rgb_orientation=rgb_orientation, + ) + ) + + meta_save = build_capture_metadata( + args=args, + contract=contract, + stream_meta=last_meta_stream, cam=cam, - frame_type_save=frame_type_save, - last_preview_bgr=last_preview_bgr, - last_packed_raw_by_camera=last_packed_raw_by_camera, - last_meta_stream=last_meta_stream, - raw_w=raw_w, - raw_h=raw_h, - bayer=args.bayer, + effective_capture_mode=effective_capture_mode, + raw_policy=raw_policy, + raw_integrity=last_raw_integrity, + preview_source_id=last_raw_integrity["rgb"]["camera_id"], + annotation_preview=preview_report, + note="autosave", ) meta_save["saved_preview_method"] = preview_method @@ -468,14 +1062,13 @@ def main(): frame_type=frame_type_save, preview_bgr=preview_to_save, meta=meta_save, - raw_payload=last_payload_float, - packed_raw=last_packed_raw, packed_raw_by_camera=last_packed_raw_by_camera, ) last_msg = "SALVO (auto)" last_msg_t = now last_auto_t = now + last_saved_frame_id = frame_id_save k = cv2.waitKey(1) & 0xFF if k in (ord("q"), ord("Q"), 27): @@ -487,7 +1080,6 @@ def main(): last_msg_t = time.time() elif k in (ord("m"), ord("M")): - #preview_upscale = 0 if preview_upscale else args.preview_upscale beauty_preview = False if beauty_preview else True last_msg = f"Preview Beauty -> {beauty_preview}" last_msg_t = time.time() @@ -504,36 +1096,28 @@ def main(): elif k in (ord("c"), ord("C"), 32): if can_save: frame_type_save = last_meta_stream.get("frame_type") - meta_save = { - "ts": datetime.now().isoformat(timespec="milliseconds"), - "cana": args.cana, - "horario": args.horario, - "sensor_width": raw_w, - "sensor_height": raw_h, - "bayer_pattern": args.bayer, - "fps_target": args.fps, - "frame_type": frame_type_save, - "capture_mode_requested": args.capture_mode, - "capture_mode_effective": effective_capture_mode, - "raw_policy": args.raw_policy, - "stream_meta": last_meta_stream, - "startup_camera_controls": cam.applied_camera_controls, - "actual_camera_controls": cam.get_current_camera_controls(), - "radiometric_last_result": cam.get_radiometric_last_result(), - "camera_params_json": args.module_calibration_json, - "note": "manual", - "raw_preview_reference_camera": preview_source_id, - } - - preview_to_save, preview_method = build_preview_to_save( + preview_to_save, preview_method, preview_report = ( + build_preview_to_save( + cam=cam, + packed_raw_by_camera=last_packed_raw_by_camera, + meta_stream=last_meta_stream, + raw_integrity=last_raw_integrity, + expected_size=(raw_w, raw_h), + bayer_pattern=bayer_pattern, + rgb_orientation=rgb_orientation, + ) + ) + meta_save = build_capture_metadata( + args=args, + contract=contract, + stream_meta=last_meta_stream, cam=cam, - frame_type_save=frame_type_save, - last_preview_bgr=last_preview_bgr, - last_packed_raw_by_camera=last_packed_raw_by_camera, - last_meta_stream=last_meta_stream, - raw_w=raw_w, - raw_h=raw_h, - bayer=args.bayer, + effective_capture_mode=effective_capture_mode, + raw_policy=raw_policy, + raw_integrity=last_raw_integrity, + preview_source_id=last_raw_integrity["rgb"]["camera_id"], + annotation_preview=preview_report, + note="manual", ) meta_save["saved_preview_method"] = preview_method @@ -543,13 +1127,12 @@ def main(): frame_type=frame_type_save, preview_bgr=preview_to_save, meta=meta_save, - raw_payload=last_payload_float, - packed_raw=last_packed_raw, packed_raw_by_camera=last_packed_raw_by_camera, ) last_msg = "SALVO (manual)" last_msg_t = time.time() + last_saved_frame_id = last_meta_stream.get("frame_id") dt_loop = time.time() - t0 if dt_loop < 0.001: @@ -561,4 +1144,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() 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 index 785728cb4..6b33ee807 100644 --- 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 @@ -13,7 +13,8 @@ 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; + - resolver/confirmar Bayer da RGB usando o RawProcessorPreview oficial; + - confirmar a orientação física/canônica das três câmeras; - 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; @@ -32,6 +33,11 @@ Exemplos: Teclas no preview: B alterna Bayer RGB + 1/2/3 seleciona RGB/RE/NIR + R gira a câmera selecionada 90 graus no sentido horário + H alterna flip horizontal da câmera selecionada + V alterna flip vertical da câmera selecionada + D restaura orientação padrão da câmera selecionada S salva snapshot ENTER confirma e promove o profile Q/ESC cancela sem alterar o profile ativo @@ -55,6 +61,8 @@ import cv2 import depthai as dai import numpy as np +from core.raw_processor_preview import RawProcessorPreview + SCHEMA = "multispec_module_profile_v1" SELECTOR_SCHEMA = "multispec_active_module_profile_v1" @@ -256,6 +264,8 @@ class CameraSpec: resolution_name: str is_color: bool preview_rotate_deg: int + preview_flip_horizontal: bool + preview_flip_vertical: bool stream_name: str @@ -302,6 +312,8 @@ def validate_and_build_specs(rows: list[dict]) -> Dict[str, CameraSpec]: resolution_name=str(cfg["resolution_enum"]), is_color=(role == "rgb"), preview_rotate_deg=int(cfg["preview_rotate_deg"]), + preview_flip_horizontal=False, + preview_flip_vertical=False, stream_name=f"profile_{role}", ) @@ -364,77 +376,12 @@ def build_preview_pipeline(specs: Dict[str, CameraSpec], fps: float): 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): +def rgb_raw_preview( + pkt, + spec: CameraSpec, + processor: RawProcessorPreview, +): + """Preview RGB oficial; não mantém um segundo mini-ISP neste script.""" raw_type = str(pkt.getType()).upper() if "PACK10" not in raw_type and "RAW10" not in raw_type: raise RuntimeError( @@ -454,31 +401,50 @@ def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str): 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) + packed_data = np.asarray(pkt.getData(), dtype=np.uint8).reshape(-1) + payload_per_row = (width // 4) * 5 + if ( + stride is None + or stride < payload_per_row + or stride * height > packed_data.size + ): + # Algumas versões do DepthAI reportam getStride() no domínio dos + # pixels, não dos bytes PACK10. Nesse caso o tamanho real vence. + stride = None - # 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)) + raw16 = processor.unpack_raw10_packed( + packed_data, + stride=stride, + ) + return processor.raw16_to_preview_bgr(raw16) -def apply_preview_orientation(img: np.ndarray, rotate_deg: int): - rotate_deg = int(rotate_deg) % 360 +def normalize_orientation(cfg: dict) -> dict: + rotate_deg = int((cfg or {}).get("rotate_deg", 0)) % 360 + if rotate_deg not in (0, 90, 180, 270): + raise ValueError(f"Rotação inválida: {rotate_deg}") + return { + "rotate_deg": rotate_deg, + "flip_horizontal": bool((cfg or {}).get("flip_horizontal", False)), + "flip_vertical": bool((cfg or {}).get("flip_vertical", False)), + "source": str((cfg or {}).get("source") or "unknown"), + } + + +def apply_preview_orientation(img: np.ndarray, cfg: dict): + cfg = normalize_orientation(cfg) + rotate_deg = cfg["rotate_deg"] 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 + img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) + elif rotate_deg == 180: + img = cv2.rotate(img, cv2.ROTATE_180) + elif rotate_deg == 270: + img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) + if cfg["flip_horizontal"]: + img = cv2.flip(img, 1) + if cfg["flip_vertical"]: + img = cv2.flip(img, 0) + return np.ascontiguousarray(img) def overlay(img, lines, x=12, y=24, scale=0.48, step=21): @@ -499,6 +465,7 @@ def preview_and_confirm( dev_info, specs: Dict[str, CameraSpec], initial_bayer: str, + initial_orientation: Dict[str, dict], algorithm: str, fps: float, panel_width: int, @@ -506,8 +473,25 @@ def preview_and_confirm( snapshots_dir: Path, ): pattern = initial_bayer + orientation = { + role: normalize_orientation(initial_orientation[role]) + for role in ROLES + } + orientation_defaults = { + role: dict(orientation[role]) + for role in ROLES + } + selected_role = "rgb" + preview_processors = { + candidate: RawProcessorPreview( + sensor_width=specs["rgb"].width, + sensor_height=specs["rgb"].height, + bayer_pattern=candidate, + ) + for candidate in BAYER_PATTERNS + } pipeline = build_preview_pipeline(specs, fps) - window = "Module Profile - Bayer Confirmation" + window = "Module Profile - Physical Sanity Check" cv2.namedWindow(window, cv2.WINDOW_NORMAL) try: @@ -538,7 +522,11 @@ def preview_and_confirm( spec = specs[role] pkt = latest[role] if role == "rgb": - view = rgb_raw_preview(pkt, spec, pattern, algorithm) + view = rgb_raw_preview( + pkt, + spec, + preview_processors[pattern], + ) else: view = pkt.getCvFrame() if view.ndim == 2: @@ -546,7 +534,7 @@ def preview_and_confirm( view = apply_preview_orientation( view, - spec.preview_rotate_deg, + orientation[role], ) panel = cv2.resize( view, @@ -554,16 +542,33 @@ def preview_and_confirm( interpolation=cv2.INTER_AREA, ) lines = [ - f"{role.upper()} | {spec.socket} | {spec.sensor}", + ( + f"> {role.upper()} | {spec.socket} | {spec.sensor}" + if role == selected_role + else f" {role.upper()} | {spec.socket} | {spec.sensor}" + ), f"native={spec.width}x{spec.height}", f"packet={latest_type[role]}", + ( + f"ROT={orientation[role]['rotate_deg']} " + f"FH={int(orientation[role]['flip_horizontal'])} " + f"FV={int(orientation[role]['flip_vertical'])}" + ), ] if role == "rgb": lines += [ - f"BAYER={pattern} | DEMOSAIC={algorithm}", + f"BAYER={pattern} | PREVIEW=RawProcessorPreview", "Confira vermelho/azul em objeto conhecido", ] overlay(panel, lines) + if role == selected_role: + cv2.rectangle( + panel, + (2, 2), + (panel_width - 3, panel_height - 3), + (0, 255, 255), + 4, + ) panels.append(panel) info = np.zeros( @@ -575,14 +580,19 @@ def preview_and_confirm( "", f"RGB Bayer selecionado: {pattern}", f"RGB processing: linear_demosaic / {algorithm}", + f"Camera selecionada: {selected_role.upper()}", "", "B = alternar Bayer", + "1/2/3 = selecionar RGB/RE/NIR", + "R = girar 90 graus horario", + "H/V = flip horizontal/vertical", + "D = restaurar orientacao padrao", "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.", + "Confirme cores e o mesmo sentido fisico", + "nas tres cameras antes de pressionar ENTER.", ]) last_board = np.vstack([ @@ -597,21 +607,48 @@ def preview_and_confirm( key = cv2.waitKey(1) & 0xFF if key in (ord("q"), ord("Q"), 27): - raise KeyboardInterrupt("Cancelado durante confirmação do Bayer.") + raise KeyboardInterrupt("Cancelado durante sanity check físico.") + if key == ord("1"): + selected_role = "rgb" + elif key == ord("2"): + selected_role = "re" + elif key == ord("3"): + selected_role = "nir" if key in (ord("b"), ord("B")): idx = (BAYER_PATTERNS.index(pattern) + 1) % len(BAYER_PATTERNS) pattern = BAYER_PATTERNS[idx] + if key in (ord("r"), ord("R")): + orientation[selected_role]["rotate_deg"] = ( + int(orientation[selected_role]["rotate_deg"]) + 90 + ) % 360 + orientation[selected_role]["source"] = "raw_preview_confirmed" + if key in (ord("h"), ord("H")): + orientation[selected_role]["flip_horizontal"] = not bool( + orientation[selected_role]["flip_horizontal"] + ) + orientation[selected_role]["source"] = "raw_preview_confirmed" + if key in (ord("v"), ord("V")): + orientation[selected_role]["flip_vertical"] = not bool( + orientation[selected_role]["flip_vertical"] + ) + orientation[selected_role]["source"] = "raw_preview_confirmed" + if key in (ord("d"), ord("D")): + orientation[selected_role] = dict( + orientation_defaults[selected_role] + ) 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"), + str(snapshots_dir / f"physical_check_{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" + for role in ROLES: + orientation[role]["source"] = "raw_preview_confirmed" + confirmed_path = snapshots_dir / "physical_sanity_confirmed.png" cv2.imwrite(str(confirmed_path), last_board) - return pattern, confirmed_path + return pattern, orientation, confirmed_path time.sleep(0.002) finally: @@ -625,9 +662,9 @@ def artifact_paths(profile_dir: Path) -> dict: "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"), + "radiometry_json": norm_path(profile_dir / "radiometry_calibration_v5.json"), + "camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v3.json"), + "homography_json": norm_path(profile_dir / "homography_calibration_v4.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" @@ -652,19 +689,27 @@ def build_profile( profile_name: str, profile_dir: Path, bayer: str, + preview_orientation: Dict[str, dict], 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", - } + role: normalize_orientation(preview_orientation[role]) for role in ROLES } + resolved_setup = {} + for role in ROLES: + item = asdict(specs[role]) + item["preview_rotate_deg"] = int(orientation[role]["rotate_deg"]) + item["preview_flip_horizontal"] = bool( + orientation[role]["flip_horizontal"] + ) + item["preview_flip_vertical"] = bool( + orientation[role]["flip_vertical"] + ) + resolved_setup[role] = item + hardware_signature = { role: { "socket": specs[role].socket, @@ -686,10 +731,7 @@ def build_profile( "depthai_version": getattr(dai, "__version__", "unknown"), "usb_speed": usb_speed, "hardware_signature": hardware_signature, - "resolved_setup": { - role: asdict(specs[role]) - for role in ROLES - }, + "resolved_setup": resolved_setup, "camera_inventory": rows, "rgb_decode": { "bayer_pattern": bayer, @@ -708,9 +750,23 @@ def build_profile( ), }, "preview_orientation_defaults": orientation, + "mounting_sanity_check": { + "confirmed_with_live_preview": bool(confirmed_with_raw_preview), + "preview_engine": "core.raw_processor_preview.RawProcessorPreview", + "orientation_by_role": orientation, + "confirmation_snapshot": ( + norm_path(confirmation_snapshot) + if confirmation_snapshot is not None + else None + ), + }, "geometry_bootstrap": { "native_space": "native_stream_no_external_undistort", - "orientation_status": "preview_default_until_homography", + "orientation_status": ( + "raw_preview_confirmed_until_homography" + if confirmed_with_raw_preview + else "default_or_cli_headless_until_homography" + ), "note": ( "A Homography homologada será a autoridade final da orientação " "científica e do espaço canônico." @@ -730,6 +786,15 @@ def build_profile( "mode": args.rgb_processing_mode, "demosaic_algorithm": args.demosaic_algorithm, }, + "camera_orientation_bootstrap": { + "enabled": True, + "input_space": "native_stream_no_external_undistort", + "output_space": ( + "canonical_oriented_stream_no_external_undistort" + ), + "by_role": orientation, + "authority": "module_profile_until_homography", + }, }, "artifact_paths": artifact_paths(profile_dir), "traceability": { @@ -740,6 +805,13 @@ def build_profile( }, "notes": args.notes or "", }, + "capture_synchronization": { + "hardware_sync_enabled": bool(args.hardware_sync_enabled), + "frame_sync_master": args.frame_sync_master, + "software_sync_mode": "best", + "sync_tolerance_ms": 12.0, + "buffer_size": 8, + }, } @@ -789,6 +861,29 @@ def backup_if_exists(path: Path): return None +def resolve_initial_orientation( + specs: Dict[str, CameraSpec], + args, +) -> Dict[str, dict]: + result = {} + for role in ROLES: + rotate_override = getattr(args, f"{role}_preview_rotate_deg") + flip_h = bool(getattr(args, f"{role}_preview_flip_horizontal")) + flip_v = bool(getattr(args, f"{role}_preview_flip_vertical")) + has_cli_override = rotate_override is not None or flip_h or flip_v + result[role] = normalize_orientation({ + "rotate_deg": ( + specs[role].preview_rotate_deg + if rotate_override is None + else rotate_override + ), + "flip_horizontal": flip_h, + "flip_vertical": flip_v, + "source": "cli" if has_cli_override else "sensor_default", + }) + return result + + def main(): parser = argparse.ArgumentParser( description="Bootstrap do profile físico/decode do módulo multiespectral.", @@ -826,6 +921,25 @@ def main(): 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) + for role in ROLES: + parser.add_argument( + f"--{role}-preview-rotate-deg", + type=int, + default=None, + choices=[0, 90, 180, 270], + help=( + f"Orientação inicial de {role.upper()}; pode ser ajustada " + "interativamente no preview." + ), + ) + parser.add_argument( + f"--{role}-preview-flip-horizontal", + action="store_true", + ) + parser.add_argument( + f"--{role}-preview-flip-vertical", + action="store_true", + ) parser.add_argument( "--no-preview", action="store_true", @@ -841,6 +955,16 @@ def main(): parser.add_argument("--lens-re", default="") parser.add_argument("--lens-nir", default="") parser.add_argument("--notes", default="") + parser.add_argument( + "--hardware-sync-enabled", + action=argparse.BooleanOptionalAction, + default=False, + ) + parser.add_argument( + "--frame-sync-master", + default="CAM_A", + choices=["CAM_A", "CAM_B", "CAM_C"], + ) args = parser.parse_args() if args.no_preview and not args.yes: @@ -849,6 +973,7 @@ def main(): dev_info = select_device_info(args.mx_id) rows, actual_mx, usb_speed = discover(dev_info) specs = validate_and_build_specs(rows) + selected_orientation = resolve_initial_orientation(specs, args) rgb_sensor = specs["rgb"].sensor default_profile_name = f"mp_{rgb_sensor.lower()}" @@ -898,6 +1023,13 @@ def main(): f"RGB decode : Bayer={selected_bayer} ({bayer_initial_source}) | " f"mode={args.rgb_processing_mode} | algo={args.demosaic_algorithm}" ) + for role in ROLES: + orient = selected_orientation[role] + print( + f"{role.upper():3s} orient. : rotate={orient['rotate_deg']:3d} | " + f"flip_h={orient['flip_horizontal']} | " + f"flip_v={orient['flip_vertical']} | source={orient['source']}" + ) print("=" * 88) confirmed_with_raw_preview = False @@ -905,10 +1037,15 @@ def main(): try: if not args.no_preview: - selected_bayer, confirmation_snapshot = preview_and_confirm( + ( + selected_bayer, + selected_orientation, + confirmation_snapshot, + ) = preview_and_confirm( dev_info=dev_info, specs=specs, initial_bayer=selected_bayer, + initial_orientation=selected_orientation, algorithm=args.demosaic_algorithm, fps=args.fps, panel_width=args.panel_width, @@ -926,6 +1063,7 @@ def main(): profile_name=profile_name, profile_dir=profile_dir, bayer=selected_bayer, + preview_orientation=selected_orientation, confirmed_with_raw_preview=confirmed_with_raw_preview, confirmation_snapshot=confirmation_snapshot, ) @@ -965,6 +1103,14 @@ def main(): print(f"[SELECTOR] {active_selector}") print(f"[SHA256] {selector['profile_sha256']}") print(f"[BAYER] {selected_bayer}") + for role in ROLES: + orient = selected_orientation[role] + print( + f"[ORIENTATION] {role.upper():3s} | " + f"rotate={orient['rotate_deg']} | " + f"flip_h={orient['flip_horizontal']} | " + f"flip_v={orient['flip_vertical']}" + ) if backup_profile: print(f"[BACKUP PROFILE] {backup_profile}") if backup_selector: 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 fc75532fe..7f1dac020 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 @@ -108,6 +108,7 @@ from __future__ import annotations import argparse import hashlib import json +import math import os import shutil import zipfile @@ -536,6 +537,78 @@ def resolve_output_path(value: str | Path) -> Path: return Path(value).expanduser() +def normalize_capture_synchronization( + value: dict, + hardware_signature: dict, +) -> dict: + """Valida e normaliza o contrato de sincronismo vindo do Module Profile.""" + if not isinstance(value, dict): + raise RuntimeError( + "Module Profile sem capture_synchronization. " + "Execute novamente o Script 0 revisado." + ) + + enabled = value.get("hardware_sync_enabled") + if not isinstance(enabled, bool): + raise RuntimeError( + "capture_synchronization.hardware_sync_enabled deve ser bool." + ) + + master = str(value.get("frame_sync_master") or "").upper().strip() + valid_sockets = { + str(item["socket"]).upper() + for item in normalize_signature(hardware_signature).values() + } + if master not in valid_sockets: + raise RuntimeError( + "capture_synchronization.frame_sync_master inválido: " + f"{master!r}. Disponíveis={sorted(valid_sockets)}" + ) + + software_mode = str( + value.get("software_sync_mode") or "" + ).lower().strip() + if not software_mode: + raise RuntimeError( + "capture_synchronization.software_sync_mode ausente." + ) + + try: + tolerance_ms = float(value.get("sync_tolerance_ms")) + except Exception as exc: + raise RuntimeError( + "capture_synchronization.sync_tolerance_ms inválido." + ) from exc + if not math.isfinite(tolerance_ms) or tolerance_ms <= 0.0: + raise RuntimeError( + "capture_synchronization.sync_tolerance_ms deve ser finito e > 0." + ) + + buffer_size = value.get("buffer_size") + if isinstance(buffer_size, bool): + raise RuntimeError( + "capture_synchronization.buffer_size deve ser inteiro positivo." + ) + try: + buffer_size = int(buffer_size) + except Exception as exc: + raise RuntimeError( + "capture_synchronization.buffer_size inválido." + ) from exc + if buffer_size <= 0: + raise RuntimeError( + "capture_synchronization.buffer_size deve ser > 0." + ) + + return { + "hardware_sync_enabled": enabled, + "frame_sync_master": master, + "software_sync_mode": software_mode, + "sync_tolerance_ms": tolerance_ms, + "buffer_size": buffer_size, + } + + def validate_module_profile(profile: dict, profile_path: Path): if profile.get("schema") != MODULE_PROFILE_SCHEMA: raise RuntimeError( @@ -602,6 +675,11 @@ def validate_module_profile(profile: dict, profile_path: Path): if not artifacts.get(key): raise RuntimeError(f"Module Profile sem artifact_paths.{key}.") + normalize_capture_synchronization( + profile.get("capture_synchronization"), + signature, + ) + def load_module_profile_contract( module_profile_arg: Optional[str], @@ -2602,6 +2680,10 @@ def runtime_policy_from_module_profile(profile: dict) -> dict: sensor nem herança de um module_params anterior. """ decode = profile["rgb_decode"] + capture_sync = normalize_capture_synchronization( + profile.get("capture_synchronization"), + profile.get("hardware_signature"), + ) declared = profile.get("runtime_policy") if declared is None: declared = {} @@ -2614,6 +2696,7 @@ def runtime_policy_from_module_profile(profile: dict) -> dict: "capture_mode_requested": "AUTO", "capture_mode_effective": "AUTO", "raw_policy": "allow_single", + "capture_synchronization": capture_sync, "rgb_processing": { "mode": str(decode["mode"]).lower(), "demosaic_algorithm": str(decode["demosaic_algorithm"]).lower(), @@ -2781,6 +2864,9 @@ def build_final_module_params( "raw_policy": runtime_policy[ "raw_policy" ], + "capture_synchronization": deepcopy( + runtime_policy["capture_synchronization"] + ), # Compatibilidade root: RGB reference camera "sensor_width": int( @@ -2958,6 +3044,7 @@ def validate_final_module_params( "bayer_pattern", "sensor_size_by_role", "camera_hardware", + "capture_synchronization", "camera_orientation", "rgb_processing", "camera_settings", @@ -3013,6 +3100,15 @@ def validate_final_module_params( if str(mp.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS: raise RuntimeError("bayer_pattern final inválido.") + + normalized_sync = normalize_capture_synchronization( + mp.get("capture_synchronization"), + mp.get("camera_hardware"), + ) + if mp.get("capture_synchronization") != normalized_sync: + raise RuntimeError( + "capture_synchronization final não está normalizado." + ) rgb_processing = mp.get("rgb_processing") if not isinstance(rgb_processing, dict): raise RuntimeError("rgb_processing final inválido.") @@ -3698,6 +3794,9 @@ def main(): provenance = { "module_profile_contract": module_profile_provenance(profile_contract), "configuration_source": "module_profile_only", + "capture_synchronization": deepcopy( + runtime_policy["capture_synchronization"] + ), "hardware_signature": deepcopy( common_signature ), @@ -3832,6 +3931,9 @@ def main(): "camera_orientation": deepcopy( camera_orientation ), + "capture_synchronization": deepcopy( + runtime_policy["capture_synchronization"] + ), "runtime_policy": runtime_policy, "calibration_provenance": provenance, "final_checks": { @@ -3847,6 +3949,7 @@ def main(): "hardware_consistency": "pass", "geometry_consistency": "pass", "camera_orientation": "pass", + "capture_synchronization": "pass", "orientation_bootstrap_consistency": "pass", "startup_radiometry_consistency": "pass", "flatfield_npz_integrity": "pass", @@ -3874,6 +3977,15 @@ def main(): f"{common_signature['nir']['size'][0]}x{common_signature['nir']['size'][1]}" ) print(f"Bayer : {bayer}") + capture_sync = runtime_policy["capture_synchronization"] + print( + "Capture sync: " + f"hardware={'ON' if capture_sync['hardware_sync_enabled'] else 'OFF'} | " + f"master={capture_sync['frame_sync_master']} | " + f"software={capture_sync['software_sync_mode']} | " + f"tolerance={capture_sync['sync_tolerance_ms']:.1f}ms | " + f"buffer={capture_sync['buffer_size']}" + ) print( f"RGB process : " f"{runtime_policy['rgb_processing'].get('mode')}" diff --git a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py index ba9dc5406..d3c50e6ea 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py +++ b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py @@ -41,12 +41,17 @@ class OakFcc3Client: capture_mode="AUTO", raw_policy="allow_single", module_calibration_json=None, - sync_mode="best", - sync_tolerance_ms=25.0, mx_id=None, imu_modo="rotation_vector", imu_freq_hz=200, evaluate_quality=True, + + hardware_sync_enabled=None, + frame_sync_master=None, + sync_mode=None, + sync_tolerance_ms=None, + buffer_size=None, + **kwargs, ): self.width = width @@ -85,14 +90,18 @@ class OakFcc3Client: output_dtype=output_dtype, capture_mode=capture_mode, raw_policy=raw_policy, - sync_mode=sync_mode, - sync_tolerance_ms=sync_tolerance_ms, mx_id=self.mx_id, module_calibration_json=module_calibration_json, imu_modo=self.imu_modo, imu_freq_hz=self.imu_freq_hz, + hardware_sync_enabled=hardware_sync_enabled, + frame_sync_master=frame_sync_master, + sync_mode=sync_mode, + sync_tolerance_ms=sync_tolerance_ms, + buffer_size=buffer_size, + **kwargs, ) diff --git a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py index ad04a1a47..3588af9bc 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py +++ b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py @@ -44,11 +44,11 @@ class OakFcc3Manager: capture_mode="AUTO", raw_policy="allow_single", roles=None, - sync_mode="best", - hardware_sync_enabled=True, - frame_sync_master="CAM_A", - sync_tolerance_ms=12.0, - buffer_size=8, + sync_mode=None, + hardware_sync_enabled=None, + frame_sync_master=None, + sync_tolerance_ms=None, + buffer_size=None, only_camera=None, mx_id=None, module_calibration_json=None, @@ -56,9 +56,6 @@ class OakFcc3Manager: imu_modo="rotation_vector", imu_freq_hz=200, ): - self.hardware_sync_enabled = hardware_sync_enabled - self.frame_sync_master = frame_sync_master - self.fps = fps # Para compatibilidade, mantemos width/height. @@ -83,10 +80,6 @@ class OakFcc3Manager: "CAM_C": "nir", } - self.sync_mode = sync_mode - self.sync_tolerance_ms = sync_tolerance_ms - self.buffer_size = buffer_size - self.mx_id = str(mx_id) if mx_id else None self.dev_info = None self.device = None @@ -127,6 +120,18 @@ class OakFcc3Manager: self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {} self.aligned_geometry = None + sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {}) + if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False) + if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A") + if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best") + if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0) + if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8) + self.hardware_sync_enabled = bool(hardware_sync_enabled) + self.frame_sync_master = str(frame_sync_master) + self.sync_mode = str(sync_mode) + self.sync_tolerance_ms = float(sync_tolerance_ms) + self.buffer_size = int(buffer_size) + self.async_capture_enabled = True self.async_capture_mode = "latest" # latest | queue self.async_capture_max_queue = 2 diff --git a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_preview.py b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_preview.py index a8d70227b..e4c053860 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_preview.py +++ b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_preview.py @@ -1,126 +1,689 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +RawProcessorPreview +=================== + +Mini-ISP exclusivamente visual para gerar previews de anotação a partir do +RAW Bayer da câmera RGB. + +Contrato: + - entrada científica permanece imutável; + - saída sempre BGR uint8 para OpenCV; + - preserva exatamente HxW do raster RGB; + - não aplica resize, crop, rotate/flip, undistort, Flat-Field ou Homography; + - não deve ser usado para formar o tensor de treino/inferência. + +Pipeline visual padrão: + RAW10/RAW16 -> níveis robustos -> gamma -> demosaico EA/BGR -> + WB por regiões claras/neutras -> CLAHE suave em luminância -> + saturação leve -> contraste -> unsharp suave. + +As APIs antigas foram preservadas. Chamadas que antes usavam +raw16_to_preview_bgr() ou raw16_to_preview_jpg_bytes() continuam válidas. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Optional + import cv2 import numpy as np -from typing import Optional class RawProcessorPreview: - def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"): - self.sensor_width = sensor_width - self.sensor_height = sensor_height - self.bayer_pattern = bayer_pattern.upper() + VERSION = "raw_processor_preview_v2_2026_09_09" + CONTRACT_SCHEMA = "rgb_raw_annotation_preview_v2" + BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") - def raw16_to_vis8( - self, raw16: np.ndarray, - black_level: Optional[int] = None, - white_level: Optional[int] = None, - gamma: float = 2.2, - bit_depth: int = 10 - ) -> np.ndarray: - """ - Conversão para visualização: - - auto-level - - gamma - """ - max_val = float((1 << bit_depth) - 1) + DEFAULT_CONFIG = { + "bit_depth": 10, + "levels": { + "mode": "robust_percentile", + "black_percentile": 0.20, + "white_percentile": 99.80, + "sample_max_pixels": 500_000, + "minimum_span_codes": 16.0, + }, + "gamma": 2.20, + "demosaic": { + "algorithm": "edge_aware", + "output_color_order": "BGR", + }, + "white_balance": { + "enabled": True, + "method": "neutral_bright_with_gray_world_fallback", + "strength": 0.65, + "gain_min": 0.60, + "gain_max": 1.70, + "bright_percentile": 55.0, + "neutral_chroma_percentile": 40.0, + "min_neutral_pixels": 256, + }, + "clahe": { + "enabled": True, + "clip_limit": 1.55, + "tile_grid_size": 8, + }, + "saturation": { + "enabled": True, + "factor": 1.04, + }, + "contrast": { + "enabled": True, + "alpha": 1.04, + "beta": 0.0, + }, + "sharpen": { + "enabled": True, + "sigma": 0.85, + "amount": 0.55, + "threshold": 2.0, + }, + } - raw = raw16.astype(np.float32) + def __init__( + self, + sensor_width: int, + sensor_height: int, + bayer_pattern: str = "GBRG", + config: Optional[dict] = None, + ): + self.sensor_width = int(sensor_width) + self.sensor_height = int(sensor_height) + self.bayer_pattern = str(bayer_pattern).upper() + self.config = self._deep_merge(self.DEFAULT_CONFIG, config or {}) + self.last_preview_report: dict[str, Any] = {} + self._validate_contract() - if black_level is None: - black_level = float(raw.min()) - if white_level is None: - white_level = float(raw.max()) + @staticmethod + def _deep_merge(base: dict, override: dict) -> dict: + result = deepcopy(base) + if not isinstance(override, dict): + raise TypeError("config do RawProcessorPreview deve ser dict.") + for key, value in override.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = RawProcessorPreview._deep_merge(result[key], value) + else: + result[key] = deepcopy(value) + return result - if white_level <= black_level: - norm = raw / max_val - else: - norm = (raw - black_level) / (white_level - black_level) - - norm = np.clip(norm, 0.0, 1.0) - - if gamma is not None and gamma > 0: - norm = np.power(norm, 1.0 / gamma) - - return (norm * 255.0).clip(0, 255).astype(np.uint8) - - def _debayer_code(self): - mapping = { - "RGGB": cv2.COLOR_BayerRG2RGB_EA, - "BGGR": cv2.COLOR_BayerBG2RGB_EA, - "GRBG": cv2.COLOR_BayerGR2RGB_EA, - "GBRG": cv2.COLOR_BayerGB2RGB_EA, - } - - if self.bayer_pattern not in mapping: + def _validate_contract(self): + if self.sensor_width <= 0 or self.sensor_height <= 0: + raise ValueError( + f"Dimensão de sensor inválida: {self.sensor_width}x{self.sensor_height}" + ) + if self.sensor_width % 4 != 0: + raise ValueError( + f"RAW10 packed exige largura múltipla de 4: {self.sensor_width}" + ) + if self.bayer_pattern not in self.BAYER_PATTERNS: raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}") - return mapping[self.bayer_pattern] + bit_depth = int(self.config.get("bit_depth", 10)) + if bit_depth < 8 or bit_depth > 16: + raise ValueError(f"bit_depth inválido: {bit_depth}") - def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray: + levels = self.config["levels"] + low = float(levels["black_percentile"]) + high = float(levels["white_percentile"]) + if not (0.0 <= low < high <= 100.0): + raise ValueError(f"Percentis de níveis inválidos: {low}, {high}") + + gamma = float(self.config["gamma"]) + if not np.isfinite(gamma) or gamma <= 0.0: + raise ValueError(f"Gamma inválido: {gamma}") + + wb = self.config["white_balance"] + if float(wb["gain_min"]) <= 0.0 or float(wb["gain_max"]) < float(wb["gain_min"]): + raise ValueError("Limites de ganho do white balance inválidos.") + + def get_preview_contract(self) -> dict: + """Contrato serializável para registrar no meta do dataset.""" + return { + "schema": self.CONTRACT_SCHEMA, + "version": self.VERSION, + "purpose": "human_annotation_only", + "geometry": "rgb_sensor_native_no_geometric_transform", + "sensor_size": [self.sensor_width, self.sensor_height], + "bayer_pattern": self.bayer_pattern, + "output": { + "layout": "HWC", + "color_order": "BGR", + "dtype": "uint8", + "range": [0, 255], + }, + "calibrations_applied": [], + "config": deepcopy(self.config), + } + + def get_last_preview_report(self) -> dict: + return deepcopy(self.last_preview_report) + + # Nome explícito e amigável para integrações que só precisam registrar + # a configuração, sem necessariamente gerar um frame antes. + def describe_config(self) -> dict: + return self.get_preview_contract() + + @staticmethod + def _finite_float(value, name: str) -> float: + result = float(value) + if not np.isfinite(result): + raise ValueError(f"{name} deve ser finito: {value!r}") + return result + + @staticmethod + def _sample_for_stats(raw: np.ndarray, max_pixels: int) -> np.ndarray: + total = int(raw.size) + if total <= max_pixels: + return raw.reshape(-1) + stride = max(1, int(np.ceil(np.sqrt(total / float(max_pixels))))) + return raw[::stride, ::stride].reshape(-1) + + def _resolve_levels( + self, + raw16: np.ndarray, + black_level: Optional[float], + white_level: Optional[float], + bit_depth: int, + black_percentile: Optional[float], + white_percentile: Optional[float], + ) -> tuple[float, float, dict]: + max_code = float((1 << int(bit_depth)) - 1) + levels = self.config["levels"] + + low_pct = float( + levels["black_percentile"] + if black_percentile is None + else black_percentile + ) + high_pct = float( + levels["white_percentile"] + if white_percentile is None + else white_percentile + ) + if not (0.0 <= low_pct < high_pct <= 100.0): + raise ValueError(f"Percentis inválidos: {low_pct}, {high_pct}") + + sample = self._sample_for_stats( + raw16, + max(1, int(levels["sample_max_pixels"])), + ).astype(np.float32, copy=False) + sample = sample[np.isfinite(sample)] + if sample.size == 0: + raise RuntimeError("RAW não contém pixels finitos para calcular níveis.") + + black_source = "explicit" + white_source = "explicit" + if black_level is None: + black = float(np.percentile(sample, low_pct)) + black_source = "percentile" + else: + black = self._finite_float(black_level, "black_level") + if white_level is None: + white = float(np.percentile(sample, high_pct)) + white_source = "percentile" + else: + white = self._finite_float(white_level, "white_level") + + black = float(np.clip(black, 0.0, max_code)) + white = float(np.clip(white, 0.0, max_code)) + minimum_span = float(levels["minimum_span_codes"]) + fallback = False + + if white <= black + minimum_span: + black = float(np.clip(np.min(sample), 0.0, max_code)) + white = float(np.clip(np.max(sample), 0.0, max_code)) + fallback = True + if white <= black: + black, white = 0.0, max_code + fallback = True + + return black, white, { + "black_level": black, + "white_level": white, + "black_source": black_source, + "white_source": white_source, + "black_percentile": low_pct, + "white_percentile": high_pct, + "fallback_to_range": fallback, + "sample_pixels": int(sample.size), + } + + def raw16_to_vis8( + self, + raw16: np.ndarray, + black_level: Optional[int] = None, + white_level: Optional[int] = None, + gamma: Optional[float] = None, + bit_depth: Optional[int] = None, + black_percentile: Optional[float] = None, + white_percentile: Optional[float] = None, + ) -> np.ndarray: + """Converte mosaico RAW16 para mosaico uint8 de visualização.""" + raw = np.asarray(raw16) + if raw.ndim != 2: + raise ValueError(f"RAW16 deve ser 2D; shape={raw.shape}") + if raw.shape != (self.sensor_height, self.sensor_width): + raise ValueError( + f"RAW16 shape={raw.shape}; esperado=" + f"{(self.sensor_height, self.sensor_width)}" + ) + if not np.issubdtype(raw.dtype, np.integer): + raise TypeError(f"RAW16 deve possuir dtype inteiro; recebido={raw.dtype}") + + bit_depth = int( + self.config["bit_depth"] if bit_depth is None else bit_depth + ) + gamma_value = self._finite_float( + self.config["gamma"] if gamma is None else gamma, + "gamma", + ) + if gamma_value <= 0.0: + raise ValueError("gamma deve ser > 0.") + + black, white, level_report = self._resolve_levels( + raw, + black_level, + white_level, + bit_depth, + black_percentile, + white_percentile, + ) + norm = (raw.astype(np.float32) - black) / max(white - black, 1.0) + np.clip(norm, 0.0, 1.0, out=norm) + if gamma_value != 1.0: + norm = np.power(norm, 1.0 / gamma_value) + + self.last_preview_report = { + "stage": "raw16_to_vis8", + "bit_depth": bit_depth, + "gamma": gamma_value, + "levels": level_report, + } + return np.clip(norm * 255.0 + 0.5, 0, 255).astype(np.uint8) + + def _debayer_code(self): + # A API desta classe promete BGR para cv2.imshow/cv2.imwrite. + mapping = { + "RGGB": ("COLOR_BayerRG2BGR_EA", "COLOR_BayerRG2BGR"), + "BGGR": ("COLOR_BayerBG2BGR_EA", "COLOR_BayerBG2BGR"), + "GRBG": ("COLOR_BayerGR2BGR_EA", "COLOR_BayerGR2BGR"), + "GBRG": ("COLOR_BayerGB2BGR_EA", "COLOR_BayerGB2BGR"), + } + preferred, fallback = mapping[self.bayer_pattern] + return getattr(cv2, preferred, getattr(cv2, fallback)) + + def apply_preview_white_balance( + self, + bgr: np.ndarray, + strength: float = 1.0, + method: Optional[str] = None, + gain_min: Optional[float] = None, + gain_max: Optional[float] = None, + return_report: bool = False, + ): """ - Gray-world simples para deixar o preview mais agradável. - Não usar no raw de treino. + WB visual robusto para campo agrícola. + + Primeiro procura pixels claros e de baixo croma, reduzindo o risco de + o gray-world neutralizar toda a vegetação verde. Se não houver amostra + neutra suficiente, usa gray-world com ganhos limitados. """ - img = bgr.astype(np.float32) + image = np.asarray(bgr) + if image.ndim != 3 or image.shape[2] != 3: + raise ValueError(f"WB requer BGR HWC; shape={image.shape}") - mean_b = float(img[:, :, 0].mean()) - mean_g = float(img[:, :, 1].mean()) - mean_r = float(img[:, :, 2].mean()) + cfg = self.config["white_balance"] + method = str(method or cfg["method"]).lower() + strength = float(np.clip(self._finite_float(strength, "wb_strength"), 0.0, 1.0)) + gain_min = float(cfg["gain_min"] if gain_min is None else gain_min) + gain_max = float(cfg["gain_max"] if gain_max is None else gain_max) + if gain_min <= 0.0 or gain_max < gain_min: + raise ValueError("Limites de ganho WB inválidos.") - mean_gray = (mean_b + mean_g + mean_r) / 3.0 + sample = image[::4, ::4].astype(np.float32) + flat = sample.reshape(-1, 3) + intensity = flat.mean(axis=1) + chroma = (flat.max(axis=1) - flat.min(axis=1)) / np.maximum(intensity, 1.0) - eps = 1e-6 - gain_b = mean_gray / max(mean_b, eps) - gain_g = mean_gray / max(mean_g, eps) - gain_r = mean_gray / max(mean_r, eps) + selected = flat + source = "gray_world" + if method.startswith("neutral_bright") and flat.shape[0] > 0: + bright_limit = float(np.percentile(intensity, float(cfg["bright_percentile"]))) + bright_mask = intensity >= bright_limit + bright_chroma = chroma[bright_mask] + if bright_chroma.size: + neutral_limit = float( + np.percentile( + bright_chroma, + float(cfg["neutral_chroma_percentile"]), + ) + ) + neutral_mask = bright_mask & (chroma <= neutral_limit) + neutral = flat[neutral_mask] + if neutral.shape[0] >= int(cfg["min_neutral_pixels"]): + selected = neutral + source = "neutral_bright" - # strength=1 aplica total, strength=0 não aplica - gain_b = 1.0 + (gain_b - 1.0) * strength - gain_g = 1.0 + (gain_g - 1.0) * strength - gain_r = 1.0 + (gain_r - 1.0) * strength + means = selected.mean(axis=0) + target = float(means.mean()) + gains = target / np.maximum(means, 1e-6) + gains = np.clip(gains, gain_min, gain_max) + gains = 1.0 + (gains - 1.0) * strength - img[:, :, 0] *= gain_b - img[:, :, 1] *= gain_g - img[:, :, 2] *= gain_r + out = np.clip( + image.astype(np.float32) * gains[None, None, :], + 0, + 255, + ).astype(np.uint8) + report = { + "method_requested": method, + "source_used": source, + "strength": strength, + "means_bgr": [float(x) for x in means], + "gains_bgr": [float(x) for x in gains], + "selected_pixels": int(selected.shape[0]), + } + return (out, report) if return_report else out - return np.clip(img, 0, 255).astype(np.uint8) + def apply_preview_contrast( + self, + bgr: np.ndarray, + alpha: float = 1.08, + beta: float = 0.0, + ) -> np.ndarray: + """Compatibilidade: contraste/brilho visual sem alterar geometria.""" + alpha = self._finite_float(alpha, "contrast_alpha") + beta = self._finite_float(beta, "contrast_beta") + return cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta) - def apply_preview_contrast(self, bgr: np.ndarray, alpha: float = 1.08, beta: float = 0.0) -> np.ndarray: - """ - Ajuste leve de contraste/brilho para preview. - """ - out = cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta) - return out + @staticmethod + def _apply_clahe_luminance( + bgr: np.ndarray, + clip_limit: float, + tile_grid_size: int, + ) -> np.ndarray: + lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) + luma, a_ch, b_ch = cv2.split(lab) + clahe = cv2.createCLAHE( + clipLimit=float(clip_limit), + tileGridSize=(int(tile_grid_size), int(tile_grid_size)), + ) + return cv2.cvtColor( + cv2.merge([clahe.apply(luma), a_ch, b_ch]), + cv2.COLOR_LAB2BGR, + ) + + @staticmethod + def _apply_saturation(bgr: np.ndarray, factor: float) -> np.ndarray: + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + sat = hsv[:, :, 1].astype(np.float32) * float(factor) + hsv[:, :, 1] = np.clip(sat, 0, 255).astype(np.uint8) + return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + + @staticmethod + def _apply_unsharp( + bgr: np.ndarray, + sigma: float, + amount: float, + threshold: float, + ) -> np.ndarray: + image = bgr.astype(np.float32) + blurred = cv2.GaussianBlur( + image, + (0, 0), + sigmaX=float(sigma), + sigmaY=float(sigma), + ) + sharpened = image + float(amount) * (image - blurred) + if threshold > 0.0: + mask = np.max(np.abs(image - blurred), axis=2) >= float(threshold) + result = image.copy() + result[mask] = sharpened[mask] + else: + result = sharpened + return np.clip(result, 0, 255).astype(np.uint8) def raw16_to_preview_bgr( self, raw16: np.ndarray, - gamma: float = 2.2, - wb_strength: float = 0.8, + gamma: Optional[float] = None, + wb_strength: Optional[float] = None, apply_wb: bool = True, apply_contrast: bool = True, - bit_depth: int = 10, - ) -> np.ndarray: - """ - Pipeline de preview bonito: - 1. auto-level + gamma no mosaico - 2. demosaic - 3. white balance simples - 4. leve contraste final - """ - vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth) + bit_depth: Optional[int] = None, + *, + black_level: Optional[float] = None, + white_level: Optional[float] = None, + black_percentile: Optional[float] = None, + white_percentile: Optional[float] = None, + apply_clahe: Optional[bool] = None, + apply_saturation: Optional[bool] = None, + apply_sharpen: Optional[bool] = None, + return_report: bool = False, + ): + """Gera preview BGR bonito preservando exatamente o raster de entrada.""" + source = np.asarray(raw16) + expected_shape = (self.sensor_height, self.sensor_width) + if source.shape != expected_shape: + raise ValueError(f"RAW16 shape={source.shape}; esperado={expected_shape}") + + effective_gamma = float( + self.config["gamma"] if gamma is None else gamma + ) + effective_bit_depth = int( + self.config["bit_depth"] if bit_depth is None else bit_depth + ) + effective_wb_strength = float( + self.config["white_balance"]["strength"] + if wb_strength is None + else wb_strength + ) + + vis8 = self.raw16_to_vis8( + source, + black_level=black_level, + white_level=white_level, + gamma=effective_gamma, + bit_depth=effective_bit_depth, + black_percentile=black_percentile, + white_percentile=white_percentile, + ) + level_report = deepcopy(self.last_preview_report.get("levels", {})) bgr = cv2.cvtColor(vis8, self._debayer_code()) + wb_report = {"enabled": False} if apply_wb: - bgr = self.apply_preview_white_balance(bgr, strength=wb_strength) + bgr, wb_report = self.apply_preview_white_balance( + bgr, + strength=effective_wb_strength, + return_report=True, + ) + wb_report["enabled"] = True + clahe_cfg = self.config["clahe"] + clahe_enabled = bool(clahe_cfg["enabled"] if apply_clahe is None else apply_clahe) + if clahe_enabled: + bgr = self._apply_clahe_luminance( + bgr, + float(clahe_cfg["clip_limit"]), + int(clahe_cfg["tile_grid_size"]), + ) + + saturation_cfg = self.config["saturation"] + saturation_enabled = bool( + saturation_cfg["enabled"] + if apply_saturation is None + else apply_saturation + ) + if saturation_enabled: + bgr = self._apply_saturation(bgr, float(saturation_cfg["factor"])) + + contrast_cfg = self.config["contrast"] if apply_contrast: - bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0) + bgr = self.apply_preview_contrast( + bgr, + alpha=float(contrast_cfg["alpha"]), + beta=float(contrast_cfg["beta"]), + ) - return bgr + sharpen_cfg = self.config["sharpen"] + sharpen_enabled = bool( + sharpen_cfg["enabled"] if apply_sharpen is None else apply_sharpen + ) + if sharpen_enabled: + bgr = self._apply_unsharp( + bgr, + sigma=float(sharpen_cfg["sigma"]), + amount=float(sharpen_cfg["amount"]), + threshold=float(sharpen_cfg["threshold"]), + ) - def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes: + if bgr.shape != (self.sensor_height, self.sensor_width, 3): + raise RuntimeError( + f"Preview alterou geometria: {bgr.shape}; esperado=" + f"{(self.sensor_height, self.sensor_width, 3)}" + ) + if bgr.dtype != np.uint8: + raise RuntimeError(f"Preview dtype={bgr.dtype}; esperado=uint8") + + self.last_preview_report = { + **self.get_preview_contract(), + "effective": { + "bit_depth": effective_bit_depth, + "gamma": effective_gamma, + "levels": level_report, + "white_balance": wb_report, + "clahe_enabled": clahe_enabled, + "saturation_enabled": saturation_enabled, + "contrast_enabled": bool(apply_contrast), + "sharpen_enabled": sharpen_enabled, + }, + } + result = np.ascontiguousarray(bgr) + return (result, self.get_last_preview_report()) if return_report else result + + def unpack_raw10_packed( + self, + packed_frame, + sensor_width: Optional[int] = None, + sensor_height: Optional[int] = None, + stride: Optional[int] = None, + ) -> np.ndarray: + """ + Desempacota RAW10 MIPI (4 pixels/5 bytes), aceitando ndarray 1D/2D, + bytes e stride/padding por linha. + """ + width = self.sensor_width if sensor_width is None else int(sensor_width) + height = self.sensor_height if sensor_height is None else int(sensor_height) + if width != self.sensor_width or height != self.sensor_height: + raise ValueError( + "RawProcessorPreview preserva o raster configurado; dimensão " + f"solicitada={width}x{height}, configurada=" + f"{self.sensor_width}x{self.sensor_height}." + ) + if width % 4 != 0: + raise ValueError(f"RAW10 exige largura múltipla de 4: {width}") + + if isinstance(packed_frame, (bytes, bytearray, memoryview)): + packed = np.frombuffer(packed_frame, dtype=np.uint8) + else: + packed = np.asarray(packed_frame) + if packed.ndim == 3 and packed.shape[2] == 1: + packed = packed[:, :, 0] + + useful_width = (width // 4) * 5 + + if packed.dtype != np.uint8: + raise TypeError( + f"RAW10 packed deve ser uint8; shape={packed.shape}, dtype={packed.dtype}" + ) + + if packed.ndim == 1: + if stride is None: + if packed.size % height != 0: + raise ValueError( + f"RAW10 1D possui {packed.size} bytes, não divisível por " + f"height={height}; informe stride." + ) + stride = packed.size // height + stride = int(stride) + expected_bytes = stride * height + if stride < useful_width or packed.size < expected_bytes: + raise ValueError( + f"RAW10 1D curto/inválido: bytes={packed.size}, stride={stride}, " + f"esperado>={expected_bytes}, payload/linha={useful_width}." + ) + packed = packed[:expected_bytes].reshape(height, stride) + elif packed.ndim == 2: + if stride is not None and int(stride) != packed.shape[1]: + raise ValueError( + f"stride={stride} diverge da largura do array={packed.shape[1]}." + ) + else: + raise TypeError( + f"RAW10 packed deve ser 1D ou 2D; shape={packed.shape}." + ) + + if packed.shape[0] != height or packed.shape[1] < useful_width: + raise ValueError( + f"RAW10 packed shape={packed.shape}; esperado >=({height}, {useful_width})" + ) + padding = int(packed.shape[1] - useful_width) + if padding > max(4096, useful_width): + raise ValueError( + f"Padding RAW10 implausível: {padding} bytes/linha; " + "verifique width/height/stride." + ) + + payload = np.ascontiguousarray(packed[:, :useful_width]) + groups = payload.reshape(height, width // 4, 5) + high = groups[:, :, :4].astype(np.uint16) + low = groups[:, :, 4].astype(np.uint16) + + out = np.empty((height, width // 4, 4), dtype=np.uint16) + out[:, :, 0] = (high[:, :, 0] << 2) | (low & 0x03) + out[:, :, 1] = (high[:, :, 1] << 2) | ((low >> 2) & 0x03) + out[:, :, 2] = (high[:, :, 2] << 2) | ((low >> 4) & 0x03) + out[:, :, 3] = (high[:, :, 3] << 2) | ((low >> 6) & 0x03) + return np.ascontiguousarray(out.reshape(height, width)) + + def packed_raw10_to_preview_bgr(self, packed_frame: np.ndarray, **kwargs): + """Atalho oficial RAW10 packed -> preview BGR.""" + raw16 = self.unpack_raw10_packed(packed_frame) + return self.raw16_to_preview_bgr(raw16, **kwargs) + + def raw16_to_preview_jpg_bytes( + self, + raw16: np.ndarray, + jpeg_quality: int = 95, + ) -> bytes: + quality = int(np.clip(int(jpeg_quality), 1, 100)) bgr = self.raw16_to_preview_bgr(raw16) - ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)]) + ok, encoded = cv2.imencode( + ".jpg", + bgr, + [int(cv2.IMWRITE_JPEG_QUALITY), quality], + ) if not ok: raise RuntimeError("Falha ao codificar preview JPG") - return enc.tobytes() + return encoded.tobytes() + + def raw16_to_preview_png_bytes( + self, + raw16: np.ndarray, + compression: int = 3, + ) -> bytes: + level = int(np.clip(int(compression), 0, 9)) + bgr = self.raw16_to_preview_bgr(raw16) + ok, encoded = cv2.imencode( + ".png", + bgr, + [int(cv2.IMWRITE_PNG_COMPRESSION), level], + ) + if not ok: + raise RuntimeError("Falha ao codificar preview PNG") + return encoded.tobytes()