From 255624a0877f62538b090c387d1e6b9425bae285 Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Mon, 31 Aug 2026 15:07:48 -0300 Subject: [PATCH] ajustado orientacao para ar0234 e calibragem --- .../_9_review_dataset_multihead_v2.py | 246 +++- .../oak-fcc-3/core/raw_processor_core.py | 858 ++++++++++++- .../utils/_1_focus_calibration_tool.py | 126 ++ .../utils/_2_intrinsics_calibration_tool.py | 406 +++++- .../utils/_3_flatfield_calibration_tool.py | 355 ++++- ....py => _4_radiometric_calibration_tool.py} | 425 +++++- .../utils/_5_camera_startup_profile.py | 342 ++++- .../utils/_6_homography_calibration_tool.py | 1073 +++++++++++++++- .../oak-fcc-3/utils/_8_build_module_params.py | 1142 ++++++++++++++++- 9 files changed, 4719 insertions(+), 254 deletions(-) rename Python/OAK/datasets/oak-fcc-3/utils/{_4_radiometric_config_tool.py => _4_radiometric_calibration_tool.py} (89%) diff --git a/Python/OAK/datasets/oak-fcc-3/_9_review_dataset_multihead_v2.py b/Python/OAK/datasets/oak-fcc-3/_9_review_dataset_multihead_v2.py index 069c1339c..660a30056 100644 --- a/Python/OAK/datasets/oak-fcc-3/_9_review_dataset_multihead_v2.py +++ b/Python/OAK/datasets/oak-fcc-3/_9_review_dataset_multihead_v2.py @@ -613,12 +613,248 @@ def build_reasons( # ============================================================ -def load_preview_rgb(sample, chw: np.ndarray, base_module, input_channel_names: Sequence[str]) -> np.ndarray: - if getattr(sample, "preview_path", None) is not None and sample.preview_path.is_file(): - bgr = cv2.imread(str(sample.preview_path), cv2.IMREAD_COLOR) +def _preview_stretch_channel( + ch: np.ndarray, + low_pct: float = 1.0, + high_pct: float = 99.5, +) -> np.ndarray: + """ + Auto-level de um canal APENAS para visualização humana. + Não usar para treino/inferência. + """ + ch = np.asarray(ch, dtype=np.float32) + + finite = np.isfinite(ch) + if not finite.any(): + return np.zeros_like(ch, dtype=np.float32) + + vals = ch[finite] + + lo = float(np.percentile(vals, low_pct)) + hi = float(np.percentile(vals, high_pct)) + + if not np.isfinite(lo): + lo = float(vals.min()) + + if not np.isfinite(hi): + hi = float(vals.max()) + + if hi <= lo + 1e-8: + lo = float(vals.min()) + hi = float(vals.max()) + + if hi <= lo + 1e-8: + return np.zeros_like(ch, dtype=np.float32) + + out = (ch - lo) / (hi - lo) + out[~finite] = 0.0 + + return np.clip(out, 0.0, 1.0) + + +def human_preview_rgb( + chw: np.ndarray, + input_channel_names: Sequence[str], + low_pct: float = 1.0, + high_pct: float = 99.5, + gamma: float = 0.82, + clahe_clip: float = 1.6, + sharpen: float = 0.15, +) -> np.ndarray: + """ + Gera RGB pensado EXCLUSIVAMENTE para revisão humana. + + IMPORTANTE: + - não preserva escala radiométrica; + - não entra no treino; + - não entra na inferência; + - não altera tensor/masks; + - objetivo é somente maximizar legibilidade visual. + """ + + names = [ + str(x).strip().upper() + for x in input_channel_names + ] + + idx = { + name: i + for i, name in enumerate(names) + } + + missing = [ + name + for name in ("R", "G", "B") + if name not in idx + ] + + if missing: + raise RuntimeError( + f"Preview RGB impossível: canais ausentes={missing}; " + f"disponíveis={names}" + ) + + rgb = np.stack( + [ + chw[idx["R"]], + chw[idx["G"]], + chw[idx["B"]], + ], + axis=-1, + ).astype(np.float32, copy=False) + + rgb = np.nan_to_num( + rgb, + nan=0.0, + posinf=0.0, + neginf=0.0, + ) + + # -------------------------------------------------------- + # 1. Auto-level independente por canal. + # + # Isso deliberadamente abandona a radiometria em favor + # de uma imagem agradável e facilmente interpretável. + # -------------------------------------------------------- + finite = np.isfinite(rgb) + + vals = rgb[finite] + + lo = float(np.percentile(vals, low_pct)) + hi = float(np.percentile(vals, high_pct)) + + if hi <= lo + 1e-8: + lo = float(vals.min()) + hi = float(vals.max()) + + pretty = (rgb - lo) / max(hi - lo, 1e-8) + + pretty = np.clip( + pretty, + 0.0, + 1.0, + ) + + # -------------------------------------------------------- + # 2. Gamma para levantar sombras e meios-tons. + # + # gamma < 1 => clareia. + # -------------------------------------------------------- + gamma = max(float(gamma), 1e-3) + + pretty = np.power( + np.clip(pretty, 0.0, 1.0), + gamma, + ) + + u8 = np.clip( + np.rint(pretty * 255.0), + 0, + 255, + ).astype(np.uint8) + + # -------------------------------------------------------- + # 3. CLAHE leve somente na luminância. + # + # Ajuda muito a enxergar folhas/contornos em chão escuro + # sem estourar tanto as cores. + # -------------------------------------------------------- + if clahe_clip > 0: + lab = cv2.cvtColor( + u8, + cv2.COLOR_RGB2LAB, + ) + + clahe = cv2.createCLAHE( + clipLimit=float(clahe_clip), + tileGridSize=(8, 8), + ) + + lab[..., 0] = clahe.apply(lab[..., 0]) + + u8 = cv2.cvtColor( + lab, + cv2.COLOR_LAB2RGB, + ) + + # -------------------------------------------------------- + # 4. Nitidez leve. + # + # Só para ajudar o olho humano em bordas/folhas. + # -------------------------------------------------------- + amount = max(0.0, float(sharpen)) + + if amount > 0: + blur = cv2.GaussianBlur( + u8, + (0, 0), + 0.8, + ) + + u8 = cv2.addWeighted( + u8, + 1.0 + amount, + blur, + -amount, + 0.0, + ) + + return u8 + + +def load_preview_rgb( + sample, + chw: np.ndarray, + base_module, + input_channel_names: Sequence[str], +) -> np.ndarray: + """ + Preview para REVISÃO HUMANA. + + Prioridade agora é sempre reconstruir RGB a partir do tensor + radiométrico com tratamento visual. + + O preview original do dataset é usado apenas como fallback. + """ + + try: + return human_preview_rgb( + chw, + input_channel_names, + low_pct=1.0, + high_pct=99.5, + gamma=0.82, + clahe_clip=1.6, + sharpen=0.15, + ) + + except Exception as exc: + print( + f"[PREVIEW][WARN] Falha no preview humano " + f"{getattr(sample, 'base', '?')}: {exc}" + ) + + # Fallback 1: preview já existente. + if ( + getattr(sample, "preview_path", None) is not None + and sample.preview_path.is_file() + ): + bgr = cv2.imread( + str(sample.preview_path), + cv2.IMREAD_COLOR, + ) + if bgr is not None: - return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - return base_module.tensor_to_preview_rgb(chw, input_channel_names) + return cv2.cvtColor( + bgr, + cv2.COLOR_BGR2RGB, + ) + + # Fallback 2: método legado. + return base_module.tensor_to_preview_rgb( + chw, + input_channel_names, + ) def save_review_item( diff --git a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py index 3a2ba9d24..7198c766d 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py +++ b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py @@ -443,6 +443,33 @@ class RawProcessorCore: } } + self.camera_orientation_config = { + "enabled": False, + "schema": "multispec_camera_orientation_v1", + "input_space": "native_stream_no_external_undistort", + "output_space": "module_canonical_no_external_undistort", + "apply_stage": "after_native_flat_before_fusion", + + "by_role": { + "rgb": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + "re": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + "nir": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + }, + } + self.last_orientation_result = None + self.calibration_json_path = calibration_json_path self.calibration_base_dir = os.path.dirname(os.path.abspath(calibration_json_path)) if calibration_json_path else os.getcwd() @@ -1809,6 +1836,9 @@ class RawProcessorCore: if apply_native_flat: decoded = self.apply_flat_gain_to_decoded(decoded) + # ★ NOVO ★ + decoded = self.apply_camera_orientation_to_decoded(decoded) + t_flat_native_ms = (time.perf_counter() - t0) * 1000.0 # Caminho espacial otimizado: direto para o target final. @@ -3634,33 +3664,80 @@ class RawProcessorCore: """ Validação fail-closed do module_params montado pelo assembler de produção. - Arquivos legados continuam aceitos fora do strict_product_contract. + Cadeia geométrica oficial: + + SENSOR / NATIVE SPACE + ↓ + Intrinsics + (runtime undistort OFF) + ↓ + Radiometric / Flat nativo + ↓ + Camera Orientation + ↓ + MODULE CANONICAL SPACE + ↓ + Homography + ↓ + crop / target final + + Regras: + - Intrinsics pertencem ao espaço nativo do sensor. + - camera_orientation.input_space deve casar com intrinsics.calibration_space. + - camera_orientation.output_space deve casar com fusion/homography. + - Homography é calibrada APÓS a orientação. + - 90/270 graus trocam W/H no espaço de calibração. + - Arquivos legados continuam aceitos fora do strict_product_contract. """ if not getattr(self, "strict_product_contract", False): return True + # ============================================================ + # MODULE PARAMS / FRAME + # ============================================================ + if self.module_params_schema != "multispec_module_params_v3": raise RuntimeError( - f"Schema de module_params inválido: {self.module_params_schema!r}" + f"Schema de module_params inválido: " + f"{self.module_params_schema!r}" ) if str(data.get("frame_type", "RAW_BRUTO")).upper() != "RAW_BRUTO": raise RuntimeError( - f"Produto exige frame_type='RAW_BRUTO', veio {data.get('frame_type')!r}." + "Produto exige frame_type='RAW_BRUTO', " + f"veio {data.get('frame_type')!r}." ) - if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"): + if self.bayer_pattern not in ( + "RGGB", + "BGGR", + "GRBG", + "GBRG", + ): raise RuntimeError( - f"Bayer inválido no module_params: {self.bayer_pattern!r}" + f"Bayer inválido no module_params: " + f"{self.bayer_pattern!r}" ) + # ============================================================ + # HARDWARE + # ============================================================ + if not isinstance(self.sensor_size_by_role, dict): - raise RuntimeError("sensor_size_by_role ausente/inválido.") + raise RuntimeError( + "sensor_size_by_role ausente/inválido." + ) if not isinstance(self.camera_hardware, dict): - raise RuntimeError("camera_hardware ausente/inválido.") + raise RuntimeError( + "camera_hardware ausente/inválido." + ) - for role in ("rgb", "re", "nir"): + for role in ( + "rgb", + "re", + "nir", + ): size = self.sensor_size_by_role.get(role) hw = self.camera_hardware.get(role) @@ -3680,11 +3757,22 @@ class RawProcessorCore: ) hw_size = hw.get("size") + if hw_size is not None: + if not ( + isinstance(hw_size, (list, tuple)) + and len(hw_size) == 2 + ): + raise RuntimeError( + f"camera_hardware.{role}.size inválido: " + f"{hw_size}" + ) + hw_size = [ int(hw_size[0]), int(hw_size[1]), ] + expected_size = [ int(size[0]), int(size[1]), @@ -3700,6 +3788,7 @@ class RawProcessorCore: int(self.sensor_width), int(self.sensor_height), ] + expected_rgb = [ int(x) for x in self.sensor_size_by_role["rgb"] @@ -3707,9 +3796,14 @@ class RawProcessorCore: if rgb_size != expected_rgb: raise RuntimeError( - f"sensor_width/height root {rgb_size} != RGB {expected_rgb}" + f"sensor_width/height root {rgb_size} " + f"!= RGB {expected_rgb}" ) + # ============================================================ + # RGB PROCESSING + # ============================================================ + rgb_mode = str( (self.rgb_processing_config or {}).get( "mode", @@ -3731,12 +3825,19 @@ class RawProcessorCore: if rgb_mode not in allowed_rgb_modes: raise RuntimeError( - f"rgb_processing.mode inválido no produto: {rgb_mode!r}" + "rgb_processing.mode inválido no produto: " + f"{rgb_mode!r}" ) + # ============================================================ + # FUSION + # ============================================================ + + fusion = self.fusion_config or {} + if ( str( - (self.fusion_config or {}).get( + fusion.get( "alignment_mode", "", ) @@ -3744,9 +3845,14 @@ class RawProcessorCore: != "homography" ): raise RuntimeError( - "Produto exige fusion_config.alignment_mode='homography'." + "Produto exige " + "fusion_config.alignment_mode='homography'." ) + # ============================================================ + # FLAT-FIELD + # ============================================================ + if not bool( (self.flatfield_config or {}).get( "enabled", @@ -3766,7 +3872,7 @@ class RawProcessorCore: if flat_space != "native_camera_space": raise RuntimeError( - f"Produto exige Flat-Field no espaço nativo; " + "Produto exige Flat-Field no espaço nativo; " f"apply_space={flat_space!r}" ) @@ -3777,15 +3883,24 @@ class RawProcessorCore: ) ): raise RuntimeError( - "Produto atual exige flatfield_config.subtract_dark=false." + "Produto atual exige " + "flatfield_config.subtract_dark=false." ) if not self.flatfield_loaded: raise RuntimeError( - "Flat-field produto não foi carregado integralmente." + "Flat-field produto não foi carregado " + "integralmente." ) - rad_norm = self.radiometric_normalization_config or {} + # ============================================================ + # RADIOMETRIC NORMALIZATION + # ============================================================ + + rad_norm = ( + self.radiometric_normalization_config + or {} + ) if not bool( rad_norm.get( @@ -3794,7 +3909,8 @@ class RawProcessorCore: ) ): raise RuntimeError( - "Produto exige radiometric_normalization.enabled=true." + "Produto exige " + "radiometric_normalization.enabled=true." ) if ( @@ -3807,7 +3923,7 @@ class RawProcessorCore: != "oak_ae_frame_controls_v1" ): raise RuntimeError( - f"Método radiométrico inválido no produto: " + "Método radiométrico inválido no produto: " f"{rad_norm.get('method')!r}" ) @@ -3820,7 +3936,8 @@ class RawProcessorCore: if factor_model != "exposure_time_us_x_iso": raise RuntimeError( - f"factor_model radiométrico inválido: {factor_model!r}" + "factor_model radiométrico inválido: " + f"{factor_model!r}" ) apply_stage = str( @@ -3832,19 +3949,29 @@ class RawProcessorCore: if apply_stage != "after_dark_before_flat_gain": raise RuntimeError( - f"apply_stage radiométrico incompatível: {apply_stage!r}" + "apply_stage radiométrico incompatível: " + f"{apply_stage!r}" ) - refs = rad_norm.get( - "reference_controls", - {}, - ) or {} + refs = ( + rad_norm.get( + "reference_controls", + {}, + ) + or {} + ) - for role in ("rgb", "re", "nir"): + for role in ( + "rgb", + "re", + "nir", + ): ref = refs.get(role) + if not isinstance(ref, dict): raise RuntimeError( - f"radiometric_normalization sem reference_controls.{role}" + "radiometric_normalization sem " + f"reference_controls.{role}" ) try: @@ -3854,22 +3981,30 @@ class RawProcessorCore: 0, ) ) + iso = float( ref.get( "sensitivity_iso", 0, ) ) + except Exception as exc: raise RuntimeError( - f"reference_controls inválido em {role}: {ref}" + f"reference_controls inválido em " + f"{role}: {ref}" ) from exc if exp <= 0 or iso <= 0: raise RuntimeError( - f"reference_controls inválido em {role}: {ref}" + f"reference_controls inválido em " + f"{role}: {ref}" ) + # ============================================================ + # LEGACY SCIENTIFIC CONTROLLERS MUST BE OFF + # ============================================================ + if bool( (self.radiometric_config or {}).get( "enabled", @@ -3877,7 +4012,8 @@ class RawProcessorCore: ) ): raise RuntimeError( - "radiometric_config controller deve estar desligado no produto." + "radiometric_config controller deve " + "estar desligado no produto." ) if bool( @@ -3887,7 +4023,8 @@ class RawProcessorCore: ) ): raise RuntimeError( - "patch_normalization deve estar desligado no produto." + "patch_normalization deve estar " + "desligado no produto." ) if bool( @@ -3897,9 +4034,14 @@ class RawProcessorCore: ) ): raise RuntimeError( - "rgb_calibration manual deve estar desligado no produto." + "rgb_calibration manual deve estar " + "desligado no produto." ) + # ============================================================ + # INTRINSICS + # ============================================================ + intr = self.intrinsics_config or {} if not bool( @@ -3927,8 +4069,9 @@ class RawProcessorCore: ) ): raise RuntimeError( - "runtime_undistort=true ainda não é permitido neste core. " - "Recalibre Homography no espaço undistorted e implemente esse " + "runtime_undistort=true ainda não é " + "permitido neste core. Recalibre Homography " + "no espaço undistorted e implemente esse " "estágio antes de ativar." ) @@ -3937,30 +4080,13 @@ class RawProcessorCore: "calibration_space", "native_stream_no_external_undistort", ) - ) + ).strip() - for role in ("re", "nir"): - entry = self._resolve_homography_geometry_entry_for_role( - role + if not intr_space: + raise RuntimeError( + "intrinsics_config.calibration_space vazio." ) - hom_space = str( - entry.get( - "coordinate_space", - ) - or "" - ) - - if ( - hom_space - and intr_space - and hom_space != intr_space - ): - raise RuntimeError( - f"Domínio geométrico divergente em {role}: " - f"intrinsics={intr_space}, homography={hom_space}" - ) - cameras = ( intr.get( "cameras", @@ -3969,7 +4095,11 @@ class RawProcessorCore: or {} ) - for role in ("rgb", "re", "nir"): + for role in ( + "rgb", + "re", + "nir", + ): cam = cameras.get(role) if not isinstance(cam, dict): @@ -3980,6 +4110,7 @@ class RawProcessorCore: image_size = cam.get( "image_size" ) + expected = [ int(x) for x in self.sensor_size_by_role[role] @@ -4033,9 +4164,443 @@ class RawProcessorCore: f"Intrinsics D inválida em {role}." ) - # Enhancement é não linear e, no caminho legado atual, acontece antes - # da normalização radiométrica. Produto calibrado não pode ativá-lo. - enh = self._get_rgb_enhancement_config() + # ============================================================ + # CAMERA ORIENTATION + # ============================================================ + + orientation = getattr( + self, + "camera_orientation_config", + None, + ) + + if not isinstance( + orientation, + dict, + ): + raise RuntimeError( + "Produto exige camera_orientation " + "explícito no module_params." + ) + + orientation_schema = str( + orientation.get( + "schema", + "", + ) + ).strip() + + if ( + orientation_schema + != "multispec_camera_orientation_v1" + ): + raise RuntimeError( + "camera_orientation.schema inválido: " + f"{orientation_schema!r}" + ) + + orientation_enabled = bool( + orientation.get( + "enabled", + False, + ) + ) + + orientation_stage = str( + orientation.get( + "apply_stage", + "", + ) + ).strip().lower() + + if ( + orientation_stage + != "after_native_flat_before_fusion" + ): + raise RuntimeError( + "camera_orientation.apply_stage inválido: " + f"{orientation_stage!r}. " + "Esperado='after_native_flat_before_fusion'." + ) + + orientation_input_space = str( + orientation.get( + "input_space", + "", + ) + ).strip() + + orientation_output_space = str( + orientation.get( + "output_space", + "", + ) + ).strip() + + if not orientation_input_space: + raise RuntimeError( + "camera_orientation.input_space vazio." + ) + + if not orientation_output_space: + raise RuntimeError( + "camera_orientation.output_space vazio." + ) + + # Intrinsics descrevem o sensor antes da orientação. + if orientation_input_space != intr_space: + raise RuntimeError( + "Domínio geométrico incompatível entre " + "Intrinsics e Camera Orientation: " + f"intrinsics={intr_space!r}, " + f"orientation.input_space=" + f"{orientation_input_space!r}" + ) + + orientation_by_role = ( + orientation.get( + "by_role", + {}, + ) + or {} + ) + + if not isinstance( + orientation_by_role, + dict, + ): + raise RuntimeError( + "camera_orientation.by_role inválido." + ) + + # Guarda também os tamanhos no domínio APÓS orientação. + orientation_geometry = {} + + has_non_identity_transform = False + + for role in ( + "rgb", + "re", + "nir", + ): + role_cfg = orientation_by_role.get( + role + ) + + if not isinstance( + role_cfg, + dict, + ): + raise RuntimeError( + f"camera_orientation.by_role.{role} " + "ausente/inválido." + ) + + rotate_raw = role_cfg.get( + "rotate_deg", + 0, + ) + + try: + rotate_float = float( + rotate_raw + ) + except Exception as exc: + raise RuntimeError( + f"rotate_deg inválido em {role}: " + f"{rotate_raw!r}" + ) from exc + + if not np.isfinite( + rotate_float + ): + raise RuntimeError( + f"rotate_deg não finito em {role}: " + f"{rotate_raw!r}" + ) + + # Não deixa 179.9 virar 179 silenciosamente. + if abs( + rotate_float + - round( + rotate_float + ) + ) > 1e-9: + raise RuntimeError( + f"rotate_deg deve ser inteiro em {role}: " + f"{rotate_raw!r}" + ) + + rotate_deg = ( + int( + round( + rotate_float + ) + ) + % 360 + ) + + if rotate_deg not in ( + 0, + 90, + 180, + 270, + ): + raise RuntimeError( + f"rotate_deg inválido em {role}: " + f"{rotate_deg}. " + "Permitidos=0,90,180,270." + ) + + flip_horizontal = role_cfg.get( + "flip_horizontal", + False, + ) + + flip_vertical = role_cfg.get( + "flip_vertical", + False, + ) + + # Fail-closed. Não aceita "false" string, 0, 1 etc. + if not isinstance( + flip_horizontal, + bool, + ): + raise RuntimeError( + "camera_orientation." + f"by_role.{role}.flip_horizontal " + "deve ser boolean." + ) + + if not isinstance( + flip_vertical, + bool, + ): + raise RuntimeError( + "camera_orientation." + f"by_role.{role}.flip_vertical " + "deve ser boolean." + ) + + if ( + rotate_deg != 0 + or flip_horizontal + or flip_vertical + ): + has_non_identity_transform = True + + native_w = int( + self.sensor_size_by_role[ + role + ][0] + ) + + native_h = int( + self.sensor_size_by_role[ + role + ][1] + ) + + if rotate_deg in ( + 90, + 270, + ): + oriented_size = [ + native_h, + native_w, + ] + else: + oriented_size = [ + native_w, + native_h, + ] + + orientation_geometry[role] = { + "rotate_deg": rotate_deg, + "flip_horizontal": ( + flip_horizontal + ), + "flip_vertical": ( + flip_vertical + ), + "input_size": [ + native_w, + native_h, + ], + "output_size": oriented_size, + } + + # Se orientation está desligado, não permitimos configuração + # latente que o operador pensa estar ativa. + if not orientation_enabled: + if has_non_identity_transform: + raise RuntimeError( + "camera_orientation.enabled=false, " + "mas existe rotate/flip diferente de identidade." + ) + + if ( + orientation_output_space + != orientation_input_space + ): + raise RuntimeError( + "camera_orientation.enabled=false exige " + "output_space == input_space. " + f"input={orientation_input_space!r}, " + f"output={orientation_output_space!r}" + ) + + homography_expected_space = ( + orientation_input_space + ) + + else: + homography_expected_space = ( + orientation_output_space + ) + + # ============================================================ + # FUSION SPACE + # ============================================================ + + fusion_space = str( + fusion.get( + "coordinate_space", + "", + ) + ).strip() + + if not fusion_space: + raise RuntimeError( + "fusion_config.coordinate_space ausente." + ) + + if ( + fusion_space + != homography_expected_space + ): + raise RuntimeError( + "Domínio geométrico incompatível entre " + "Camera Orientation e Fusion: " + f"esperado={homography_expected_space!r}, " + f"fusion={fusion_space!r}" + ) + + # ============================================================ + # HOMOGRAPHY + # ============================================================ + + expected_rgb_reference_size = ( + orientation_geometry[ + "rgb" + ][ + "output_size" + ] + ) + + for role in ( + "re", + "nir", + ): + entry = ( + self + ._resolve_homography_geometry_entry_for_role( + role + ) + ) + + hom_space = str( + entry.get( + "coordinate_space", + ) + or "" + ).strip() + + if not hom_space: + raise RuntimeError( + "Homography sem coordinate_space " + f"em role={role}." + ) + + if ( + hom_space + != homography_expected_space + ): + raise RuntimeError( + f"Domínio geométrico divergente em {role}: " + f"orientation_output=" + f"{homography_expected_space!r}, " + f"homography={hom_space!r}" + ) + + source_calibration_size = [ + int(x) + for x in ( + entry.get( + "source_calibration_size", + [], + ) + or [] + ) + ] + + reference_calibration_size = [ + int(x) + for x in ( + entry.get( + "reference_calibration_size", + [], + ) + or [] + ) + ] + + expected_source_size = ( + orientation_geometry[ + role + ][ + "output_size" + ] + ) + + # Homography foi calibrada DEPOIS da orientação, + # portanto seus tamanhos precisam descrever o domínio + # canônico, não o sensor-native anterior à rotação. + if ( + source_calibration_size + != expected_source_size + ): + raise RuntimeError( + "Homography source_size incompatível " + f"em {role}: " + f"homography={source_calibration_size}, " + f"orientation_output=" + f"{expected_source_size}" + ) + + if ( + reference_calibration_size + != expected_rgb_reference_size + ): + raise RuntimeError( + "Homography reference_size incompatível " + f"em {role}: " + f"homography={reference_calibration_size}, " + "rgb_orientation_output=" + f"{expected_rgb_reference_size}" + ) + + # ============================================================ + # RGB ENHANCEMENT + # ============================================================ + + # Enhancement é não linear e, no caminho legado atual, + # acontece antes da normalização radiométrica. + # Produto calibrado não pode ativá-lo. + enh = ( + self._get_rgb_enhancement_config() + ) if bool( enh.get( @@ -4044,11 +4609,19 @@ class RawProcessorCore: ) ): raise RuntimeError( - "rgb_processing.enhancement deve ficar disabled no pipeline " - "calibrado de produto." + "rgb_processing.enhancement deve ficar " + "disabled no pipeline calibrado de produto." ) - for role in ("rgb", "re", "nir"): + # ============================================================ + # CAMERA SETTINGS + # ============================================================ + + for role in ( + "rgb", + "re", + "nir", + ): if role not in ( self.camera_settings or {} @@ -4059,7 +4632,6 @@ class RawProcessorCore: return True - def _validate_stream_camera_contract(self, frame: dict, meta: dict): """ Confere por frame se o RAW recebido pertence ao hardware homologado. @@ -4341,6 +4913,13 @@ class RawProcessorCore: self.flatfield_maps = {} self.flatfield_loaded = False + orientation = data.get("camera_orientation") + if isinstance(orientation, dict): + self.camera_orientation_config = self._merge_config( + self.camera_orientation_config, + orientation, + ) + self.clear_direct_fusion_geometry_cache() self._flatfield_runtime_cache = {} @@ -7665,5 +8244,160 @@ class RawProcessorCore: return gain_tensor + def _apply_orientation_image( + self, + img, + rotate_deg=0, + flip_horizontal=False, + flip_vertical=False, + ): + rotate_deg = int(rotate_deg) % 360 + if rotate_deg == 90: + 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, + ) + + elif rotate_deg != 0: + raise RuntimeError( + f"rotate_deg inválido: {rotate_deg}" + ) + + if flip_horizontal: + img = cv2.flip( + img, + 1, + ) + + if flip_vertical: + img = cv2.flip( + img, + 0, + ) + + return np.ascontiguousarray( + img + ) + + def apply_camera_orientation_to_decoded( + self, + decoded, + ): + cfg = self.camera_orientation_config or {} + + result = { + "enabled": bool( + cfg.get("enabled", False) + ), + "applied": False, + "by_role": {}, + } + + if not cfg.get("enabled", False): + self.last_orientation_result = result + return decoded + + by_role = cfg.get( + "by_role", + {}, + ) or {} + + out = {} + + for cam_id, item in decoded.items(): + + role = str( + item.get("role") + or item.get("meta", {}).get("role") + or "" + ).lower() + + role_cfg = by_role.get( + role, + {}, + ) or {} + + rotate_deg = int( + role_cfg.get( + "rotate_deg", + 0, + ) + ) + + flip_h = bool( + role_cfg.get( + "flip_horizontal", + False, + ) + ) + + flip_v = bool( + role_cfg.get( + "flip_vertical", + False, + ) + ) + + image = item.get("image") + + new_item = dict(item) + new_meta = dict( + item.get("meta", {}) or {} + ) + + if ( + image is not None + and ( + rotate_deg != 0 + or flip_h + or flip_v + ) + ): + image = self._apply_orientation_image( + image, + rotate_deg=rotate_deg, + flip_horizontal=flip_h, + flip_vertical=flip_v, + ) + + result["applied"] = True + + new_meta["orientation_applied"] = { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_h, + "flip_vertical": flip_v, + } + + new_item["image"] = image + new_item["meta"] = new_meta + + out[cam_id] = new_item + + result["by_role"][role] = { + "camera_id": cam_id, + "rotate_deg": rotate_deg, + "flip_horizontal": flip_h, + "flip_vertical": flip_v, + "shape": ( + list(image.shape) + if image is not None + else None + ), + } + + self.last_orientation_result = result + return out diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_1_focus_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_1_focus_calibration_tool.py index 516328ade..561a56421 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/_1_focus_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/_1_focus_calibration_tool.py @@ -177,6 +177,26 @@ MONO_SENSOR_MODES = { } } +SENSOR_ORIENTATION = { + "OV9782": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + + "AR0234": { + "rotate_deg": 180, + "flip_horizontal": False, + "flip_vertical": False, + }, + + "OV9282": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, +} + SCHEMA = "multispec_focus_qc_v3" @@ -494,6 +514,88 @@ def rect_from_norm(shape_hw, x0, y0, x1, y1): ) +def apply_sensor_orientation( + img: np.ndarray, + sensor_name: str, +) -> np.ndarray: + if img is None: + return img + + sensor = str( + sensor_name + or "" + ).strip().upper() + + cfg = ( + SENSOR_ORIENTATION.get( + sensor, + {} + ) + or {} + ) + + rotate_deg = int( + cfg.get( + "rotate_deg", + 0, + ) + ) % 360 + + flip_horizontal = bool( + cfg.get( + "flip_horizontal", + False, + ) + ) + + flip_vertical = bool( + cfg.get( + "flip_vertical", + False, + ) + ) + + if rotate_deg == 90: + 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, + ) + + elif rotate_deg != 0: + raise RuntimeError( + f"rotate_deg inválido para " + f"{sensor}: {rotate_deg}" + ) + + if flip_horizontal: + img = cv2.flip( + img, + 1, + ) + + if flip_vertical: + img = cv2.flip( + img, + 0, + ) + + return np.ascontiguousarray( + img + ) + + # ============================================================ # Descoberta e contrato de hardware # ============================================================ @@ -1673,6 +1775,16 @@ class FocusRuntime: if img is None: continue + # ---------------------------------------------------- + # Orientação canônica para QC / visualização + # ---------------------------------------------------- + spec = self.specs[role] + + img = apply_sensor_orientation( + img, + sensor_name=spec.sensor_name, + ) + if img.ndim == 2: img = cv2.cvtColor( img, @@ -2695,6 +2807,20 @@ def build_base_report( "results_by_role": {}, "snapshots": [], "promoted": False, + "orientation_used": { + role: { + "sensor": specs[role].sensor_name, + **SENSOR_ORIENTATION.get( + specs[role].sensor_name, + { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + ), + } + for role in ROLES + }, } diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_2_intrinsics_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_2_intrinsics_calibration_tool.py index 37c64d52a..544669127 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/_2_intrinsics_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/_2_intrinsics_calibration_tool.py @@ -26,6 +26,10 @@ IMPORTANTE: Se no futuro ativarmos undistort no runtime, a homografia precisa ser recalibrada no mesmo espaço geométrico undistorted. + A orientação configurável deste calibrador é SOMENTE de preview. + ChArUco, pose, K, D, reprojection e acceptance permanecem sempre no + espaço nativo do sensor. + Fluxo: 1) Mostre o ChArUco nas 3 câmeras. 2) ENTER trava EXP/ISO. @@ -80,6 +84,26 @@ MONO_MODES = { "OV9282": ("THE_800_P", 1280, 800), } +# Orientação SOMENTE para visualização/diagnóstico. +# Não altera os frames científicos usados para calcular Intrinsics. +PREVIEW_ORIENTATION_BY_SENSOR = { + "OV9782": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + "AR0234": { + "rotate_deg": 180, + "flip_horizontal": False, + "flip_vertical": False, + }, + "OV9282": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, +} + QA = { "min_markers": 4, "min_corners": 10, @@ -183,6 +207,105 @@ def safe_int(v, default=None): return default +def normalize_preview_orientation(cfg): + cfg = cfg or {} + + rotate_deg = int(cfg.get("rotate_deg", 0)) % 360 + + if rotate_deg not in (0, 90, 180, 270): + raise RuntimeError( + f"Preview rotate_deg inválido: {rotate_deg}. " + "Permitidos=0,90,180,270." + ) + + flip_horizontal = cfg.get("flip_horizontal", False) + flip_vertical = cfg.get("flip_vertical", False) + + if not isinstance(flip_horizontal, bool): + raise RuntimeError("flip_horizontal deve ser bool.") + + if not isinstance(flip_vertical, bool): + raise RuntimeError("flip_vertical deve ser bool.") + + return { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_horizontal, + "flip_vertical": flip_vertical, + } + + +def apply_preview_orientation(img: np.ndarray, cfg: dict) -> np.ndarray: + """ + Ajusta SOMENTE apresentação/diagnóstico. + + NÃO use a saída desta função para: + - detect_charuco() + - pose_signature() + - calibrateCamera() + - cálculo de K/D + - reprojection error + + Intrinsics permanecem no espaço nativo do sensor. + """ + if img is None: + return img + + cfg = normalize_preview_orientation(cfg) + rotate_deg = cfg["rotate_deg"] + + if rotate_deg == 90: + 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 resolve_preview_orientation(specs, args): + """ + Resolve orientação visual por sensor, com override opcional por role. + + Isso NÃO muda o domínio científico da calibração. + """ + result = {} + + for role in ROLES: + sensor = str(specs[role].sensor).upper() + + base = dict( + PREVIEW_ORIENTATION_BY_SENSOR.get( + sensor, + { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + ) + ) + + rotate_override = getattr(args, f"{role}_preview_rotate_deg") + if rotate_override is not None: + base["rotate_deg"] = int(rotate_override) + + if getattr(args, f"{role}_preview_flip_horizontal"): + base["flip_horizontal"] = True + + if getattr(args, f"{role}_preview_flip_vertical"): + base["flip_vertical"] = True + + result[role] = normalize_preview_orientation(base) + + return result + + def overlay(img, lines, x=12, y=24, scale=0.45, step=19): yy = y for line in lines: @@ -1402,6 +1525,9 @@ def module_fragment(evaluation, specs): "undistort_products": m["undistort_products"], } + # IMPORTANTE: + # camera_orientation NÃO entra neste fragmento. K/D pertencem ao raster + # nativo do sensor. A orientação canônica é um estágio geométrico separado. return { "intrinsics_config": { "enabled": True, @@ -1440,20 +1566,64 @@ def draw_points(panel, points, src_shape, color): cv2.FONT_HERSHEY_SIMPLEX, 0.3, color, 1, cv2.LINE_AA) -def save_view_preview(path, frames, det): +def save_view_preview(path, frames, det, preview_orientation): + """ + Salva PNG de diagnóstico em orientação visual canônica. + + Os corners são desenhados primeiro no espaço nativo e só depois imagem + + overlays são rotacionados juntos. Os dados científicos permanecem nativos. + """ panels = [] - colors = {"rgb": (0, 255, 0), "re": (0, 255, 255), "nir": (255, 255, 0)} + colors = { + "rgb": (0, 255, 0), + "re": (0, 255, 255), + "nir": (255, 255, 0), + } for role in ROLES: - img = frames[role] - bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) - p = cv2.resize(bgr, (480, 300), interpolation=cv2.INTER_AREA) - draw_points(p, det["detections"][role]["points"], img.shape[:2], colors[role]) - overlay(p, [ - role.upper(), - f"markers={det['detections'][role]['markers']}", - f"corners={det['detections'][role]['corners']}", - ], x=8, y=18, scale=0.36, step=16) + img_native = frames[role] + + bgr = ( + img_native.copy() + if img_native.ndim == 3 + else cv2.cvtColor(img_native, cv2.COLOR_GRAY2BGR) + ) + + # Points/detections ainda estão no espaço nativo. + draw_points( + bgr, + det["detections"][role]["points"], + img_native.shape[:2], + colors[role], + ) + + # Somente agora converte para a orientação visual do módulo. + bgr = apply_preview_orientation( + bgr, + preview_orientation[role], + ) + + p = cv2.resize( + bgr, + (480, 300), + interpolation=cv2.INTER_AREA, + ) + + orient = preview_orientation[role] + overlay( + p, + [ + role.upper(), + f"markers={det['detections'][role]['markers']}", + f"corners={det['detections'][role]['corners']}", + f"DISPLAY ROT={orient['rotate_deg']} | CALIB=NATIVE", + ], + x=8, + y=18, + scale=0.34, + step=15, + ) + panels.append(p) cv2.imwrite(str(path), np.hstack(panels)) @@ -1468,47 +1638,119 @@ def undistorted(frame, cam_result, alpha): return cv2.undistort(frame, K, D, None, newK) -def build_ui(runtime, specs, views, last_det, evaluation, - selected_role, show_undist, args, msg=""): +def build_ui( + runtime, + specs, + views, + last_det, + evaluation, + selected_role, + show_undist, + args, + preview_orientation, + msg="", +): + """ + UI em orientação canônica SOMENTE para visualização. + + runtime.frames permanece SENSOR-NATIVE do começo ao fim. + """ pw, ph = args.panel_width, args.panel_height panels = {} - colors = {"rgb": (0, 255, 0), "re": (0, 255, 255), "nir": (255, 255, 0)} + colors = { + "rgb": (0, 255, 0), + "re": (0, 255, 255), + "nir": (255, 255, 0), + } for role in ROLES: - frame = runtime.frames[role] + frame_native = runtime.frames[role] - if show_undist and evaluation is not None and role == selected_role: - frame = undistorted( - frame, evaluation["cameras"][role], args.preview_alpha + if frame_native is None: + raise RuntimeError(f"{role}: frame ausente ao montar UI.") + + showing_undist = bool( + show_undist + and evaluation is not None + and role == selected_role + ) + + if showing_undist: + frame_view = undistorted( + frame_native, + evaluation["cameras"][role], + args.preview_alpha, ) + else: + frame_view = frame_native - bgr = frame if frame.ndim == 3 else cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) - p = cv2.resize(bgr, (pw, ph), interpolation=cv2.INTER_AREA) + bgr = ( + frame_view.copy() + if frame_view.ndim == 3 + else cv2.cvtColor(frame_view, cv2.COLOR_GRAY2BGR) + ) - if last_det is not None: + # last_det pertence ao frame distorcido/nativo. + # Desenhamos antes da rotação visual. No preview undistorted os corners + # antigos seriam geometricamente incorretos, então não os desenhamos. + if last_det is not None and not showing_undist: draw_points( - p, + bgr, last_det["detections"][role]["points"], - runtime.frames[role].shape[:2], + frame_native.shape[:2], colors[role], ) - overlay(p, [ - f"{role.upper()} | {specs[role].sensor}", - f"{specs[role].width}x{specs[role].height}", - "UNDIST PREVIEW" if (show_undist and role == selected_role) else "NATIVE", - f"EXP={runtime.ctrl[role].get('exposure_time_us')}us " - f"ISO={runtime.ctrl[role].get('sensitivity_iso')}", - ], x=8, y=18, scale=0.36, step=16) + bgr = apply_preview_orientation( + bgr, + preview_orientation[role], + ) + + p = cv2.resize( + bgr, + (pw, ph), + interpolation=cv2.INTER_AREA, + ) + + orient = preview_orientation[role] + mode = ( + "UNDIST PREVIEW | CALIB=NATIVE" + if showing_undist + else "DISPLAY CANONICAL | CALIB=NATIVE" + ) + + overlay( + p, + [ + f"{role.upper()} | {specs[role].sensor}", + f"native={specs[role].width}x{specs[role].height}", + mode, + ( + f"ROT={orient['rotate_deg']} " + f"FH={int(orient['flip_horizontal'])} " + f"FV={int(orient['flip_vertical'])}" + ), + ( + f"EXP={runtime.ctrl[role].get('exposure_time_us')}us " + f"ISO={runtime.ctrl[role].get('sensitivity_iso')}" + ), + ], + x=8, + y=18, + scale=0.34, + step=15, + ) panels[role] = p data = np.zeros((ph, pw, 3), dtype=np.uint8) + selected_orient = preview_orientation[selected_role] lines = [ "INTRINSICS CALIBRATION - PRODUCTION", f"views={len(views)} / min={QA['min_views']} rec={QA['recommended_views']}", f"preview={selected_role.upper()} undist={'ON' if show_undist else 'OFF'}", + f"measurement=NATIVE | display_rot={selected_orient['rotate_deg']}", "", "G capture | A calibrate", "V clear | S snapshot", @@ -1539,8 +1781,14 @@ def build_ui(runtime, specs, views, last_det, evaluation, if msg: cv2.putText( - board, msg, (16, board.shape[0] - 14), - cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 255, 0), 2, cv2.LINE_AA, + board, + msg, + (16, board.shape[0] - 14), + cv2.FONT_HERSHEY_SIMPLEX, + 0.52, + (0, 255, 0), + 2, + cv2.LINE_AA, ) return board @@ -1596,6 +1844,30 @@ def main(): ap.add_argument("--lens-nir", default="") ap.add_argument("--notes", default="") + # Orientação SOMENTE de preview. + # Defaults vêm do sensor detectado; as flags abaixo são overrides locais. + for role in ROLES: + ap.add_argument( + f"--{role}-preview-rotate-deg", + type=int, + default=None, + choices=[0, 90, 180, 270], + help=( + "Rotação SOMENTE do preview. " + "Não altera o domínio da calibração intrínseca." + ), + ) + ap.add_argument( + f"--{role}-preview-flip-horizontal", + action="store_true", + help="Flip horizontal somente para preview.", + ) + ap.add_argument( + f"--{role}-preview-flip-vertical", + action="store_true", + help="Flip vertical somente para preview.", + ) + args = ap.parse_args() sid = stamp() @@ -1629,6 +1901,7 @@ def main(): try: rows, actual_mx, usb = discover(args.mx_id) specs = validate_specs(rows) + preview_orientation = resolve_preview_orientation(specs, args) pipeline, isp = build_pipeline( specs, @@ -1650,6 +1923,18 @@ def main(): f"{s.width}x{s.height} | {s.source}" ) + print("-" * 82) + print("[GEOMETRY] Intrinsics measurement = SENSOR NATIVE") + for role in ROLES: + o = preview_orientation[role] + print( + f"[PREVIEW] {role.upper():3s} | " + f"rotate={o['rotate_deg']:3d} | " + f"flip_h={o['flip_horizontal']} | " + f"flip_v={o['flip_vertical']} | " + "runtime_effect=NONE" + ) + print("=" * 82) report = { @@ -1664,6 +1949,24 @@ def main(): "usb_speed": usb, "hardware_signature": hardware_signature(specs), "calibration_space": CALIBRATION_SPACE, + "geometry_contract": { + "intrinsics_measurement_space": CALIBRATION_SPACE, + "measurement_orientation": "sensor_native", + "preview_orientation_only": True, + "preview_orientation_by_role": { + role: { + "sensor": specs[role].sensor, + **preview_orientation[role], + } + for role in ROLES + }, + "runtime_orientation_effect": "none", + "notes": ( + "Preview pode ser rotacionado/flipado para o operador, " + "mas ChArUco, pose, K, D, reprojection e acceptance " + "permanecem no espaço nativo do sensor." + ), + }, "charuco": { "dictionary": args.charuco_dictionary, "squares_x": args.charuco_squares_x, @@ -1715,8 +2018,20 @@ def main(): panels = [] for role in ROLES: - img = runtime.frames[role] - bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + img_native = runtime.frames[role] + bgr = ( + img_native.copy() + if img_native.ndim == 3 + else cv2.cvtColor(img_native, cv2.COLOR_GRAY2BGR) + ) + + # Somente visual. detect_triplet acima continua usando + # runtime.frames no espaço nativo. + bgr = apply_preview_orientation( + bgr, + preview_orientation[role], + ) + p = cv2.resize( bgr, (args.panel_width, args.panel_height), @@ -1724,12 +2039,14 @@ def main(): ) d = det["detections"][role] + orient = preview_orientation[role] overlay(p, [ f"{role.upper()} | {specs[role].sensor}", f"markers={d['markers']} corners={d['corners']}", + f"DISPLAY ROT={orient['rotate_deg']} | CALIB=NATIVE", f"EXP={runtime.ctrl[role].get('exposure_time_us')}us " f"ISO={runtime.ctrl[role].get('sensitivity_iso')}", - ], x=8, y=18, scale=0.38, step=17) + ], x=8, y=18, scale=0.36, step=16) panels.append(p) @@ -1794,7 +2111,7 @@ def main(): ui = build_ui( runtime, specs, views, last_det, evaluation, - selected_role, show_undist, args, msg + selected_role, show_undist, args, preview_orientation, msg ) cv2.imshow(window, ui) @@ -1870,6 +2187,12 @@ def main(): "captured_at": now_str(), "pose": pose, "controls": trip["controls"], + "measurement_space": CALIBRATION_SPACE, + "measurement_orientation": "sensor_native", + "preview_orientation_by_role": { + role: dict(preview_orientation[role]) + for role in ROLES + }, "detections": { role: { "markers": det["detections"][role]["markers"], @@ -1885,7 +2208,7 @@ def main(): preview = views_dir / f"view_{view_id:03d}.png" save_view_preview( - preview, trip["frames"], det + preview, trip["frames"], det, preview_orientation ) view["preview_png"] = str(preview) @@ -1991,8 +2314,13 @@ def main(): print("[RUNTIME] runtime_undistort permanece false.") print( - "[IMPORTANTE] Se ativarmos undistort, recalibrar Homography " - "no espaço undistorted." + "[GEOMETRY] K/D permanecem no SENSOR NATIVE. " + "Preview orientation não altera a calibração." + ) + print( + "[IMPORTANTE] Homography deve consumir a orientação " + "canônica antes da detecção/cálculo. Se ativarmos " + "undistort, recalibrar Homography no novo espaço." ) print(f"[ACTIVE] {active}") print("=" * 82) diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_3_flatfield_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_3_flatfield_calibration_tool.py index 680844c1e..91440ae59 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/_3_flatfield_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/_3_flatfield_calibration_tool.py @@ -28,6 +28,8 @@ Princípios de produto: 12. Só promove para "active" se o acceptance test passar. 13. Preserva calibração ativa anterior em caso de falha. 14. Gera metadados, hashes, previews e sugestão de flatfield_config. +15. Orientação de câmera é SOMENTE visual neste calibrador; o Flat-Field + científico permanece no espaço nativo/decode do sensor. Dependências: ------------- @@ -161,6 +163,26 @@ SUPPORTED_MONO = { BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") +# Orientação SOMENTE de preview/diagnóstico. +# O Flat-Field científico permanece no espaço nativo/decode do sensor. +PREVIEW_ORIENTATION_BY_SENSOR = { + "OV9782": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + "AR0234": { + "rotate_deg": 180, + "flip_horizontal": False, + "flip_vertical": False, + }, + "OV9282": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, +} + # Raw10 RAW10_MAX = 1023.0 RAW10_WHITE_SAT = 1018 @@ -402,6 +424,142 @@ def resize_panel(img: np.ndarray, width: int, height: int) -> np.ndarray: return cv2.resize(img, (int(width), int(height)), interpolation=cv2.INTER_AREA) +def normalize_preview_orientation(cfg: Optional[dict]) -> dict: + """Normaliza orientação usada EXCLUSIVAMENTE em UI/previews.""" + cfg = cfg or {} + + try: + rotate_deg = int(cfg.get("rotate_deg", 0)) % 360 + except Exception as exc: + raise RuntimeError( + f"preview rotate_deg inválido: {cfg.get('rotate_deg')!r}" + ) from exc + + if rotate_deg not in (0, 90, 180, 270): + raise RuntimeError( + f"preview rotate_deg inválido: {rotate_deg}. " + "Permitidos=0,90,180,270." + ) + + flip_horizontal = cfg.get("flip_horizontal", False) + flip_vertical = cfg.get("flip_vertical", False) + + if not isinstance(flip_horizontal, bool): + raise RuntimeError("preview flip_horizontal deve ser bool.") + if not isinstance(flip_vertical, bool): + raise RuntimeError("preview flip_vertical deve ser bool.") + + return { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_horizontal, + "flip_vertical": flip_vertical, + "source": str(cfg.get("source") or "unknown"), + } + + +def apply_preview_orientation( + img: np.ndarray, + cfg: Optional[dict], +) -> np.ndarray: + """ + Aplica orientação SOMENTE para apresentação/diagnóstico. + + Nunca use a saída para build_gain_maps(), acceptance_test(), + save_candidate_npz() ou qualquer etapa científica do Flat-Field. + """ + if img is None: + return img + + cfg = normalize_preview_orientation(cfg) + rotate_deg = cfg["rotate_deg"] + + if rotate_deg == 90: + 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 resolve_preview_orientation( + specs: Dict[str, "CameraSpec"], + args, + module_params: Optional[dict] = None, +) -> Dict[str, dict]: + """ + Precedência: + 1. defaults por sensor; + 2. camera_orientation do module_params, se enabled=true; + 3. overrides explícitos de CLI. + + O resultado afeta SOMENTE UI/previews. + """ + module_params = module_params or {} + mp_orientation = module_params.get("camera_orientation", {}) + mp_orientation_enabled = bool( + isinstance(mp_orientation, dict) + and mp_orientation.get("enabled", False) + ) + mp_by_role = ( + mp_orientation.get("by_role", {}) + if mp_orientation_enabled + else {} + ) + + result = {} + + for role in ROLES: + sensor = str(specs[role].sensor_name or "").upper() + + base = dict( + PREVIEW_ORIENTATION_BY_SENSOR.get( + sensor, + { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + ) + ) + base["source"] = "sensor_default" + + mp_role = mp_by_role.get(role) if isinstance(mp_by_role, dict) else None + if isinstance(mp_role, dict): + for key in ("rotate_deg", "flip_horizontal", "flip_vertical"): + if key in mp_role: + base[key] = mp_role[key] + base["source"] = "module_params" + + rotate_override = getattr(args, f"{role}_preview_rotate_deg", None) + cli_override = False + + if rotate_override is not None: + base["rotate_deg"] = int(rotate_override) + cli_override = True + + if bool(getattr(args, f"{role}_preview_flip_horizontal", False)): + base["flip_horizontal"] = True + cli_override = True + + if bool(getattr(args, f"{role}_preview_flip_vertical", False)): + base["flip_vertical"] = True + cli_override = True + + if cli_override: + base["source"] = "cli_override" + + result[role] = normalize_preview_orientation(base) + + return result + + def status_rank(status: str) -> int: return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 2) @@ -1212,6 +1370,7 @@ class AcquisitionContext: panel_width: int, panel_height: int, rgb_bayer: str, + preview_orientation: Dict[str, dict], ): self.device = device self.specs = specs @@ -1219,6 +1378,12 @@ class AcquisitionContext: self.panel_width = int(panel_width) self.panel_height = int(panel_height) self.rgb_bayer = rgb_bayer + self.preview_orientation = { + role: normalize_preview_orientation( + (preview_orientation or {}).get(role, {}) + ) + for role in ROLES + } self.raw_queues = { role: device.getOutputQueue( @@ -1449,6 +1614,12 @@ class AcquisitionContext: u8 = raw10_display(raw) view = cv2.cvtColor(u8, cv2.COLOR_GRAY2BGR) + # SOMENTE DISPLAY. latest_raw permanece sensor-native. + view = apply_preview_orientation( + view, + self.preview_orientation[role], + ) + panel = resize_panel(view, pw, ph) st = raw_stats(raw) @@ -1458,7 +1629,12 @@ class AcquisitionContext: panel, [ f"{role.upper()} | {spec.socket_name} | {spec.sensor_name}", - f"RAW10 {spec.width}x{spec.height}", + f"RAW10 native {spec.width}x{spec.height}", + ( + f"DISPLAY ROT={self.preview_orientation[role]['rotate_deg']} " + f"FH={int(self.preview_orientation[role]['flip_horizontal'])} " + f"FV={int(self.preview_orientation[role]['flip_vertical'])}" + ), f"p50={st['p50']:.3f} p99={st['p99']:.3f}", f"sat={st['sat_pct']:.3f}% dark={st['dark_pct']:.3f}%", f"EXP={ctrl.get('exposure_time_us')}us ISO={ctrl.get('sensitivity_iso')}", @@ -1855,6 +2031,25 @@ def disable_nonflat_processing(core: RawProcessorCore): except Exception: pass + # Belt-and-suspenders: Flat científico deve nascer ANTES da orientação. + try: + orientation = getattr(core, "camera_orientation_config", None) + if isinstance(orientation, dict): + orientation["enabled"] = False + orientation["input_space"] = "native_stream_no_external_undistort" + orientation["output_space"] = "native_stream_no_external_undistort" + + by_role = orientation.get("by_role") + if isinstance(by_role, dict): + for role in ROLES: + item = by_role.get(role) + if isinstance(item, dict): + item["rotate_deg"] = 0 + item["flip_horizontal"] = False + item["flip_vertical"] = False + except Exception: + pass + def create_processing_core( specs: Dict[str, CameraSpec], @@ -1958,6 +2153,12 @@ def decode_reference( specs: Dict[str, CameraSpec], raw_means: Dict[str, np.ndarray], ) -> dict: + """ + Decode científico do Flat-Field no espaço NATIVO/DECODE. + + Não aplica camera_orientation, Homography, crop ou resize de fusão. + Isso é intencional: no runtime o Flat nativo vem antes do ROTATE/FLIP. + """ frame = {} for role in ROLES: @@ -2307,8 +2508,29 @@ def save_previews( white_signal_a: dict, white_b: dict, dark: Optional[dict], + preview_orientation: Optional[Dict[str, dict]] = None, ): + """ + Salva previews em dois domínios: + + nomes legados + *_native.png + espaço científico real do Flat-Field; + + *_display.png + cópia rotacionada/flipada SOMENTE para inspeção humana. + + O NPZ e o acceptance usam exclusivamente os arrays nativos. + """ ensure_dir(out_dir) + preview_orientation = preview_orientation or {} + + channel_to_role = { + "R": "rgb", + "G": "rgb", + "B": "rgb", + "RE": "re", + "NIR": "nir", + } for ch in CHANNELS: gain = gain_maps[ch] @@ -2318,26 +2540,60 @@ def save_previews( validation_signal = np.maximum(wb - d, 1e-8) corrected = validation_signal * gain - cv2.imwrite(str(out_dir / f"{ch}_gain.png"), gray_bgr(gain)) - cv2.imwrite(str(out_dir / f"{ch}_white_A_signal.png"), gray_bgr(white_signal_a[ch])) - cv2.imwrite(str(out_dir / f"{ch}_white_B_before.png"), gray_bgr(validation_signal)) - cv2.imwrite(str(out_dir / f"{ch}_white_B_after.png"), gray_bgr(corrected)) + gain_native = gray_bgr(gain) + white_a_native = gray_bgr(white_signal_a[ch]) + before_native = gray_bgr(validation_signal) + after_native = gray_bgr(corrected) - # Comparativo lado a lado. - before = gray_bgr(validation_signal) - after = gray_bgr(corrected) + # Nomes históricos continuam apontando para o espaço científico nativo. + cv2.imwrite(str(out_dir / f"{ch}_gain.png"), gain_native) + cv2.imwrite(str(out_dir / f"{ch}_white_A_signal.png"), white_a_native) + cv2.imwrite(str(out_dir / f"{ch}_white_B_before.png"), before_native) + cv2.imwrite(str(out_dir / f"{ch}_white_B_after.png"), after_native) - h = min(before.shape[0], after.shape[0]) - w = min(before.shape[1], after.shape[1]) + cv2.imwrite(str(out_dir / f"{ch}_gain_native.png"), gain_native) + cv2.imwrite(str(out_dir / f"{ch}_white_A_signal_native.png"), white_a_native) + cv2.imwrite(str(out_dir / f"{ch}_white_B_before_native.png"), before_native) + cv2.imwrite(str(out_dir / f"{ch}_white_B_after_native.png"), after_native) - before = cv2.resize(before, (w, h)) - after = cv2.resize(after, (w, h)) + before_cmp = before_native.copy() + after_cmp = after_native.copy() + overlay_hud(before_cmp, [f"{ch} BEFORE | NATIVE"], x=12, y=26, font_scale=0.65, line_step=24) + overlay_hud(after_cmp, [f"{ch} AFTER | NATIVE"], x=12, y=26, font_scale=0.65, line_step=24) + native_side = np.hstack([before_cmp, after_cmp]) - overlay_hud(before, [f"{ch} BEFORE"], x=12, y=26, font_scale=0.65, line_step=24) - overlay_hud(after, [f"{ch} AFTER"], x=12, y=26, font_scale=0.65, line_step=24) + cv2.imwrite(str(out_dir / f"{ch}_validation_compare.png"), native_side) + cv2.imwrite(str(out_dir / f"{ch}_validation_compare_native.png"), native_side) - side = np.hstack([before, after]) - cv2.imwrite(str(out_dir / f"{ch}_validation_compare.png"), side) + # DISPLAY CANÔNICO, sem efeito científico. + role = channel_to_role[ch] + orient_cfg = preview_orientation.get(role, {}) + orient = normalize_preview_orientation(orient_cfg) + + gain_display = apply_preview_orientation(gain_native.copy(), orient_cfg) + white_a_display = apply_preview_orientation(white_a_native.copy(), orient_cfg) + before_display = apply_preview_orientation(before_native.copy(), orient_cfg) + after_display = apply_preview_orientation(after_native.copy(), orient_cfg) + + cv2.imwrite(str(out_dir / f"{ch}_gain_display.png"), gain_display) + cv2.imwrite(str(out_dir / f"{ch}_white_A_signal_display.png"), white_a_display) + cv2.imwrite(str(out_dir / f"{ch}_white_B_before_display.png"), before_display) + cv2.imwrite(str(out_dir / f"{ch}_white_B_after_display.png"), after_display) + + before_disp_cmp = before_display.copy() + after_disp_cmp = after_display.copy() + overlay_hud( + before_disp_cmp, + [f"{ch} BEFORE | DISPLAY", f"ROT={orient['rotate_deg']}"], + x=12, y=26, font_scale=0.55, line_step=22, + ) + overlay_hud( + after_disp_cmp, + [f"{ch} AFTER | DISPLAY", f"ROT={orient['rotate_deg']}"], + x=12, y=26, font_scale=0.55, line_step=22, + ) + display_side = np.hstack([before_disp_cmp, after_disp_cmp]) + cv2.imwrite(str(out_dir / f"{ch}_validation_compare_display.png"), display_side) # ============================================================ @@ -2486,6 +2742,29 @@ def main(): parser.add_argument(f"--{role}-exp-us", type=int, default=None) parser.add_argument(f"--{role}-iso", type=int, default=None) + # Orientação SOMENTE de preview/diagnóstico. + for role in ROLES: + parser.add_argument( + f"--{role}-preview-rotate-deg", + type=int, + default=None, + choices=[0, 90, 180, 270], + help=( + "Rotação SOMENTE da UI/previews. " + "Flat científico permanece no espaço nativo." + ), + ) + parser.add_argument( + f"--{role}-preview-flip-horizontal", + action="store_true", + help="Flip horizontal SOMENTE da UI/previews.", + ) + parser.add_argument( + f"--{role}-preview-flip-vertical", + action="store_true", + help="Flip vertical SOMENTE da UI/previews.", + ) + # Processamento parser.add_argument( "--sigma-frac", @@ -2578,6 +2857,12 @@ def main(): rgb_spec = specs["rgb"] + preview_orientation = resolve_preview_orientation( + specs, + args, + module_params=module_params, + ) + print("=" * 82) print("FLAT-FIELD CALIBRATION - PRODUCTION") print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}") @@ -2593,6 +2878,18 @@ def main(): f"{s.width}x{s.height} | {s.resolution_enum}{extra}" ) + print("-" * 82) + print("[GEOMETRY] Flat measurement = SENSOR/DECODE NATIVE") + for role in ROLES: + o = preview_orientation[role] + print( + f"[PREVIEW] {role.upper():3s} | " + f"rotate={o['rotate_deg']:3d} | " + f"flip_h={o['flip_horizontal']} | " + f"flip_v={o['flip_vertical']} | " + f"source={o['source']} | scientific_effect=NONE" + ) + print("=" * 82) # -------------------------------------------------------- @@ -2628,6 +2925,24 @@ def main(): role: asdict(spec) for role, spec in specs.items() }, + "geometry_contract": { + "flat_measurement_space": "native_camera_space", + "decode_space": "sensor_native_decode", + "measurement_orientation": "sensor_native", + "preview_orientation_only": True, + "preview_orientation_by_role": { + role: { + "sensor": specs[role].sensor_name, + **preview_orientation[role], + } + for role in ROLES + }, + "runtime_order": ( + "decode -> radiometric_normalization -> flat_native -> " + "camera_orientation -> homography" + ), + "scientific_orientation_effect": "none", + }, "module_params_path": args.module_params, "rgb_processing": None, "capture": { @@ -2667,6 +2982,7 @@ def main(): panel_width=args.panel_width, panel_height=args.panel_height, rgb_bayer=rgb_spec.bayer_pattern, + preview_orientation=preview_orientation, ) # ---------------------------------------------------- @@ -2681,6 +2997,7 @@ def main(): "enhancement_forced_off": True, "flatfield_forced_off": True, "radiometric_normalization_forced_off": True, + "camera_orientation_forced_off_for_measurement": True, } print( @@ -2922,6 +3239,7 @@ def main(): white_signal_a=white_signal_a, white_b=white_b, dark=dark, + preview_orientation=preview_orientation, ) report["candidate_sha256"] = sha256_file(candidate_npz) @@ -3015,6 +3333,11 @@ def main(): f"RGB: {rgb_spec.sensor_name} {rgb_spec.width}x{rgb_spec.height}", f"Bayer: {rgb_spec.bayer_pattern}", f"RGB processing: {report['rgb_processing']['mode']}", + "Flat scientific space: NATIVE", + ( + f"RGB display rotate: " + f"{preview_orientation['rgb']['rotate_deg']} deg" + ), "", ] diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_config_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_calibration_tool.py similarity index 89% rename from Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_config_tool.py rename to Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_calibration_tool.py index 380fe5d46..98a396e81 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_config_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_calibration_tool.py @@ -67,11 +67,17 @@ Domínio de medição A medição é feita no RAW nativo da câmera, antes de: - demosaic; - enhancement; + - camera orientation; - homografia; - flat gain; - resize; - patch normalization. +A orientação de câmera deste calibrador é SOMENTE VISUAL: + - pode rotacionar/flipar a UI e imagens de evidência; + - NÃO altera ROI, sweep, regressão, p50, reference_controls ou acceptance; + - o artefato radiométrico continua pertencendo ao espaço RAW nativo. + Isso casa com o propósito do bloco runtime: apply_stage = after_dark_before_flat_gain @@ -210,6 +216,26 @@ MONO_SENSOR_MODES = { }, } +# Orientação EXCLUSIVAMENTE de preview/evidência. +# O domínio científico permanece native_sensor_raw_linear. +PREVIEW_ORIENTATION_BY_SENSOR = { + "OV9782": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + "AR0234": { + "rotate_deg": 180, + "flip_horizontal": False, + "flip_vertical": False, + }, + "OV9282": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, +} + SCHEMA = "multispec_radiometric_calibration_v5" CALIBRATION_DOMAIN = "native_sensor_raw_linear" NORMALIZATION_METHOD = "oak_ae_frame_controls_v1" @@ -518,6 +544,201 @@ def resize_panel( ) +def normalize_preview_orientation(cfg: Optional[dict]) -> dict: + """Normaliza orientação usada SOMENTE para UI/evidência visual.""" + cfg = cfg or {} + + try: + rotate_deg = int(cfg.get("rotate_deg", 0)) % 360 + except Exception as exc: + raise RuntimeError( + f"preview rotate_deg inválido: {cfg.get('rotate_deg')!r}" + ) from exc + + if rotate_deg not in (0, 90, 180, 270): + raise RuntimeError( + f"preview rotate_deg inválido: {rotate_deg}. " + "Permitidos=0,90,180,270." + ) + + flip_horizontal = cfg.get("flip_horizontal", False) + flip_vertical = cfg.get("flip_vertical", False) + + if not isinstance(flip_horizontal, bool): + raise RuntimeError("preview flip_horizontal deve ser bool.") + + if not isinstance(flip_vertical, bool): + raise RuntimeError("preview flip_vertical deve ser bool.") + + return { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_horizontal, + "flip_vertical": flip_vertical, + } + + +def apply_preview_orientation( + img: np.ndarray, + cfg: Optional[dict], +) -> np.ndarray: + """ + Aplica rotação/flip SOMENTE à cópia usada para visualização. + + Nunca usar a saída desta função em: + - image_stats() + - preflight_report() + - capture_level() + - analyze_role_sweep() + - evaluate_reference_validation() + - build_normalization_fragment() + """ + if img is None: + return img + + cfg = normalize_preview_orientation(cfg) + rotate_deg = cfg["rotate_deg"] + + if rotate_deg == 90: + 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 load_optional_json(path: Optional[str]) -> dict: + if not path: + return {} + + p = Path(path) + if not p.is_file(): + return {} + + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + + return data if isinstance(data, dict) else {} + + +def resolve_preview_orientation( + specs, + args, + module_params: Optional[dict] = None, +): + """ + Precedência: + 1. default por sensor; + 2. camera_orientation.by_role do module_params, se fornecido; + 3. override explícito por CLI. + + Tudo continua sendo SOMENTE preview. + """ + module_params = module_params or {} + + mp_orientation = module_params.get( + "camera_orientation", + {}, + ) + if not isinstance(mp_orientation, dict): + mp_orientation = {} + + mp_by_role = mp_orientation.get( + "by_role", + {}, + ) + if not isinstance(mp_by_role, dict): + mp_by_role = {} + + result = {} + + for role in ROLES: + sensor = str( + specs[role].sensor_name + or "" + ).upper() + + cfg = dict( + PREVIEW_ORIENTATION_BY_SENSOR.get( + sensor, + { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + ) + ) + + source = "sensor_default" + + mp_role = mp_by_role.get(role) + if isinstance(mp_role, dict): + for key in ( + "rotate_deg", + "flip_horizontal", + "flip_vertical", + ): + if key in mp_role: + cfg[key] = mp_role[key] + source = "module_params" + + rotate_override = getattr( + args, + f"{role}_preview_rotate_deg", + None, + ) + if rotate_override is not None: + cfg["rotate_deg"] = int( + rotate_override + ) + source = "cli_override" + + if bool( + getattr( + args, + f"{role}_preview_flip_horizontal", + False, + ) + ): + cfg["flip_horizontal"] = True + source = "cli_override" + + if bool( + getattr( + args, + f"{role}_preview_flip_vertical", + False, + ) + ): + cfg["flip_vertical"] = True + source = "cli_override" + + cfg = normalize_preview_orientation( + cfg + ) + cfg["source"] = source + + result[role] = cfg + + return result + + def robust_median(arr: np.ndarray) -> float: f = np.asarray( arr, @@ -1542,11 +1763,25 @@ class RadiometricRuntime: specs, args, qa, + preview_orientation, ): self.device = device self.specs = specs self.args = args self.qa = qa + self.preview_orientation = { + role: { + **normalize_preview_orientation( + (preview_orientation or {}).get(role, {}) + ), + "source": ( + (preview_orientation or {}) + .get(role, {}) + .get("source", "unknown") + ), + } + for role in ROLES + } self.out_q = { role: ( @@ -2538,11 +2773,18 @@ def show_progress_board( dtype=np.uint8, ) else: + # A ROI é calculada/desenhada no frame NATIVO. + # Depois rotacionamos imagem + retângulo juntos só para display. view = gray_preview( img, args.roi_frac, ) + view = apply_preview_orientation( + view, + runtime.preview_orientation[role], + ) + panel = resize_panel( view, pw, @@ -2567,9 +2809,15 @@ def show_progress_board( [ f"{role.upper()} | " f"{runtime.specs[role].sensor_name}", - f"RAW " + f"RAW NATIVE " f"{runtime.specs[role].width}x" f"{runtime.specs[role].height}", + ( + f"DISPLAY ROT=" + f"{runtime.preview_orientation[role]['rotate_deg']} " + f"FH={int(runtime.preview_orientation[role]['flip_horizontal'])} " + f"FV={int(runtime.preview_orientation[role]['flip_vertical'])}" + ), f"p50={st.get('p50', 0):.3f} " f"p95={st.get('p95', 0):.3f}", f"sat={st.get('sat_pct', 0):.3f}%", @@ -3799,6 +4047,13 @@ def build_normalization_fragment( role_analyses, args, ): + """ + Gera SOMENTE radiometric_normalization. + + camera_orientation não pertence a este fragmento: + a calibração radiométrica mede resposta no RAW nativo e o runtime + aplica a orientação posteriormente, depois de radiometric + flat. + """ reference_controls = {} scale_limits = {} @@ -3951,6 +4206,38 @@ def main(): default=20.0, ) + parser.add_argument( + "--module-params", + default="calibration/module_params.json", + help=( + "Opcional. Lê SOMENTE camera_orientation para alinhar a UI/evidências " + "com o módulo. Não altera a medição radiométrica." + ), + ) + + # Orientação SOMENTE de preview/evidência. + for role in ROLES: + parser.add_argument( + f"--{role}-preview-rotate-deg", + type=int, + default=None, + choices=[0, 90, 180, 270], + help=( + "Rotação SOMENTE da UI/evidências. " + "Sweep/regressão permanecem no RAW nativo." + ), + ) + parser.add_argument( + f"--{role}-preview-flip-horizontal", + action="store_true", + help="Flip horizontal SOMENTE da UI/evidências.", + ) + parser.add_argument( + f"--{role}-preview-flip-vertical", + action="store_true", + help="Flip vertical SOMENTE da UI/evidências.", + ) + # Bancada parser.add_argument( "--rig-id", @@ -4261,6 +4548,16 @@ def main(): camera_rows ) + module_params = load_optional_json( + args.module_params + ) + + preview_orientation = resolve_preview_orientation( + specs, + args, + module_params=module_params, + ) + print("=" * 88) print( "RADIOMETRIC CALIBRATION - PRODUCTION" @@ -4285,6 +4582,23 @@ def main(): f"{s.width}x{s.height}" ) + print("-" * 88) + print( + "[GEOMETRY] Radiometric measurement = " + f"{CALIBRATION_DOMAIN}" + ) + + for role in ROLES: + o = preview_orientation[role] + print( + f"[PREVIEW] {role.upper():3s} | " + f"rotate={o['rotate_deg']:3d} | " + f"flip_h={o['flip_horizontal']} | " + f"flip_v={o['flip_vertical']} | " + f"source={o.get('source')} | " + "scientific_effect=NONE" + ) + print("=" * 88) pipeline = build_pipeline( @@ -4341,6 +4655,25 @@ def main(): "calibration_domain": ( CALIBRATION_DOMAIN ), + "geometry_contract": { + "radiometric_measurement_space": CALIBRATION_DOMAIN, + "measurement_orientation": "sensor_native", + "preview_orientation_only": True, + "preview_orientation_by_role": { + role: { + "sensor": specs[role].sensor_name, + **preview_orientation[role], + } + for role in ROLES + }, + "module_params_path": args.module_params, + "module_params_orientation_used_for_preview_only": True, + "scientific_orientation_effect": "none", + "runtime_order": ( + "decode -> radiometric_normalization -> flat_native -> " + "camera_orientation -> homography" + ), + }, "normalization_contract": { "method": ( NORMALIZATION_METHOD @@ -4460,6 +4793,7 @@ def main(): specs, args, qa, + preview_orientation, ) ) @@ -4973,11 +5307,13 @@ def main(): ] ) - # Salva frame de evidência no ponto de referência. + # Salva evidência NATIVA e uma cópia DISPLAY canônica. + # Os números de validação foram calculados exclusivamente + # sobre runtime.images[role] no RAW nativo. if runtime.images[ role ] is not None: - vis = gray_preview( + vis_native = gray_preview( runtime.images[ role ], @@ -4985,9 +5321,9 @@ def main(): ) overlay_hud( - vis, + vis_native, [ - f"{role.upper()} REFERENCE VALIDATION", + f"{role.upper()} REFERENCE VALIDATION | NATIVE", f"p50=" f"{vr['measured_p50']:.4f}", f"target=" @@ -5001,25 +5337,85 @@ def main(): ], x=12, y=25, - font_scale=0.55, - line_step=23, + font_scale=0.52, + line_step=22, ) cv2.imwrite( str( candidate_dir - / ( - f"validation_" - f"{role}.png" - ) + / f"validation_{role}_native.png" ), - vis, + vis_native, + ) + + vis_display = apply_preview_orientation( + vis_native.copy(), + preview_orientation[role], + ) + + orient = preview_orientation[role] + + overlay_hud( + vis_display, + [ + ( + f"DISPLAY ROT={orient['rotate_deg']} " + f"FH={int(orient['flip_horizontal'])} " + f"FV={int(orient['flip_vertical'])}" + ), + "SCIENTIFIC MEASUREMENT=NATIVE", + ], + x=12, + y=138, + font_scale=0.43, + line_step=19, + ) + + cv2.imwrite( + str( + candidate_dir + / f"validation_{role}_display.png" + ), + vis_display, + ) + + # Alias histórico permanece NATIVE. + cv2.imwrite( + str( + candidate_dir + / f"validation_{role}.png" + ), + vis_native, ) report[ "reference_validation" ] = validation_report + report[ + "visual_evidence" + ] = { + role: { + "native": str( + candidate_dir + / f"validation_{role}_native.png" + ), + "display": str( + candidate_dir + / f"validation_{role}_display.png" + ), + "legacy_alias_native": str( + candidate_dir + / f"validation_{role}.png" + ), + "preview_orientation": dict( + preview_orientation[role] + ), + } + for role in ROLES + } + fragment = ( build_normalization_fragment( role_analyses, @@ -5162,6 +5558,11 @@ def main(): lines.extend([ "", + ( + "Scientific space: RAW NATIVE | " + f"RGB display rotate=" + f"{preview_orientation['rgb']['rotate_deg']} deg" + ), "Field radiometric controller: remains DISABLED", "Patch normalization: remains DISABLED", "", diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_5_camera_startup_profile.py b/Python/OAK/datasets/oak-fcc-3/utils/_5_camera_startup_profile.py index 1dbfbfa52..b94d92d5f 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/_5_camera_startup_profile.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/_5_camera_startup_profile.py @@ -25,6 +25,18 @@ Na arquitetura final de produto essas responsabilidades já foram separadas. Esta ferramenta preserva SOMENTE o que ainda é necessário ao runtime: camera_settings +Ela NÃO captura frames e NÃO transforma pixels. + +Portanto `camera_orientation` NÃO pertence ao `camera_settings`. +Nesta revisão a ferramenta apenas: + - reconhece/audita `module_params.camera_orientation`, quando um MP existe; + - registra o contrato geométrico no artefato; + - garante que orientação nunca seja misturada em camera_settings/rgb_calibration. + +A aplicação real de rotate/flip continua pertencendo ao RawProcessorCore, +na etapa: + radiometric_normalization -> flat_native -> camera_orientation -> homography + e força explicitamente: rgb_calibration.enabled = false gains = 1,1,1 @@ -124,6 +136,8 @@ import depthai as dai SCHEMA = "multispec_camera_startup_profile_v3" +TOOL_VERSION = "production_orientation_aware_v1_2026_08_31" +CAMERA_ORIENTATION_SCHEMA = "multispec_camera_orientation_v1" ISO_BASE = 100.0 ROLES = ("rgb", "re", "nir") @@ -534,9 +548,269 @@ def build_rgb_calibration_policy() -> dict: } +def _normalize_orientation_role( + role: str, + cfg: dict, +) -> dict: + if not isinstance(cfg, dict): + raise RuntimeError( + f"camera_orientation.by_role.{role} ausente/inválido." + ) + + raw_rotate = cfg.get("rotate_deg", 0) + + try: + rotate_float = float(raw_rotate) + except Exception as exc: + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg inválido: {raw_rotate!r}" + ) from exc + + if abs(rotate_float - round(rotate_float)) > 1e-9: + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg deve ser inteiro: " + f"{raw_rotate!r}" + ) + + rotate_deg = int(round(rotate_float)) % 360 + + if rotate_deg not in (0, 90, 180, 270): + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg inválido: {rotate_deg}. " + "Permitidos=0,90,180,270." + ) + + flip_horizontal = cfg.get("flip_horizontal", False) + flip_vertical = cfg.get("flip_vertical", False) + + if not isinstance(flip_horizontal, bool): + raise RuntimeError( + f"camera_orientation.{role}.flip_horizontal deve ser bool." + ) + + if not isinstance(flip_vertical, bool): + raise RuntimeError( + f"camera_orientation.{role}.flip_vertical deve ser bool." + ) + + return { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_horizontal, + "flip_vertical": flip_vertical, + } + + +def audit_camera_orientation_contract( + module_params: dict, + current_signature: dict, +) -> dict: + """ + Audita camera_orientation sem assumir autoridade sobre ela. + + Este tool NÃO gera nem aplica orientação. Ele apenas verifica um MP já + montado, quando disponível. + """ + if not isinstance(module_params, dict) or not module_params: + return { + "available": False, + "valid": None, + "reason": "module_params_not_available", + } + + orientation = module_params.get("camera_orientation") + + if not isinstance(orientation, dict): + return { + "available": False, + "valid": None, + "reason": "camera_orientation_not_present", + "module_params_schema": module_params.get("schema"), + } + + schema = str( + orientation.get("schema", "") + or "" + ).strip() + + enabled = bool( + orientation.get("enabled", False) + ) + + if schema != CAMERA_ORIENTATION_SCHEMA: + raise RuntimeError( + f"camera_orientation.schema inválido: {schema!r}. " + f"Esperado {CAMERA_ORIENTATION_SCHEMA!r}." + ) + + input_space = str( + orientation.get("input_space", "") + or "" + ).strip() + + output_space = str( + orientation.get("output_space", "") + or "" + ).strip() + + apply_stage = str( + orientation.get("apply_stage", "") + or "" + ).strip().lower() + + if not input_space: + raise RuntimeError( + "camera_orientation.input_space ausente/vazio." + ) + + if not output_space: + raise RuntimeError( + "camera_orientation.output_space ausente/vazio." + ) + + if apply_stage != "after_native_flat_before_fusion": + raise RuntimeError( + "camera_orientation.apply_stage inválido: " + f"{apply_stage!r}. Esperado " + "'after_native_flat_before_fusion'." + ) + + by_role = orientation.get("by_role", {}) or {} + + if not isinstance(by_role, dict): + raise RuntimeError( + "camera_orientation.by_role inválido." + ) + + normalized = {} + has_transform = False + + for role in ROLES: + normalized[role] = _normalize_orientation_role( + role, + by_role.get(role), + ) + + cfg = normalized[role] + + if ( + cfg["rotate_deg"] != 0 + or cfg["flip_horizontal"] + or cfg["flip_vertical"] + ): + has_transform = True + + if not enabled: + if has_transform: + raise RuntimeError( + "camera_orientation.enabled=false, mas existe rotate/flip " + "diferente de identidade." + ) + + if input_space != output_space: + raise RuntimeError( + "camera_orientation.enabled=false exige " + "input_space == output_space." + ) + + # Se camera_hardware está presente no MP, garante que o contrato de + # orientação pertence ao mesmo módulo/hardware conectado. + mp_hw = module_params.get("camera_hardware") + + if isinstance(mp_hw, dict): + mp_sig = {} + + for role in ROLES: + item = mp_hw.get(role) + + if not isinstance(item, dict): + raise RuntimeError( + f"camera_hardware.{role} ausente no module_params." + ) + + size = item.get("size") + + if not ( + isinstance(size, (list, tuple)) + and len(size) == 2 + ): + raise RuntimeError( + f"camera_hardware.{role}.size inválido: {size}" + ) + + mp_sig[role] = { + "socket": str(item.get("socket") or ""), + "sensor": str(item.get("sensor") or "").upper(), + "size": [ + int(size[0]), + int(size[1]), + ], + } + + if mp_sig != current_signature: + raise RuntimeError( + "camera_orientation pertence a um module_params cujo " + "camera_hardware não corresponde ao hardware conectado.\\n" + f"Atual: {current_signature}\\n" + f"MP : {mp_sig}" + ) + + return { + "available": True, + "valid": True, + "enabled": enabled, + "schema": schema, + "input_space": input_space, + "output_space": output_space, + "apply_stage": apply_stage, + "by_role": normalized, + "scientific_effect_in_this_tool": "none", + "runtime_owner": "RawProcessorCore", + } + + +def extract_radiometry_geometry_audit( + radiometry: dict, +) -> dict: + """ + Lê apenas metadados geométricos do artefato radiométrico. + + Eles são rastreabilidade. Não entram no module_params_fragment desta etapa. + """ + geometry = ( + radiometry.get("geometry_contract", {}) + if isinstance(radiometry, dict) + else {} + ) + + if not isinstance(geometry, dict) or not geometry: + return { + "available": False, + } + + return { + "available": True, + "radiometric_measurement_space": geometry.get( + "radiometric_measurement_space" + ), + "measurement_orientation": geometry.get( + "measurement_orientation" + ), + "preview_orientation_only": geometry.get( + "preview_orientation_only" + ), + "preview_orientation_by_role": geometry.get( + "preview_orientation_by_role" + ), + "scientific_orientation_effect": geometry.get( + "scientific_orientation_effect" + ), + } + + def compare_existing_module_params( module_params_path: Optional[str], generated_fragment: dict, + current_signature: dict, ) -> dict: if not module_params_path: return { @@ -560,6 +834,11 @@ def compare_existing_module_params( generated_camera = generated_fragment["camera_settings"] generated_rgb = generated_fragment["rgb_calibration"] + orientation_audit = audit_camera_orientation_contract( + current, + current_signature, + ) + return { "available": True, "path": str(p), @@ -570,6 +849,7 @@ def compare_existing_module_params( "generated_camera_settings": generated_camera, "current_rgb_calibration": current_rgb, "generated_rgb_calibration": generated_rgb, + "camera_orientation_audit": orientation_audit, } @@ -646,6 +926,10 @@ def main(): camera_settings = build_camera_settings(reference_controls) rgb_calibration = build_rgb_calibration_policy() + radiometry_geometry_audit = extract_radiometry_geometry_audit( + radiometry + ) + module_params_fragment = { "camera_settings": camera_settings, "rgb_calibration": { @@ -661,6 +945,7 @@ def main(): audit = compare_existing_module_params( args.compare_module_params, module_params_fragment, + current_signature, ) payload = { @@ -671,6 +956,27 @@ def main(): "runtime_effect": ( "startup_camera_policy_and_explicit_rgb_gain_disable" ), + "tool_version": TOOL_VERSION, + "geometry_contract": { + "applies_pixel_transform": False, + "camera_orientation_generated_here": False, + "camera_orientation_stored_in_camera_settings": False, + "camera_orientation_runtime_owner": "RawProcessorCore", + "runtime_order": ( + "decode -> radiometric_normalization -> flat_native -> " + "camera_orientation -> homography" + ), + "source_radiometry_geometry": radiometry_geometry_audit, + "existing_module_params_orientation_audit": ( + audit.get("camera_orientation_audit") + if isinstance(audit, dict) + else None + ), + "note": ( + "This startup tool never rotates/flips frames. " + "camera_orientation is a separate geometric contract." + ), + }, "device_mx_id": actual_mx, "usb_speed": usb_speed, "hardware_inventory": rows, @@ -694,6 +1000,10 @@ def main(): "With AE enabled, manager stores exposure/gain as fallback " "and does not force them manually at startup." ), + "camera_orientation_policy": ( + "not_part_of_camera_settings; audited_only; " + "applied_by_raw_processor_core" + ), }, "rgb_calibration_policy": rgb_calibration, "module_params_fragment": module_params_fragment, @@ -724,18 +1034,46 @@ def main(): print("-" * 82) print("rgb_calibration: DISABLED | gains = 1.0 / 1.0 / 1.0") + print( + "camera_orientation: NOT APPLIED HERE | " + "owner=RawProcessorCore" + ) if audit.get("available"): + orientation_audit = ( + audit.get("camera_orientation_audit") + or {} + ) + print( "module_params audit: " f"camera_settings_equal={audit['camera_settings_equal']} | " - f"rgb_calibration_equal={audit['rgb_calibration_equal']}" + f"rgb_calibration_equal={audit['rgb_calibration_equal']} | " + f"orientation_valid={orientation_audit.get('valid')}" ) + + if orientation_audit.get("available"): + for role in ROLES: + o = ( + orientation_audit.get("by_role", {}) + .get(role, {}) + ) + + print( + f" orientation {role.upper():3s}: " + f"rotate={o.get('rotate_deg')} " + f"flip_h={o.get('flip_horizontal')} " + f"flip_v={o.get('flip_vertical')} " + "(audit only)" + ) else: print("module_params audit: arquivo não disponível") if args.check_only: - print("[CHECK-ONLY] Nenhum arquivo alterado.") + print( + "[CHECK-ONLY] Nenhum arquivo alterado. " + "Nenhuma transformação geométrica foi aplicada." + ) print("=" * 82) return diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_6_homography_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_6_homography_calibration_tool.py index 064fde68f..2cdfe87e4 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/_6_homography_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/_6_homography_calibration_tool.py @@ -19,33 +19,44 @@ Topologia oficial Princípio geométrico -------------------- -A homografia é estimada DIRETAMENTE entre os espaços nativos: +A homografia é estimada DIRETAMENTE no espaço CANÔNICO PÓS-ORIENTAÇÃO: - H_RE_to_RGB : coordenadas RE 1280x800 -> coordenadas RGB nativas - H_NIR_to_RGB : coordenadas NIR 1280x800 -> coordenadas RGB nativas + sensor/stream nativo + -> camera_orientation (rotate/flip discreto, sem interpolação) + -> detecção ChArUco + -> H_RE_to_RGB / H_NIR_to_RGB -Portanto: - - OV9782: destino RGB = 1280x800 - - AR0234: destino RGB = 1920x1200 +No módulo final atual: + RGB AR0234 -> ROT180 + RE OV9282 -> identidade + NIR OV9282 -> identidade + +Logo a AR0234 é girada ANTES da detecção ChArUco e ANTES de calcular H. NÃO redimensionamos RE/NIR para o tamanho da RGB antes de detectar pontos. Isso seria geometricamente incorreto para uma calibração de produto. Domínio da calibração --------------------- -As imagens usadas aqui são: +As imagens de entrada são: RGB -> saída ISP full-resolution RE -> saída MONO full-resolution NIR -> saída MONO full-resolution Nenhum undistort/remap externo é aplicado nesta ferramenta. -O JSON registra explicitamente: - coordinate_space = "native_stream_no_external_undistort" +A única transformação geométrica pré-H permitida é camera_orientation, +com rotações discretas 0/90/180/270 e flips. Ela deve ser EXATAMENTE a +mesma configuração usada pelo RawProcessorCore em runtime. -Assim o assembler final do module_params sabe em qual espaço a matriz nasceu. -Se uma etapa posterior introduzir remap/undistort antes da homografia, o -assembler/runtime deve respeitar/compor os espaços corretamente. +O JSON registra explicitamente: + native_coordinate_space + homography_coordinate_space + camera_orientation + source/reference sizes já no espaço orientado + +Assim uma matriz nunca pode ser confundida com uma homografia antiga +calibrada antes da introdução de camera_orientation. Por que ChArUco? ---------------- @@ -200,7 +211,33 @@ MONO_SENSOR_MODES = { } SCHEMA = "multispec_homography_calibration_v4" -COORDINATE_SPACE = "native_stream_no_external_undistort" +TOOL_VERSION = "production_orientation_aware_v1_2026_08_31" + +CAMERA_ORIENTATION_SCHEMA = "multispec_camera_orientation_v1" +NATIVE_COORDINATE_SPACE = "native_stream_no_external_undistort" +DEFAULT_ORIENTED_COORDINATE_SPACE = ( + "canonical_oriented_stream_no_external_undistort" +) + +# Fallback mecânico do produto quando ainda não existe module_params montado. +# Se module_params.camera_orientation existir, ele tem precedência. +ORIENTATION_BY_SENSOR = { + "OV9782": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + "AR0234": { + "rotate_deg": 180, + "flip_horizontal": False, + "flip_vertical": False, + }, + "OV9282": { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, +} # ============================================================ @@ -536,6 +573,606 @@ def normalize_gray_u8(img) -> np.ndarray: return np.clip(out * 255.0, 0, 255).astype(np.uint8) +# ============================================================ +# Camera orientation / espaço geométrico +# ============================================================ + +def normalize_orientation_role( + role: str, + cfg: Optional[dict], +) -> dict: + cfg = cfg or {} + + raw_rotate = cfg.get("rotate_deg", 0) + + try: + rotate_float = float(raw_rotate) + except Exception as exc: + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg inválido: " + f"{raw_rotate!r}" + ) from exc + + if abs(rotate_float - round(rotate_float)) > 1e-9: + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg deve ser inteiro: " + f"{raw_rotate!r}" + ) + + rotate_deg = int(round(rotate_float)) % 360 + + if rotate_deg not in (0, 90, 180, 270): + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg inválido: " + f"{rotate_deg}. Permitidos=0,90,180,270." + ) + + flip_horizontal = cfg.get( + "flip_horizontal", + False, + ) + flip_vertical = cfg.get( + "flip_vertical", + False, + ) + + if not isinstance(flip_horizontal, bool): + raise RuntimeError( + f"camera_orientation.{role}.flip_horizontal deve ser bool." + ) + + if not isinstance(flip_vertical, bool): + raise RuntimeError( + f"camera_orientation.{role}.flip_vertical deve ser bool." + ) + + return { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_horizontal, + "flip_vertical": flip_vertical, + "source": str( + cfg.get("source") + or "unknown" + ), + } + + +def orientation_is_identity(cfg: dict) -> bool: + cfg = normalize_orientation_role( + "orientation", + cfg, + ) + + return ( + cfg["rotate_deg"] == 0 + and not cfg["flip_horizontal"] + and not cfg["flip_vertical"] + ) + + +def apply_camera_orientation( + img: np.ndarray, + cfg: dict, +) -> np.ndarray: + """ + Transformação CIENTÍFICA pré-Homography. + + Deve espelhar a ordem do RawProcessorCore: + rotate -> flip_horizontal -> flip_vertical + + Somente operações discretas, sem interpolação. + """ + if img is None: + return img + + cfg = normalize_orientation_role( + "runtime", + cfg, + ) + + rotate_deg = cfg["rotate_deg"] + + if rotate_deg == 90: + 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 oriented_size_wh( + width: int, + height: int, + cfg: dict, +) -> tuple[int, int]: + cfg = normalize_orientation_role( + "size", + cfg, + ) + + if cfg["rotate_deg"] in ( + 90, + 270, + ): + return int(height), int(width) + + return int(width), int(height) + + +def oriented_frames( + frames: dict, + orientation_contract: dict, +) -> dict: + by_role = ( + orientation_contract.get( + "by_role", + {}, + ) + or {} + ) + + out = {} + + for role in ROLES: + img = frames.get(role) + + if img is None: + out[role] = None + continue + + out[role] = apply_camera_orientation( + img, + by_role.get(role, {}), + ) + + return out + + +def orientation_signature( + orientation_contract: dict, +) -> dict: + by_role = ( + orientation_contract.get( + "by_role", + {}, + ) + or {} + ) + + return { + "schema": orientation_contract.get( + "schema" + ), + "enabled": bool( + orientation_contract.get( + "enabled", + False, + ) + ), + "input_space": orientation_contract.get( + "input_space" + ), + "output_space": orientation_contract.get( + "output_space" + ), + "apply_stage": orientation_contract.get( + "apply_stage" + ), + "by_role": { + role: { + "rotate_deg": int( + by_role[role]["rotate_deg"] + ), + "flip_horizontal": bool( + by_role[role]["flip_horizontal"] + ), + "flip_vertical": bool( + by_role[role]["flip_vertical"] + ), + "native_size": list( + by_role[role]["native_size"] + ), + "oriented_size": list( + by_role[role]["oriented_size"] + ), + } + for role in ROLES + }, + } + + +def resolve_orientation_contract( + specs, + args, + module_params: Optional[dict] = None, +): + """ + Resolve a orientação científica da calibração. + + Precedência: + 1. default mecânico por sensor; + 2. module_params.camera_orientation; + 3. CLI explícita. + + Se existir MP e a CLI divergir dele, bloqueia por padrão. + Use --allow-orientation-override somente durante engenharia e atualize + o module_params antes de usar a Homography gerada. + """ + module_params = module_params or {} + + mp_orientation = module_params.get( + "camera_orientation", + {}, + ) + + has_mp_orientation = isinstance( + mp_orientation, + dict, + ) and bool(mp_orientation) + + mp_enabled = False + mp_by_role = {} + input_space = NATIVE_COORDINATE_SPACE + output_space = None + apply_stage = ( + "after_native_flat_before_fusion" + ) + + if has_mp_orientation: + schema = str( + mp_orientation.get("schema", "") + or "" + ) + + if ( + schema + and schema + != CAMERA_ORIENTATION_SCHEMA + ): + raise RuntimeError( + f"camera_orientation.schema inválido: " + f"{schema!r}; esperado " + f"{CAMERA_ORIENTATION_SCHEMA!r}." + ) + + mp_enabled = bool( + mp_orientation.get( + "enabled", + False, + ) + ) + + input_space = str( + mp_orientation.get( + "input_space", + NATIVE_COORDINATE_SPACE, + ) + or NATIVE_COORDINATE_SPACE + ) + + output_space = str( + mp_orientation.get( + "output_space", + "", + ) + or "" + ) + + apply_stage = str( + mp_orientation.get( + "apply_stage", + apply_stage, + ) + or apply_stage + ).lower() + + if ( + apply_stage + != "after_native_flat_before_fusion" + ): + raise RuntimeError( + "camera_orientation.apply_stage incompatível: " + f"{apply_stage!r}" + ) + + mp_by_role = ( + mp_orientation.get( + "by_role", + {}, + ) + or {} + ) + + if not isinstance( + mp_by_role, + dict, + ): + raise RuntimeError( + "camera_orientation.by_role inválido." + ) + + # Se o MP tem hardware, impede calibrar orientação de outro módulo. + mp_hw = module_params.get( + "camera_hardware" + ) + + if isinstance(mp_hw, dict): + for role in ROLES: + item = mp_hw.get(role) + + if not isinstance(item, dict): + raise RuntimeError( + f"camera_hardware.{role} ausente." + ) + + expected = specs[role] + + size = item.get("size") + + if ( + str(item.get("socket") or "") + != expected.socket_name + or str(item.get("sensor") or "").upper() + != expected.sensor_name + or not isinstance(size, (list, tuple)) + or len(size) != 2 + or [ + int(size[0]), + int(size[1]), + ] + != [ + expected.width, + expected.height, + ] + ): + raise RuntimeError( + f"module_params.camera_hardware.{role} " + "não corresponde ao hardware conectado." + ) + + resolved = {} + cli_diverged_from_mp = False + cli_used = False + + for role in ROLES: + sensor = str( + specs[role].sensor_name + or "" + ).upper() + + base = dict( + ORIENTATION_BY_SENSOR.get( + sensor, + { + "rotate_deg": 0, + "flip_horizontal": False, + "flip_vertical": False, + }, + ) + ) + base["source"] = "sensor_default" + + mp_role = ( + mp_by_role.get(role) + if has_mp_orientation + else None + ) + + mp_norm = None + + if isinstance(mp_role, dict): + base.update({ + "rotate_deg": mp_role.get( + "rotate_deg", + base["rotate_deg"], + ), + "flip_horizontal": mp_role.get( + "flip_horizontal", + base["flip_horizontal"], + ), + "flip_vertical": mp_role.get( + "flip_vertical", + base["flip_vertical"], + ), + "source": "module_params", + }) + + mp_norm = normalize_orientation_role( + role, + base, + ) + + rotate_override = getattr( + args, + f"{role}_rotate_deg", + None, + ) + + role_cli = False + + if rotate_override is not None: + base["rotate_deg"] = int( + rotate_override + ) + role_cli = True + + if bool( + getattr( + args, + f"{role}_flip_horizontal", + False, + ) + ): + base["flip_horizontal"] = True + role_cli = True + + if bool( + getattr( + args, + f"{role}_flip_vertical", + False, + ) + ): + base["flip_vertical"] = True + role_cli = True + + if role_cli: + base["source"] = "cli_override" + cli_used = True + + norm = normalize_orientation_role( + role, + base, + ) + + if ( + role_cli + and mp_norm is not None + and any( + norm[k] != mp_norm[k] + for k in ( + "rotate_deg", + "flip_horizontal", + "flip_vertical", + ) + ) + ): + cli_diverged_from_mp = True + + ow, oh = oriented_size_wh( + specs[role].width, + specs[role].height, + norm, + ) + + norm["native_size"] = [ + int(specs[role].width), + int(specs[role].height), + ] + norm["oriented_size"] = [ + int(ow), + int(oh), + ] + + resolved[role] = norm + + if ( + cli_diverged_from_mp + and not args.allow_orientation_override + ): + raise RuntimeError( + "Override de orientação diverge do module_params atual. " + "Isso produziria Homography incompatível com o runtime. " + "Corrija o MP ou use --allow-orientation-override somente " + "para engenharia." + ) + + any_transform = any( + not ( + resolved[role]["rotate_deg"] == 0 + and not resolved[role]["flip_horizontal"] + and not resolved[role]["flip_vertical"] + ) + for role in ROLES + ) + + enabled = bool(any_transform) + + if has_mp_orientation and not cli_diverged_from_mp: + if mp_enabled != enabled: + raise RuntimeError( + "camera_orientation.enabled do module_params não corresponde " + "às transformações resolvidas." + ) + + if not output_space: + if enabled: + raise RuntimeError( + "camera_orientation.output_space ausente no module_params." + ) + output_space = input_space + + elif has_mp_orientation and cli_diverged_from_mp: + # Engenharia explícita: a geometria resolvida passa a ser a autoridade + # desta sessão e o relatório obriga atualização posterior do MP. + output_space = ( + DEFAULT_ORIENTED_COORDINATE_SPACE + if enabled + else input_space + ) + + else: + output_space = ( + DEFAULT_ORIENTED_COORDINATE_SPACE + if enabled + else input_space + ) + + if not enabled and output_space != input_space: + raise RuntimeError( + "Orientação identidade exige output_space == input_space." + ) + + if enabled and output_space == input_space: + raise RuntimeError( + "camera_orientation ativa exige output_space diferente do " + "input_space para não mascarar o domínio geométrico." + ) + + return { + "schema": CAMERA_ORIENTATION_SCHEMA, + "enabled": enabled, + "input_space": input_space, + "output_space": output_space, + "apply_stage": apply_stage, + "by_role": resolved, + "source_module_params": bool( + has_mp_orientation + ), + "module_params_path": str( + args.module_params + ) if args.module_params else None, + "cli_override_used": bool( + cli_used + ), + "cli_diverged_from_module_params": bool( + cli_diverged_from_mp + ), + "requires_module_params_update": bool( + cli_diverged_from_mp + or ( + enabled + and not has_mp_orientation + ) + ), + } + + # ============================================================ # Hardware # ============================================================ @@ -1608,7 +2245,8 @@ def detect_charuco_best( ): """ Tenta variantes de contraste e escolhe a que produz mais cantos. - As coordenadas continuam no frame nativo original. + As coordenadas permanecem no frame recebido. No fluxo de produto deste + calibrador, esse frame já está no espaço CANÔNICO pós-camera_orientation. """ variants = [ (False, False), @@ -2220,6 +2858,7 @@ def build_role_model( role, specs, qa, + orientation_contract, ): rejection = reject_incompatible_samples( samples, @@ -2290,14 +2929,20 @@ def build_role_model( set(pairs["charuco_ids"]) ) - dst_size = ( - specs["rgb"].width, - specs["rgb"].height, + dst_size = tuple( + orientation_contract[ + "by_role" + ]["rgb"][ + "oriented_size" + ] ) - src_size = ( - specs[role].width, - specs[role].height, + src_size = tuple( + orientation_contract[ + "by_role" + ][role][ + "oriented_size" + ] ) coverage = coverage_stats( @@ -2551,6 +3196,7 @@ def evaluate_profile( samples, specs, qa, + orientation_contract, ): models = { role: build_role_model( @@ -2558,6 +3204,7 @@ def evaluate_profile( role, specs, qa, + orientation_contract, ) for role in SPEC_ROLES } @@ -2619,13 +3266,19 @@ def detect_triplet( dictionary, detector_params, qa, + orientation_contract, allow_invert=True, ): + calibration_frames = oriented_frames( + triplet["frames"], + orientation_contract, + ) + detections = {} for role in ROLES: detections[role] = detect_charuco_best( - triplet["frames"][role], + calibration_frames[role], board, dictionary, detector_params, @@ -2686,7 +3339,7 @@ def detect_triplet( signature = pose_signature( rgb_points, - triplet["frames"]["rgb"].shape[:2], + calibration_frames["rgb"].shape[:2], ) return { @@ -2696,6 +3349,16 @@ def detect_triplet( "pairs": pairs, "pose_signature": asdict(signature), "sync_dt_ms": sync_dt_ms, + "coordinate_space": orientation_contract[ + "output_space" + ], + "frame_shapes": { + role: [ + int(calibration_frames[role].shape[1]), + int(calibration_frames[role].shape[0]), + ] + for role in ROLES + }, } @@ -2808,9 +3471,13 @@ def preflight_detection( dictionary, detector_params, qa, + orientation_contract, allow_invert, ): - frames = runtime.frames + frames = oriented_frames( + runtime.frames, + orientation_contract, + ) result = { "status": "good", @@ -3054,6 +3721,7 @@ def save_sample_preview( role.upper(), f"markers={detection['detections'][role]['markers']}", f"corners={detection['detections'][role]['corners']}", + f"space={detection.get('coordinate_space')}", ], x=8, y=18, @@ -3080,11 +3748,17 @@ def build_ui_board( profile_eval, args, qa, + orientation_contract, message="", ): pw = int(args.panel_width) ph = int(args.panel_height) + calibration_frames = oriented_frames( + runtime.frames, + orientation_contract, + ) + H_preview = provisional_h( samples, selected_role, @@ -3092,8 +3766,8 @@ def build_ui_board( ) overlay_native = build_overlay( - runtime.frames["rgb"], - runtime.frames[selected_role], + calibration_frames["rgb"], + calibration_frames[selected_role], selected_role, H_preview, alpha=args.overlay_alpha, @@ -3102,7 +3776,7 @@ def build_ui_board( panels = {} for role in ROLES: - img = runtime.frames[role] + img = calibration_frames[role] if img is None: panel = np.zeros( @@ -3230,9 +3904,12 @@ def build_ui_board( coverage = coverage_stats( rgb_pts_all, - ( - specs["rgb"].width, - specs["rgb"].height, + tuple( + orientation_contract[ + "by_role" + ]["rgb"][ + "oriented_size" + ] ), ) if rgb_pts_all else { "span_x": 0.0, @@ -3244,6 +3921,10 @@ def build_ui_board( "HOMOGRAPHY CALIBRATION - PRODUCTION", f"profile={args.profile_name} | depth={args.depth_cm} cm", f"selected overlay={selected_role.upper()}", + ( + f"space={orientation_contract['output_space']} | " + f"RGB rot={orientation_contract['by_role']['rgb']['rotate_deg']}" + ), "", f"samples={len(samples)} / min={qa['min_samples']} rec={qa['recommended_samples']}", f"coverage X={coverage['span_x']*100:.1f}%", @@ -3385,9 +4066,17 @@ def profile_to_module_fragment( ], "depth_unit": "cm", "homography_source": ( - "charuco_native_multi_sample_group_cv" + "charuco_oriented_multi_sample_group_cv" ), - "coordinate_space": COORDINATE_SPACE, + "native_coordinate_space": profile_payload[ + "native_coordinate_space" + ], + "coordinate_space": profile_payload[ + "coordinate_space" + ], + "camera_orientation": profile_payload[ + "camera_orientation" + ], "reference_size": profile_payload[ "coordinate_spaces" ]["rgb"]["size"], @@ -3480,6 +4169,41 @@ def create_or_update_active( specs ) + geometry_contract = ( + candidate_report.get( + "geometry_contract", + {}, + ) + or {} + ) + + homography_space = str( + geometry_contract.get( + "homography_coordinate_space", + "", + ) + or "" + ) + + orientation_sig = ( + geometry_contract.get( + "camera_orientation_signature" + ) + ) + + if not homography_space: + raise RuntimeError( + "Candidate sem homography_coordinate_space." + ) + + if not isinstance( + orientation_sig, + dict, + ): + raise RuntimeError( + "Candidate sem camera_orientation_signature." + ) + if active: if active.get("schema") != SCHEMA: raise RuntimeError( @@ -3501,10 +4225,22 @@ def create_or_update_active( if ( active.get("coordinate_space") - != COORDINATE_SPACE + != homography_space ): raise RuntimeError( - "Coordinate space do ativo é incompatível." + "Coordinate space do ativo é incompatível. " + "Não misture perfis native/oriented." + ) + + if ( + active.get( + "camera_orientation_signature" + ) + != orientation_sig + ): + raise RuntimeError( + "Homography ativa foi calibrada com outra " + "camera_orientation. Arquive/recalibre os perfis." ) else: @@ -3512,7 +4248,9 @@ def create_or_update_active( "schema": SCHEMA, "created_at": now_str(), "hardware_signature": signature, - "coordinate_space": COORDINATE_SPACE, + "coordinate_space": homography_space, + "native_coordinate_space": NATIVE_COORDINATE_SPACE, + "camera_orientation_signature": orientation_sig, "reference_camera": "rgb", "baseline_mm": float( args.baseline_mm @@ -3525,6 +4263,15 @@ def create_or_update_active( profile_name = args.profile_name active["updated_at"] = now_str() + active["coordinate_space"] = ( + homography_space + ) + active["native_coordinate_space"] = ( + NATIVE_COORDINATE_SPACE + ) + active["camera_orientation_signature"] = ( + orientation_sig + ) active["profiles"][profile_name] = ( profile_payload ) @@ -3627,6 +4374,49 @@ def main(): default=20.0, ) + parser.add_argument( + "--module-params", + default="calibration/module_params.json", + help=( + "Opcional, mas recomendado em produto. Lê camera_orientation " + "para garantir que a Homography nasce no mesmo espaço do runtime." + ), + ) + + # Aqui a orientação é CIENTÍFICA, não apenas preview. + for role in ROLES: + parser.add_argument( + f"--{role}-rotate-deg", + type=int, + default=None, + choices=[0, 90, 180, 270], + help=( + "Override da orientação pré-Homography. " + "Divergência do MP é bloqueada por padrão." + ), + ) + + parser.add_argument( + f"--{role}-flip-horizontal", + action="store_true", + help="Flip horizontal CIENTÍFICO antes do ChArUco.", + ) + + parser.add_argument( + f"--{role}-flip-vertical", + action="store_true", + help="Flip vertical CIENTÍFICO antes do ChArUco.", + ) + + parser.add_argument( + "--allow-orientation-override", + action="store_true", + help=( + "Engenharia: permite CLI divergir de camera_orientation do MP. " + "O relatório marcará que o module_params precisa ser atualizado." + ), + ) + parser.add_argument( "--isp-sharpness", type=int, @@ -3810,6 +4600,11 @@ def main(): profile_eval = None actual_mx = None usb_speed = None + module_params = {} + orientation_contract = {} + calibration_coordinate_space = ( + NATIVE_COORDINATE_SPACE + ) window_name = ( "Homography Calibration - Production" @@ -3841,6 +4636,28 @@ def main(): camera_rows ) + module_params = ( + load_json( + args.module_params + ) + if args.module_params + else {} + ) + + orientation_contract = ( + resolve_orientation_contract( + specs, + args, + module_params=module_params, + ) + ) + + calibration_coordinate_space = str( + orientation_contract[ + "output_space" + ] + ) + print("=" * 88) print("HOMOGRAPHY CALIBRATION - PRODUCTION") print(f"Profile : {args.profile_name}") @@ -3862,12 +4679,37 @@ def main(): print("-" * 88) print( - f"H_RE : 1280x800 -> " - f"{specs['rgb'].width}x{specs['rgb'].height}" + f"[SPACE] native={NATIVE_COORDINATE_SPACE}" ) print( - f"H_NIR: 1280x800 -> " - f"{specs['rgb'].width}x{specs['rgb'].height}" + f"[SPACE] homography={calibration_coordinate_space}" + ) + + for role in ROLES: + o = orientation_contract[ + "by_role" + ][role] + + print( + f"[ORIENTATION] {role.upper():3s} | " + f"rotate={o['rotate_deg']:3d} | " + f"flip_h={o['flip_horizontal']} | " + f"flip_v={o['flip_vertical']} | " + f"{o['native_size']} -> {o['oriented_size']} | " + f"source={o['source']}" + ) + + print("-" * 88) + + print( + f"H_RE : " + f"{orientation_contract['by_role']['re']['oriented_size']} -> " + f"{orientation_contract['by_role']['rgb']['oriented_size']}" + ) + print( + f"H_NIR: " + f"{orientation_contract['by_role']['nir']['oriented_size']} -> " + f"{orientation_contract['by_role']['rgb']['oriented_size']}" ) print("=" * 88) @@ -3895,6 +4737,7 @@ def main(): report = { "schema": SCHEMA, + "tool_version": TOOL_VERSION, "session_id": sid, "created_at": now_str(), "status": "running", @@ -3912,15 +4755,67 @@ def main(): "hardware_signature": hardware_signature( specs ), - "coordinate_space": COORDINATE_SPACE, + "native_coordinate_space": ( + NATIVE_COORDINATE_SPACE + ), + "coordinate_space": ( + calibration_coordinate_space + ), + "geometry_contract": { + "native_coordinate_space": ( + NATIVE_COORDINATE_SPACE + ), + "homography_coordinate_space": ( + calibration_coordinate_space + ), + "camera_orientation": ( + orientation_contract + ), + "camera_orientation_signature": ( + orientation_signature( + orientation_contract + ) + ), + "orientation_applied_before_charuco": True, + "orientation_applied_before_homography": True, + "external_undistort_applied": False, + "runtime_order": ( + "decode -> radiometric_normalization -> flat_native -> " + "camera_orientation -> homography" + ), + }, "coordinate_spaces": { role: { "role": role, "sensor": specs[role].sensor_name, - "size": [ + "native_size": [ specs[role].width, specs[role].height, ], + "size": list( + orientation_contract[ + "by_role" + ][role][ + "oriented_size" + ] + ), + "orientation": { + "rotate_deg": orientation_contract[ + "by_role" + ][role][ + "rotate_deg" + ], + "flip_horizontal": orientation_contract[ + "by_role" + ][role][ + "flip_horizontal" + ], + "flip_vertical": orientation_contract[ + "by_role" + ][role][ + "flip_vertical" + ], + }, "stream_source": specs[role].source, "external_undistort_applied": False, } @@ -3969,6 +4864,19 @@ def main(): candidate_dir ), "active_json": args.active_json, + "module_params_orientation_source": ( + args.module_params + if orientation_contract.get( + "source_module_params" + ) + else None + ), + "requires_module_params_update": bool( + orientation_contract.get( + "requires_module_params_update", + False, + ) + ), } with device_ctx as device: @@ -3987,6 +4895,9 @@ def main(): print("") print("[PRE-FLIGHT]") + print( + "[GEOMETRY] ChArUco será detectado APÓS camera_orientation." + ) print( "Mostre o ChArUco às três câmeras no plano que será calibrado." ) @@ -4003,6 +4914,7 @@ def main(): dictionary, detector_params, qa, + orientation_contract, allow_invert=( not args.no_auto_invert ), @@ -4010,8 +4922,13 @@ def main(): panels = [] + preflight_frames = oriented_frames( + runtime.frames, + orientation_contract, + ) + for role in ROLES: - img = runtime.frames[role] + img = preflight_frames[role] if img.ndim == 2: view = cv2.cvtColor( @@ -4033,6 +4950,10 @@ def main(): panel, [ f"{role.upper()} | {specs[role].sensor_name}", + ( + f"ROT={orientation_contract['by_role'][role]['rotate_deg']} " + f"SPACE={calibration_coordinate_space}" + ), f"PREFLIGHT={p['status'].upper()}", f"markers={p['markers']} corners={p['corners']}", f"EXP={runtime.controls[role].get('exposure_time_us')}us " @@ -4184,6 +5105,7 @@ def main(): profile_eval, args, qa, + orientation_contract, message=msg, ) @@ -4288,6 +5210,7 @@ def main(): dictionary, detector_params, qa, + orientation_contract, allow_invert=( not args.no_auto_invert ), @@ -4361,6 +5284,17 @@ def main(): "frame_controls": triplet[ "controls" ], + "coordinate_space": ( + calibration_coordinate_space + ), + "camera_orientation_signature": ( + orientation_signature( + orientation_contract + ) + ), + "calibration_frame_shapes": detection[ + "frame_shapes" + ], "detections": { role: { "markers": detection[ @@ -4402,7 +5336,10 @@ def main(): save_sample_preview( preview_path, - triplet["frames"], + oriented_frames( + triplet["frames"], + orientation_contract, + ), detection, ) @@ -4464,6 +5401,7 @@ def main(): samples, specs, qa, + orientation_contract, ) ) @@ -4636,13 +5574,47 @@ def main(): args.depth_cm ), "same_physical_plane_required": True, - "coordinate_space": COORDINATE_SPACE, + "native_coordinate_space": ( + NATIVE_COORDINATE_SPACE + ), + "coordinate_space": ( + calibration_coordinate_space + ), + "camera_orientation": ( + orientation_signature( + orientation_contract + ) + ), "coordinate_spaces": { role: { - "size": [ + "native_size": [ specs[role].width, specs[role].height, ], + "size": list( + orientation_contract[ + "by_role" + ][role][ + "oriented_size" + ] + ), + "orientation": { + "rotate_deg": orientation_contract[ + "by_role" + ][role][ + "rotate_deg" + ], + "flip_horizontal": orientation_contract[ + "by_role" + ][role][ + "flip_horizontal" + ], + "flip_vertical": orientation_contract[ + "by_role" + ][role][ + "flip_vertical" + ], + }, "sensor": specs[role].sensor_name, "stream_source": specs[role].source, "external_undistort_applied": False, @@ -4650,7 +5622,7 @@ def main(): for role in ROLES }, "homography_source": ( - "charuco_native_multi_sample_group_cv" + "charuco_oriented_multi_sample_group_cv" ), "homography_stats": homography_stats, "homographies": { @@ -4743,6 +5715,8 @@ def main(): f"status={report['status']}\n" f"profile={args.profile_name}\n" f"depth_cm={args.depth_cm}\n" + f"coordinate_space={calibration_coordinate_space}\n" + f"rgb_rotate_deg={orientation_contract['by_role']['rgb']['rotate_deg']}\n" f"active_json={args.active_json}\n" ), encoding="utf-8", @@ -4774,6 +5748,11 @@ def main(): "", f"profile={args.profile_name}", f"depth={args.depth_cm} cm", + f"space={calibration_coordinate_space}", + ( + f"RGB orientation=" + f"{orientation_contract['by_role']['rgb']['rotate_deg']}deg" + ), "", f"RE fit med={re_model['fit_error']['median_px']:.2f}px", f"RE CV med={re_model['cross_validation']['overall']['median_px']:.2f}px", @@ -4814,7 +5793,7 @@ def main(): "schema": SCHEMA, "session_id": sid, "created_at": now_str(), - "coordinate_space": COORDINATE_SPACE, + "coordinate_space": calibration_coordinate_space, } report["status"] = "cancelled" @@ -4851,7 +5830,7 @@ def main(): "schema": SCHEMA, "session_id": sid, "created_at": now_str(), - "coordinate_space": COORDINATE_SPACE, + "coordinate_space": calibration_coordinate_space, } report["status"] = "error" diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_8_build_module_params.py b/Python/OAK/datasets/oak-fcc-3/utils/_8_build_module_params.py index 036f7d53f..156eb7ca5 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/_8_build_module_params.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/_8_build_module_params.py @@ -12,7 +12,7 @@ Ele somente: 1. carrega os artefatos HOMOLOGADOS das etapas anteriores; 2. valida schema/status/promoção; 3. cruza hardware/MX ID; - 4. cruza domínio geométrico Intrinsics <-> Homography; + 4. cruza o contrato geométrico Intrinsics -> Camera Orientation -> Homography; 5. cruza Startup Profile <-> Radiometry; 6. valida o NPZ do Flat-Field; 7. preserva apenas políticas runtime explicitamente permitidas; @@ -78,6 +78,7 @@ Entretanto o arquivo passa a conter também: sensor_size_by_role camera_hardware intrinsics_config + camera_orientation calibration_provenance assembly_metadata @@ -107,8 +108,10 @@ O assembler ABORTA se: - MX IDs divergem; - Startup veio de outra Radiometry; - reference_controls divergem; - - Homography e Intrinsics nasceram em espaços geométricos incompatíveis; - - runtime_undistort está ligado com Homography no espaço errado; + - Intrinsics não termina no input_space de camera_orientation; + - camera_orientation não termina no coordinate_space da Homography; + - tamanhos native/oriented não batem com hardware e perfis de Homography; + - runtime_undistort está ligado neste contrato ainda não suportado; - Flat-Field NPZ não existe; - Flat-Field NPZ não contém os gains obrigatórios; - perfil de Homography selecionado não existe; @@ -186,6 +189,11 @@ EXPECTED_NATIVE_SIZES = { } CALIBRATION_SPACE_NATIVE = "native_stream_no_external_undistort" +CALIBRATION_SPACE_ORIENTED = ( + "canonical_oriented_stream_no_external_undistort" +) +CAMERA_ORIENTATION_SCHEMA = "multispec_camera_orientation_v1" +CAMERA_ORIENTATION_APPLY_STAGE = "after_native_flat_before_fusion" # ============================================================ @@ -1458,6 +1466,585 @@ def validate_intrinsics( ) +def oriented_size_from_native( + native_size: list, + rotate_deg: int, +) -> list: + if not ( + isinstance(native_size, (list, tuple)) + and len(native_size) == 2 + ): + raise RuntimeError( + f"native_size inválido: {native_size}" + ) + + w = int(native_size[0]) + h = int(native_size[1]) + + if rotate_deg in (90, 270): + return [h, w] + + return [w, h] + + +def normalize_camera_orientation_role( + role: str, + item: dict, + hardware_signature: dict, +) -> dict: + if not isinstance(item, dict): + raise RuntimeError( + f"camera_orientation.by_role.{role} ausente/inválido." + ) + + raw_rotate = item.get( + "rotate_deg", + 0, + ) + + try: + rotate_float = float( + raw_rotate + ) + except Exception as exc: + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg inválido: " + f"{raw_rotate!r}" + ) from exc + + if abs( + rotate_float + - round(rotate_float) + ) > 1e-9: + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg deve ser inteiro." + ) + + rotate_deg = int( + round(rotate_float) + ) % 360 + + if rotate_deg not in ( + 0, + 90, + 180, + 270, + ): + raise RuntimeError( + f"camera_orientation.{role}.rotate_deg inválido: {rotate_deg}" + ) + + flip_horizontal = item.get( + "flip_horizontal", + False, + ) + + flip_vertical = item.get( + "flip_vertical", + False, + ) + + if not isinstance( + flip_horizontal, + bool, + ): + raise RuntimeError( + f"camera_orientation.{role}.flip_horizontal deve ser bool." + ) + + if not isinstance( + flip_vertical, + bool, + ): + raise RuntimeError( + f"camera_orientation.{role}.flip_vertical deve ser bool." + ) + + expected_native = deepcopy( + hardware_signature[ + role + ][ + "size" + ] + ) + + native_size = item.get( + "native_size", + expected_native, + ) + + if not ( + isinstance( + native_size, + (list, tuple), + ) + and len(native_size) == 2 + ): + raise RuntimeError( + f"camera_orientation.{role}.native_size inválido: {native_size}" + ) + + native_size = [ + int(native_size[0]), + int(native_size[1]), + ] + + if native_size != expected_native: + raise RuntimeError( + f"camera_orientation.{role}.native_size={native_size} " + f"!= hardware={expected_native}" + ) + + expected_oriented = ( + oriented_size_from_native( + native_size, + rotate_deg, + ) + ) + + oriented_size = item.get( + "oriented_size", + expected_oriented, + ) + + if not ( + isinstance( + oriented_size, + (list, tuple), + ) + and len(oriented_size) == 2 + ): + raise RuntimeError( + f"camera_orientation.{role}.oriented_size inválido: " + f"{oriented_size}" + ) + + oriented_size = [ + int(oriented_size[0]), + int(oriented_size[1]), + ] + + if oriented_size != expected_oriented: + raise RuntimeError( + f"camera_orientation.{role}.oriented_size={oriented_size} " + f"!= esperado={expected_oriented} para rotate={rotate_deg}" + ) + + return { + "rotate_deg": rotate_deg, + "flip_horizontal": flip_horizontal, + "flip_vertical": flip_vertical, + "native_size": native_size, + "oriented_size": oriented_size, + } + + +def validate_camera_orientation_from_homography( + homography_data: dict, + fusion_cfg: dict, + hardware_signature: dict, +) -> dict: + """ + A Homography homologada é a autoridade da orientação geométrica final. + + Motivo: + - Intrinsics, Flat e Radiometry medem no espaço nativo; + - Homography é a primeira calibração científica executada DEPOIS + de camera_orientation; + - logo seu camera_orientation_signature define a transformação que + o runtime precisa reproduzir antes de H. + + O runtime-base antigo NUNCA é fonte desta calibração. + """ + signature = homography_data.get( + "camera_orientation_signature" + ) + + if not isinstance( + signature, + dict, + ): + raise RuntimeError( + "Homography ativa sem camera_orientation_signature. " + "Recalibre com a ferramenta orientation-aware." + ) + + schema = str( + signature.get( + "schema", + "", + ) + or "" + ) + + if schema != CAMERA_ORIENTATION_SCHEMA: + raise RuntimeError( + f"camera_orientation.schema={schema!r}, " + f"esperado={CAMERA_ORIENTATION_SCHEMA!r}" + ) + + input_space = str( + signature.get( + "input_space", + "", + ) + or "" + ) + + output_space = str( + signature.get( + "output_space", + "", + ) + or "" + ) + + apply_stage = str( + signature.get( + "apply_stage", + "", + ) + or "" + ).lower() + + if input_space != CALIBRATION_SPACE_NATIVE: + raise RuntimeError( + "camera_orientation.input_space incompatível: " + f"{input_space!r}. Produto atual exige " + f"{CALIBRATION_SPACE_NATIVE!r}." + ) + + if not output_space: + raise RuntimeError( + "camera_orientation.output_space ausente." + ) + + if apply_stage != CAMERA_ORIENTATION_APPLY_STAGE: + raise RuntimeError( + "camera_orientation.apply_stage incompatível: " + f"{apply_stage!r}; esperado " + f"{CAMERA_ORIENTATION_APPLY_STAGE!r}." + ) + + by_role = ( + signature.get( + "by_role", + {}, + ) + or {} + ) + + if not isinstance( + by_role, + dict, + ): + raise RuntimeError( + "camera_orientation.by_role inválido." + ) + + normalized_roles = { + role: normalize_camera_orientation_role( + role, + by_role.get(role), + hardware_signature, + ) + for role in ROLES + } + + transform_enabled = any( + ( + normalized_roles[role][ + "rotate_deg" + ] + != 0 + or normalized_roles[role][ + "flip_horizontal" + ] + or normalized_roles[role][ + "flip_vertical" + ] + ) + for role in ROLES + ) + + enabled = bool( + signature.get( + "enabled", + False, + ) + ) + + if enabled != transform_enabled: + raise RuntimeError( + "camera_orientation.enabled não corresponde às " + "transformações por role." + ) + + if enabled: + if output_space == input_space: + raise RuntimeError( + "camera_orientation ativa exige output_space diferente " + "do input_space." + ) + else: + if output_space != input_space: + raise RuntimeError( + "camera_orientation identidade exige " + "output_space == input_space." + ) + + root_native_space = str( + homography_data.get( + "native_coordinate_space", + "", + ) + or "" + ) + + root_hom_space = str( + homography_data.get( + "coordinate_space", + "", + ) + or "" + ) + + if root_native_space != input_space: + raise RuntimeError( + "Homography.native_coordinate_space diverge do " + "camera_orientation.input_space: " + f"{root_native_space!r} != {input_space!r}" + ) + + if root_hom_space != output_space: + raise RuntimeError( + "Homography.coordinate_space diverge do " + "camera_orientation.output_space: " + f"{root_hom_space!r} != {output_space!r}" + ) + + profiles = ( + homography_data.get( + "profiles", + {}, + ) + or {} + ) + + selected_name = fusion_cfg.get( + "homography_profile" + ) + + if ( + not selected_name + or selected_name not in profiles + ): + raise RuntimeError( + "Perfil selecionado ausente ao validar camera_orientation." + ) + + # Todos os perfis do mesmo active devem pertencer ao MESMO contrato + # de orientação. Isso também protege baixa/media/alta. + canonical_signature = { + "schema": CAMERA_ORIENTATION_SCHEMA, + "enabled": enabled, + "input_space": input_space, + "output_space": output_space, + "apply_stage": apply_stage, + "by_role": deepcopy( + normalized_roles + ), + } + + for profile_name, profile in profiles.items(): + if not isinstance( + profile, + dict, + ): + raise RuntimeError( + f"Homography profile {profile_name} inválido." + ) + + profile_space = str( + profile.get( + "coordinate_space", + "", + ) + or "" + ) + + profile_native_space = str( + profile.get( + "native_coordinate_space", + "", + ) + or "" + ) + + if profile_space != output_space: + raise RuntimeError( + f"Homography profile {profile_name}: coordinate_space=" + f"{profile_space!r} != orientation.output_space=" + f"{output_space!r}" + ) + + if profile_native_space != input_space: + raise RuntimeError( + f"Homography profile {profile_name}: native_coordinate_space=" + f"{profile_native_space!r} != orientation.input_space=" + f"{input_space!r}" + ) + + profile_orientation = profile.get( + "camera_orientation" + ) + + if not isinstance( + profile_orientation, + dict, + ): + raise RuntimeError( + f"Homography profile {profile_name} sem camera_orientation." + ) + + # Normaliza para não depender de ordenação/chaves auxiliares. + p_roles = ( + profile_orientation.get( + "by_role", + {}, + ) + or {} + ) + + p_canonical = { + "schema": profile_orientation.get( + "schema" + ), + "enabled": bool( + profile_orientation.get( + "enabled", + False, + ) + ), + "input_space": profile_orientation.get( + "input_space" + ), + "output_space": profile_orientation.get( + "output_space" + ), + "apply_stage": str( + profile_orientation.get( + "apply_stage", + "", + ) + or "" + ).lower(), + "by_role": { + role: normalize_camera_orientation_role( + role, + p_roles.get(role), + hardware_signature, + ) + for role in ROLES + }, + } + + if p_canonical != canonical_signature: + raise RuntimeError( + f"Homography profile {profile_name} usa outra " + "camera_orientation." + ) + + # O fragmento consumido pelo runtime deve carregar o mesmo domínio. + f_profiles = ( + fusion_cfg.get( + "homography_profiles", + {}, + ) + or {} + ) + + for profile_name, profile in f_profiles.items(): + if not isinstance( + profile, + dict, + ): + raise RuntimeError( + f"fusion_config.homography_profiles.{profile_name} inválido." + ) + + if str( + profile.get( + "coordinate_space", + "", + ) + or "" + ) != output_space: + raise RuntimeError( + f"fusion profile {profile_name}: coordinate_space divergente." + ) + + frag_orientation = profile.get( + "camera_orientation" + ) + + if not isinstance( + frag_orientation, + dict, + ): + raise RuntimeError( + f"fusion profile {profile_name} sem camera_orientation." + ) + + frag_roles = ( + frag_orientation.get( + "by_role", + {}, + ) + or {} + ) + + frag_canonical = { + "schema": frag_orientation.get( + "schema" + ), + "enabled": bool( + frag_orientation.get( + "enabled", + False, + ) + ), + "input_space": frag_orientation.get( + "input_space" + ), + "output_space": frag_orientation.get( + "output_space" + ), + "apply_stage": str( + frag_orientation.get( + "apply_stage", + "", + ) + or "" + ).lower(), + "by_role": { + role: normalize_camera_orientation_role( + role, + frag_roles.get(role), + hardware_signature, + ) + for role in ROLES + }, + } + + if frag_canonical != canonical_signature: + raise RuntimeError( + f"fusion profile {profile_name} usa outra camera_orientation." + ) + + return canonical_signature + + def validate_homography( data: dict, profile_override: Optional[str], @@ -1621,19 +2208,64 @@ def validate_geometry_contract( intrinsics_cfg: dict, homography_data: dict, fusion_cfg: dict, + camera_orientation: dict, + hardware_signature: dict, ): - intr_space = intrinsics_cfg.get( - "calibration_space" + """ + Cadeia geométrica oficial: + + Intrinsics / Flat / Radiometry + native_stream_no_external_undistort + | + v + camera_orientation + | + v + canonical_oriented_stream_no_external_undistort + | + v + Homography + + Intrinsics e Homography NÃO precisam mais compartilhar o mesmo + coordinate_space. Eles precisam encaixar nas duas extremidades da + camera_orientation. + """ + intr_space = str( + intrinsics_cfg.get( + "calibration_space", + "", + ) + or "" ) - hom_space = homography_data.get( - "coordinate_space" + orientation_input = str( + camera_orientation.get( + "input_space", + "", + ) + or "" + ) + + orientation_output = str( + camera_orientation.get( + "output_space", + "", + ) + or "" + ) + + hom_space = str( + homography_data.get( + "coordinate_space", + "", + ) + or "" ) runtime_und = ( intrinsics_cfg.get( "runtime_undistort", - {} + {}, ) or {} ) @@ -1645,26 +2277,32 @@ def validate_geometry_contract( ) ) - if not und_enabled: - if ( - intr_space != CALIBRATION_SPACE_NATIVE - or hom_space != CALIBRATION_SPACE_NATIVE - ): - raise RuntimeError( - "Domínio geométrico inconsistente: " - f"intrinsics={intr_space!r}, homography={hom_space!r}, " - "runtime_undistort=false." - ) + # O RawProcessorCore de produto atual ainda bloqueia runtime undistort. + if und_enabled: + raise RuntimeError( + "runtime_undistort=true ainda não é suportado pelo contrato " + "de produto atual. Recalibre/componha a geometria antes de ativar." + ) - else: - if ( - hom_space - == CALIBRATION_SPACE_NATIVE - ): - raise RuntimeError( - "runtime_undistort=true, mas Homography foi calibrada " - "no espaço nativo distorcido. Recalibre a Homography." - ) + if intr_space != orientation_input: + raise RuntimeError( + "Intrinsics.calibration_space deve ser exatamente o " + "camera_orientation.input_space: " + f"{intr_space!r} != {orientation_input!r}" + ) + + if hom_space != orientation_output: + raise RuntimeError( + "Homography.coordinate_space deve ser exatamente o " + "camera_orientation.output_space: " + f"{hom_space!r} != {orientation_output!r}" + ) + + if intr_space != CALIBRATION_SPACE_NATIVE: + raise RuntimeError( + f"Intrinsics produto deve permanecer no espaço nativo: " + f"{intr_space!r}" + ) selected_profile = fusion_cfg[ "homography_profile" @@ -1678,6 +2316,21 @@ def validate_geometry_contract( ] ) + profile_space = str( + profile.get( + "coordinate_space", + "", + ) + or "" + ) + + if profile_space != orientation_output: + raise RuntimeError( + f"Perfil {selected_profile}: coordinate_space=" + f"{profile_space!r} != orientation.output_space=" + f"{orientation_output!r}" + ) + ref_size = ( profile.get( "reference_size" @@ -1690,7 +2343,7 @@ def validate_geometry_contract( src_sizes = ( profile.get( "source_size_by_role", - {} + {}, ) or {} ) @@ -1699,41 +2352,113 @@ def validate_geometry_contract( "cameras" ] - if ( - ref_size - != cams[ - "rgb" + # Intrinsics sempre descrevem o raster NATIVE. + for role in ROLES: + intr_size = cams[ + role ][ "image_size" ] - ): + + hw_size = hardware_signature[ + role + ][ + "size" + ] + + orient_native = camera_orientation[ + "by_role" + ][role][ + "native_size" + ] + + if intr_size != hw_size: + raise RuntimeError( + f"Intrinsics {role} size={intr_size} " + f"!= hardware native={hw_size}" + ) + + if orient_native != hw_size: + raise RuntimeError( + f"camera_orientation {role} native_size={orient_native} " + f"!= hardware native={hw_size}" + ) + + # Homography já foi medida APÓS orientation, portanto seus tamanhos + # precisam ser os rasteres ORIENTADOS. + rgb_oriented = camera_orientation[ + "by_role" + ][ + "rgb" + ][ + "oriented_size" + ] + + if ref_size != rgb_oriented: raise RuntimeError( - "Homography RGB reference_size diverge de Intrinsics RGB: " - f"{ref_size} != {cams['rgb']['image_size']}" + "Homography RGB reference_size deve corresponder ao RGB " + "já orientado: " + f"{ref_size} != {rgb_oriented}" ) for role in ( "re", "nir", ): + expected_oriented = camera_orientation[ + "by_role" + ][role][ + "oriented_size" + ] + source_size = src_sizes.get( role ) - if ( - source_size is not None - and source_size - != cams[ - role - ][ - "image_size" - ] - ): + if source_size != expected_oriented: raise RuntimeError( - f"Homography source_size {role} diverge de Intrinsics: " - f"{source_size} != {cams[role]['image_size']}" + f"Homography source_size {role}={source_size} " + f"!= raster orientado={expected_oriented}" ) + return { + "intrinsics_space": intr_space, + "orientation_input_space": orientation_input, + "orientation_output_space": orientation_output, + "homography_space": hom_space, + "runtime_undistort_enabled": und_enabled, + "selected_profile": selected_profile, + "native_sizes": { + role: deepcopy( + camera_orientation[ + "by_role" + ][role][ + "native_size" + ] + ) + for role in ROLES + }, + "oriented_sizes": { + role: deepcopy( + camera_orientation[ + "by_role" + ][role][ + "oriented_size" + ] + ) + for role in ROLES + }, + "runtime_order": [ + "decode", + "radiometric_normalization", + "flat_native", + "camera_orientation", + "homography", + "common_crop", + "final_resample", + ], + } + # ============================================================ # Runtime base, whitelist only @@ -1765,6 +2490,10 @@ def read_runtime_policy( ) -> dict: """ Extrai SOMENTE campos que não são calibração. + + `camera_orientation` é deliberadamente ignorada aqui. + Sua autoridade final vem da Homography homologada, pois H foi medida + no espaço já orientado. """ defaults = runtime_defaults() @@ -1924,6 +2653,7 @@ def build_final_module_params( radiometric_normalization: dict, flatfield_config: dict, intrinsics_config: dict, + camera_orientation: dict, fusion_config: dict, provenance: dict, assembly_report_path: str, @@ -1994,6 +2724,12 @@ def build_final_module_params( hardware_signature ), + # Transformação geométrica científica entre o domínio nativo + # (Intrinsics/Flat/Radiometry) e o domínio da Homography. + "camera_orientation": deepcopy( + camera_orientation + ), + "rgb_processing": deepcopy( runtime_policy[ "rgb_processing" @@ -2055,6 +2791,55 @@ def build_final_module_params( False, ) ), + "camera_orientation_schema": ( + camera_orientation.get( + "schema" + ) + ), + "camera_orientation_enabled": bool( + camera_orientation.get( + "enabled", + False, + ) + ), + "geometry_chain": { + "intrinsics_space": intrinsics_config.get( + "calibration_space" + ), + "orientation_input_space": camera_orientation.get( + "input_space" + ), + "orientation_output_space": camera_orientation.get( + "output_space" + ), + "orientation_apply_stage": camera_orientation.get( + "apply_stage" + ), + "homography_space": ( + fusion.get( + "homography_profiles", + {} + ) + .get( + fusion.get( + "homography_profile" + ), + {}, + ) + .get( + "coordinate_space" + ) + ), + "runtime_order": [ + "decode", + "radiometric_normalization", + "flat_native", + "camera_orientation", + "homography", + "common_crop", + "final_resample", + ], + }, }, } @@ -2087,6 +2872,7 @@ def validate_final_module_params( "bayer_pattern", "sensor_size_by_role", "camera_hardware", + "camera_orientation", "rgb_processing", "camera_settings", "fusion_config", @@ -2147,6 +2933,110 @@ def validate_final_module_params( f"sensor_size_by_role sem {role}" ) + orientation = mp[ + "camera_orientation" + ] + + if not isinstance( + orientation, + dict, + ): + raise RuntimeError( + "camera_orientation final inválido." + ) + + if orientation.get( + "schema" + ) != CAMERA_ORIENTATION_SCHEMA: + raise RuntimeError( + "camera_orientation.schema final inválido." + ) + + if str( + orientation.get( + "input_space", + "", + ) + or "" + ) != str( + mp[ + "intrinsics_config" + ].get( + "calibration_space", + "", + ) + or "" + ): + raise RuntimeError( + "camera_orientation.input_space final não encaixa " + "em intrinsics_config.calibration_space." + ) + + if str( + orientation.get( + "apply_stage", + "", + ) + or "" + ).lower() != CAMERA_ORIENTATION_APPLY_STAGE: + raise RuntimeError( + "camera_orientation.apply_stage final inválido." + ) + + by_role = ( + orientation.get( + "by_role", + {}, + ) + or {} + ) + + if not isinstance( + by_role, + dict, + ): + raise RuntimeError( + "camera_orientation.by_role final inválido." + ) + + normalized_final_roles = { + role: normalize_camera_orientation_role( + role, + by_role.get(role), + mp[ + "camera_hardware" + ], + ) + for role in ROLES + } + + transform_enabled = any( + ( + normalized_final_roles[role][ + "rotate_deg" + ] + != 0 + or normalized_final_roles[role][ + "flip_horizontal" + ] + or normalized_final_roles[role][ + "flip_vertical" + ] + ) + for role in ROLES + ) + + if bool( + orientation.get( + "enabled", + False, + ) + ) != transform_enabled: + raise RuntimeError( + "camera_orientation.enabled final não corresponde " + "às transformações." + ) + fusion = mp[ "fusion_config" ] @@ -2181,6 +3071,74 @@ def validate_final_module_params( "Perfil de homografia final inválido." ) + selected_profile = profiles[ + selected + ] + + orientation_output_space = str( + orientation.get( + "output_space", + "", + ) + or "" + ) + + if str( + selected_profile.get( + "coordinate_space", + "", + ) + or "" + ) != orientation_output_space: + raise RuntimeError( + "Homography final não encaixa em " + "camera_orientation.output_space." + ) + + expected_ref_size = normalized_final_roles[ + "rgb" + ][ + "oriented_size" + ] + + ref_size = ( + selected_profile.get( + "reference_size" + ) + or selected_profile.get( + "homography_calibration_size" + ) + ) + + if ref_size != expected_ref_size: + raise RuntimeError( + f"Homography reference_size final={ref_size} " + f"!= RGB orientado={expected_ref_size}" + ) + + src_sizes = ( + selected_profile.get( + "source_size_by_role", + {}, + ) + or {} + ) + + for role in ( + "re", + "nir", + ): + if src_sizes.get(role) != normalized_final_roles[ + role + ][ + "oriented_size" + ]: + raise RuntimeError( + f"Homography source_size final {role}=" + f"{src_sizes.get(role)} != orientado=" + f"{normalized_final_roles[role]['oriented_size']}" + ) + if bool( mp[ "radiometric_config" @@ -2502,6 +3460,14 @@ def main(): ) ) + camera_orientation = ( + validate_camera_orientation_from_homography( + homography, + fusion_cfg, + common_signature, + ) + ) + mx_ids = extract_mx_ids( artifacts ) @@ -2526,10 +3492,12 @@ def main(): # Cross-contract checks # -------------------------------------------------------- - validate_geometry_contract( + geometry_contract = validate_geometry_contract( intrinsics_cfg, homography, fusion_cfg, + camera_orientation, + common_signature, ) # Intrinsics dimensions must exactly match common hardware. @@ -2610,6 +3578,34 @@ def main(): ), "device_mx_id": common_mx, "module_id": common_module_id, + "camera_orientation": { + "source": ( + "homography.camera_orientation_signature" + ), + "schema": camera_orientation.get( + "schema" + ), + "enabled": bool( + camera_orientation.get( + "enabled", + False, + ) + ), + "input_space": camera_orientation.get( + "input_space" + ), + "output_space": camera_orientation.get( + "output_space" + ), + "apply_stage": camera_orientation.get( + "apply_stage" + ), + "selected_homography_profile": ( + fusion_cfg[ + "homography_profile" + ] + ), + }, "artifacts": { "focus": artifact_meta( args.focus_json, @@ -2666,6 +3662,7 @@ def main(): radiometric_normalization=rad_norm, flatfield_config=flatfield_cfg, intrinsics_config=intrinsics_cfg, + camera_orientation=camera_orientation, fusion_config=fusion_cfg, provenance=provenance, assembly_report_path=args.report, @@ -2706,30 +3703,12 @@ def main(): "homography_profile" ] ), - "geometric_contract": { - "intrinsics_space": ( - intrinsics_cfg.get( - "calibration_space" - ) - ), - "homography_space": ( - homography.get( - "coordinate_space" - ) - ), - "runtime_undistort_enabled": bool( - ( - intrinsics_cfg.get( - "runtime_undistort", - {} - ) - or {} - ).get( - "enabled", - False, - ) - ), - }, + "geometric_contract": deepcopy( + geometry_contract + ), + "camera_orientation": deepcopy( + camera_orientation + ), "runtime_policy": runtime_policy, "calibration_provenance": provenance, "final_checks": { @@ -2741,6 +3720,7 @@ def main(): "homography": "pass", "hardware_consistency": "pass", "geometry_consistency": "pass", + "camera_orientation": "pass", "startup_radiometry_consistency": "pass", "flatfield_npz_integrity": "pass", "module_params_structure": "pass", @@ -2773,6 +3753,26 @@ def main(): f"Homography : " f"{fusion_cfg['homography_profile']}" ) + print( + "Orientation : " + f"enabled={camera_orientation['enabled']} | " + f"in={camera_orientation['input_space']} | " + f"out={camera_orientation['output_space']}" + ) + + for role in ROLES: + orient = camera_orientation[ + "by_role" + ][role] + + print( + f" {role.upper():3s} : " + f"ROT={orient['rotate_deg']:3d} " + f"FH={int(orient['flip_horizontal'])} " + f"FV={int(orient['flip_vertical'])} | " + f"{orient['native_size']} -> {orient['oriented_size']}" + ) + print( f"Undistort : " f"{intrinsics_cfg['runtime_undistort'].get('enabled', False)}"