diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py index 3a2ba9d24..6282d4e94 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py @@ -28,7 +28,7 @@ module_params is loaded. The strict fail-closed behavior is enabled only for module_params generated by the production assembler. """ -RAW_PROCESSOR_CORE_VERSION = "production_v1_2026_08_24" +RAW_PROCESSOR_CORE_VERSION = "production_v2_2026_09_08" import json import os @@ -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,23 +3909,72 @@ class RawProcessorCore: ) ): raise RuntimeError( - "Produto exige radiometric_normalization.enabled=true." + "Produto exige " + "radiometric_normalization.enabled=true." ) - if ( - str( + radiometric_method = str( + rad_norm.get( + "method", + "", + ) + ).lower() + + allowed_radiometric_methods = { + "oak_ae_frame_controls_v1", + "oak_ae_frame_controls_affine_v2", + } + + if radiometric_method not in allowed_radiometric_methods: + raise RuntimeError( + "Método radiométrico inválido no produto: " + f"{rad_norm.get('method')!r}" + ) + + if radiometric_method == "oak_ae_frame_controls_affine_v2": + black_offset_model = str( rad_norm.get( - "method", + "black_offset_model", "", ) ).lower() - != "oak_ae_frame_controls_v1" - ): - raise RuntimeError( - f"Método radiométrico inválido no produto: " - f"{rad_norm.get('method')!r}" + + if black_offset_model != "per_role_scalar_raw01": + raise RuntimeError( + "black_offset_model inválido para affine_v2: " + f"{rad_norm.get('black_offset_model')!r}. " + "Esperado='per_role_scalar_raw01'." + ) + + black_offsets = ( + rad_norm.get( + "black_offset_by_role", + {}, + ) + or {} ) + for role in ( + "rgb", + "re", + "nir", + ): + try: + offset = float( + black_offsets[role] + ) + except Exception as exc: + raise RuntimeError( + "radiometric_normalization sem black offset " + f"válido para role={role}." + ) from exc + + if not np.isfinite(offset) or not (0.0 <= offset < 1.0): + raise RuntimeError( + "black offset fora do domínio RAW01 em " + f"role={role}: {offset!r}" + ) + factor_model = str( rad_norm.get( "factor_model", @@ -3820,7 +3984,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 +3997,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 +4029,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 +4060,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 +4071,8 @@ class RawProcessorCore: ) ): raise RuntimeError( - "patch_normalization deve estar desligado no produto." + "patch_normalization deve estar " + "desligado no produto." ) if bool( @@ -3897,9 +4082,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 +4117,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 +4128,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 +4143,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 +4158,7 @@ class RawProcessorCore: image_size = cam.get( "image_size" ) + expected = [ int(x) for x in self.sensor_size_by_role[role] @@ -4033,9 +4212,472 @@ 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 + # ============================================================ + + # No contrato com perfis, coordinate_space pertence ao profile ativo. + # Mantemos fallback para o campo top-level por compatibilidade legada. + fusion_space = str( + fusion.get( + "coordinate_space", + "", + ) + or "" + ).strip() + + if not fusion_space: + selected_profile = self._resolve_homography_profile_name_for_role( + "re" + ) + profiles = fusion.get( + "homography_profiles", + {}, + ) or {} + profile = profiles.get(selected_profile) + + if profile is None and isinstance(profiles, dict): + for profile_name, profile_item in profiles.items(): + if str(profile_name).lower() == str(selected_profile).lower(): + profile = profile_item + break + + if isinstance(profile, dict): + fusion_space = str( + profile.get( + "coordinate_space", + "", + ) + or "" + ).strip() + + if not fusion_space: + raise RuntimeError( + "coordinate_space ausente no fusion_config e no " + "homography_profile ativo." + ) + + 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 +4686,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 +4709,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 +4990,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 = {} @@ -5071,11 +5727,18 @@ class RawProcessorCore: return decoded method = str(cfg.get("method", "oak_ae_frame_controls_v1")).lower() - if method not in ("oak_ae_frame_controls_v1", "exposure_iso_reference"): + if method not in ( + "oak_ae_frame_controls_v1", + "oak_ae_frame_controls_affine_v2", + "exposure_iso_reference", + ): result["warnings"].append(f"unsupported_method:{method}") self.last_radiometric_normalization_result = result return decoded + affine_v2 = method == "oak_ae_frame_controls_affine_v2" + black_offset_by_role = cfg.get("black_offset_by_role", {}) or {} + controls_by_role, controls_by_cam = self._extract_frame_controls_from_meta_by_role(meta) if not controls_by_role: @@ -5098,6 +5761,7 @@ class RawProcessorCore: # Cache por role para não recalcular factor/scale 3x quando RGB tem 3 canais no mesmo item. scale_by_role = {} + offset_by_role = {} debug_by_role = {} normalized = {} @@ -5113,6 +5777,7 @@ class RawProcessorCore: if role in scale_by_role: scale = scale_by_role[role] + offset = offset_by_role[role] debug = dict(debug_by_role[role]) debug["camera_id"] = cam_id else: @@ -5142,6 +5807,44 @@ class RawProcessorCore: raw_scale = float(ref_factor / actual_factor) scale = self._clip_radiometric_scale(raw_scale, role, cfg) + offset = 0.0 + if affine_v2: + try: + offset = float(black_offset_by_role[role]) + except Exception: + normalized[cam_id] = item + result["warnings"].append( + f"{role}:invalid_black_offset" + ) + + if ( + getattr(self, "strict_product_contract", False) + or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise" + ): + raise RuntimeError( + "Black offset radiométrico inválido ou ausente " + f"para role={role}." + ) + + continue + + if not np.isfinite(offset) or not (0.0 <= offset < 1.0): + normalized[cam_id] = item + result["warnings"].append( + f"{role}:invalid_black_offset:{offset!r}" + ) + + if ( + getattr(self, "strict_product_contract", False) + or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise" + ): + raise RuntimeError( + "Black offset radiométrico fora do domínio RAW01 " + f"para role={role}: {offset!r}" + ) + + continue + debug = self._build_radiometric_debug( method=method, role=role, @@ -5153,16 +5856,26 @@ class RawProcessorCore: raw_scale=raw_scale, scale=scale, clip_output=clip_output, + black_offset=offset if affine_v2 else None, ) scale_by_role[role] = scale + offset_by_role[role] = offset debug_by_role[role] = dict(debug) - out, reused = self._apply_radiometric_scale_inplace( - img, - scale=scale, - clip_output=clip_output, - ) + if affine_v2: + out, reused = self._apply_radiometric_affine_inplace( + img, + scale=scale, + black_offset=offset, + clip_output=clip_output, + ) + else: + out, reused = self._apply_radiometric_scale_inplace( + img, + scale=scale, + clip_output=clip_output, + ) new_item = dict(item) new_meta = dict(item.get("meta", {}) or {}) @@ -5174,6 +5887,8 @@ class RawProcessorCore: new_meta["radiometric_normalization"] = debug new_meta["radiometric_normalization_applied"] = True new_meta["radiometric_normalization_scale"] = float(scale) + if affine_v2: + new_meta["radiometric_normalization_black_offset"] = float(offset) new_item["image"] = out new_item["meta"] = new_meta @@ -5196,6 +5911,12 @@ class RawProcessorCore: "inplace_fast_path": True, } + if affine_v2: + result["summary"]["black_offset_by_role"] = { + role: float(value) + for role, value in offset_by_role.items() + } + self.last_radiometric_normalization_result = result return normalized @@ -5745,8 +6466,55 @@ class RawProcessorCore: return out, reused - def _build_radiometric_debug(self, method, role, cam_id, actual_ctrl, ref_ctrl, actual_factor, ref_factor, raw_scale, scale, clip_output): - return { + def _apply_radiometric_affine_inplace( + self, + img, + scale: float, + black_offset: float, + clip_output: bool, + ): + """ + Aplica o contrato radiométrico affine_v2 no domínio RAW01: + + offset + (value - offset) * reference_factor / actual_factor + + O mesmo offset escalar é aplicado aos três canais RGB da role RGB, + conforme black_offset_model='per_role_scalar_raw01'. + """ + out, reused = self._radiometric_get_writable_float32_image(img) + + if out is None: + return img, False + + scale = np.float32(float(scale)) + offset = np.float32(float(black_offset)) + + # Para scale=1 a transformação é identidade, independentemente do offset. + if abs(float(scale) - 1.0) > 1e-6: + np.subtract(out, offset, out=out, casting="unsafe") + np.multiply(out, scale, out=out, casting="unsafe") + np.add(out, offset, out=out, casting="unsafe") + + if clip_output: + np.clip(out, 0.0, 1.0, out=out) + + return out, reused + + def _build_radiometric_debug( + self, + method, + role, + cam_id, + actual_ctrl, + ref_ctrl, + actual_factor, + ref_factor, + raw_scale, + scale, + clip_output, + black_offset=None, + ): + debug = { "applied": True, "method": method, "role": role, @@ -5760,6 +6528,16 @@ class RawProcessorCore: "clip_output": bool(clip_output), } + if black_offset is not None: + debug["black_offset_model"] = "per_role_scalar_raw01" + debug["black_offset"] = float(black_offset) + debug["formula"] = ( + "offset + (value - offset) * " + "reference_factor / actual_factor" + ) + + return debug + def _bayer_pattern_to_code_fast(self, bayer_pattern: str | None) -> int: @@ -7665,5 +8443,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/calibration/_1_focus_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_1_focus_calibration_tool.py index 561a56421..f519c7593 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_1_focus_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_1_focus_calibration_tool.py @@ -48,9 +48,10 @@ bloqueia acceptance se alguma das 5 zonas não tiver textura suficiente. Fluxo ----- 1) PRE-FLIGHT - - target de foco preenche o FOV + - operador pode definir uma CALIBRATION ROI por câmera com o mouse + - a ROI representa somente a área útil que seguirá para o produto/fusão - AE estabiliza - - valida saturação, escuridão e textura + - valida saturação, escuridão e textura SOMENTE dentro da CALIBRATION ROI - ENTER congela EXP/ISO das 3 câmeras 2) FOCUS RGB @@ -68,6 +69,14 @@ Fluxo - PASS: salva candidate + promove relatório QC ativo - FAIL/CANCEL: candidate preservado, ativo anterior mantido +Teclas no PRE-FLIGHT +--------------------- +Mouse -> arraste dentro de RGB / RE / NIR para definir CALIBRATION ROI +1 / 2 / 3 -> seleciona RGB / RE / NIR +R -> reseta CALIBRATION ROI da câmera selecionada para frame inteiro +ENTER -> congela EXP/ISO e inicia foco +Q / ESC -> cancela sessão + Teclas durante ajuste --------------------- 1 / 2 / 3 -> seleciona RGB / RE / NIR @@ -471,6 +480,82 @@ def default_roi_for_shape(shape_hw, frac=0.36): return (x0, y0, x0 + rw, y0 + rh) +def full_roi_for_shape(shape_hw): + h, w = shape_hw + return (0, 0, int(w), int(h)) + + +def default_roi_inside_rect(rect, frac=0.36): + """ + ROI central de foco calculada DENTRO da CALIBRATION ROI. + """ + x0, y0, x1, y1 = map(int, rect) + + w = max(1, x1 - x0) + h = max(1, y1 - y0) + + rw = max(16, int(round(w * frac))) + rh = max(16, int(round(h * frac))) + + cx = (x0 + x1) // 2 + cy = (y0 + y1) // 2 + + rx0 = cx - rw // 2 + ry0 = cy - rh // 2 + + return ( + int(rx0), + int(ry0), + int(rx0 + rw), + int(ry0 + rh), + ) + + +def rect_from_norm_inside_roi( + roi_rect, + x0, + y0, + x1, + y1, +): + """ + Converte coordenadas normalizadas 0..1 para pixels RELATIVOS à + CALIBRATION ROI, e não ao frame inteiro. + """ + rx0, ry0, rx1, ry1 = map(int, roi_rect) + + rw = max(1, rx1 - rx0) + rh = max(1, ry1 - ry0) + + return ( + int(round(rx0 + x0 * rw)), + int(round(ry0 + y0 * rh)), + int(round(rx0 + x1 * rw)), + int(round(ry0 + y1 * rh)), + ) + + +def intersect_rects(a, b): + if a is None: + return b + + if b is None: + return a + + ax0, ay0, ax1, ay1 = map(int, a) + bx0, by0, bx1, by1 = map(int, b) + + x0 = max(ax0, bx0) + y0 = max(ay0, by0) + x1 = min(ax1, bx1) + y1 = min(ay1, by1) + + if x1 <= x0 or y1 <= y0: + return None + + return (x0, y0, x1, y1) + + def sanitize_roi(rect, shape_hw): if rect is None: return None @@ -1320,6 +1405,7 @@ def compute_field_qa( img_bgr: np.ndarray, qa: dict, equalize=False, + calibration_roi=None, ): gray = to_gray01(img_bgr) @@ -1330,11 +1416,29 @@ def compute_field_qa( "zones": {}, } + if calibration_roi is None: + calibration_roi = full_roi_for_shape( + gray.shape[:2] + ) + + calibration_roi = sanitize_roi( + calibration_roi, + gray.shape[:2], + ) + + if calibration_roi is None: + return { + "status": "bad", + "reasons": ["calibration_roi_invalida"], + "zones": {}, + "calibration_roi": None, + } + zones = {} for name, nrect in FIELD_ZONES_NORM.items(): - rect = rect_from_norm( - gray.shape[:2], + rect = rect_from_norm_inside_roi( + calibration_roi, *nrect, ) @@ -1437,6 +1541,9 @@ def compute_field_qa( "corner_ratios": corner_ratios, "corner_spread": spread, "samples": 1, + "calibration_roi": list( + map(int, calibration_roi) + ), } @@ -1599,16 +1706,50 @@ def aggregate_field_qa( # Preflight # ============================================================ -def image_preflight(img_bgr, qa: dict): +def image_preflight( + img_bgr, + qa: dict, + calibration_roi=None, +): gray = to_gray01(img_bgr) if gray is None: return { "status": "bad", "reasons": ["sem_frame"], + "calibration_roi": None, } - arr = gray.reshape(-1) + if calibration_roi is None: + calibration_roi = full_roi_for_shape( + gray.shape[:2] + ) + + calibration_roi = sanitize_roi( + calibration_roi, + gray.shape[:2], + ) + + if calibration_roi is None: + return { + "status": "bad", + "reasons": ["calibration_roi_invalida"], + "calibration_roi": None, + } + + roi = crop_rect( + gray, + calibration_roi, + ) + + if roi is None or roi.size < 64: + return { + "status": "bad", + "reasons": ["calibration_roi_vazia"], + "calibration_roi": list(calibration_roi), + } + + arr = roi.reshape(-1) mean = float(np.mean(arr)) std = float(np.std(arr)) @@ -1635,7 +1776,7 @@ def image_preflight(img_bgr, qa: dict): status = merge_status(status, "warning") reasons.append(f"dark_warn:{dark_pct:.2f}%") - # Full frame precisa ter alguma textura. + # Somente a área útil precisa ter textura. if std < qa["min_texture_std"]: status = merge_status(status, "bad") reasons.append(f"texture_bad:{std:.4f}") @@ -1652,10 +1793,23 @@ def image_preflight(img_bgr, qa: dict): "p95": p95, "sat_pct": sat_pct, "dark_pct": dark_pct, + "calibration_roi": list( + map(int, calibration_roi) + ), + "roi_pixels": int(arr.size), } -def merge_preflight_by_role(frames, qa): +def merge_preflight_by_role( + frames, + qa, + calibration_roi_by_role=None, +): + calibration_roi_by_role = ( + calibration_roi_by_role + or {} + ) + result = { "status": "good", "roles": {}, @@ -1663,14 +1817,24 @@ def merge_preflight_by_role(frames, qa): } for role in ROLES: - r = image_preflight(frames.get(role), qa) + r = image_preflight( + frames.get(role), + qa, + calibration_roi=calibration_roi_by_role.get(role), + ) + result["roles"][role] = r + result["status"] = merge_status( result["status"], r["status"], ) + result["reasons"].extend( - [f"{role}:{x}" for x in r.get("reasons", [])] + [ + f"{role}:{x}" + for x in r.get("reasons", []) + ] ) return result @@ -2020,14 +2184,43 @@ def draw_roi( ) -def draw_field_zones(panel, src_shape_hw, field_qa=None): +def draw_calibration_roi( + panel, + calibration_roi, + src_shape_hw, + active=False, +): + if calibration_roi is None: + return + + draw_roi( + panel, + calibration_roi, + src_shape_hw, + color=(255, 0, 255) if active else (180, 0, 180), + label="CALIBRATION ROI", + thickness=2 if active else 1, + ) + + +def draw_field_zones( + panel, + src_shape_hw, + field_qa=None, + calibration_roi=None, +): color_good = (0, 220, 0) color_warn = (0, 200, 255) color_bad = (0, 0, 255) + if calibration_roi is None: + calibration_roi = full_roi_for_shape( + src_shape_hw + ) + for name, nrect in FIELD_ZONES_NORM.items(): - rect = rect_from_norm( - src_shape_hw, + rect = rect_from_norm_inside_roi( + calibration_roi, *nrect, ) @@ -2149,6 +2342,7 @@ def build_board( runtime: FocusRuntime, specs, selected_role, + calibration_roi_rects, roi_rects, state, field_qa_by_role, @@ -2197,6 +2391,13 @@ def build_board( active = role == selected_role + draw_calibration_roi( + panel, + calibration_roi_rects.get(role), + img.shape[:2], + active=active, + ) + draw_roi( panel, roi_rects[role], @@ -2210,6 +2411,7 @@ def build_board( panel, img.shape[:2], field_qa_by_role.get(role), + calibration_roi=calibration_roi_rects.get(role), ) c = runtime.controls[role] @@ -2219,6 +2421,10 @@ def build_board( [ f"{role.upper()} | {spec.socket_name} | {spec.sensor_name}", f"{img.shape[1]}x{img.shape[0]} | {'ATIVA' if active else ''}", + ( + f"CALIB ROI=" + f"{list(calibration_roi_rects.get(role) or [])}" + ), f"EXP={c.get('exposure_time_us')}us ISO={c.get('sensitivity_iso')}", f"FPS={runtime.fps[role]:.1f}", ], @@ -2396,6 +2602,7 @@ def attempt_accept( state, field_qa_by_role, runtime, + calibration_roi_rects, roi_rects, args, qa, @@ -2469,6 +2676,16 @@ def attempt_accept( ), "stability": dict(stable), "field_qa": field, + "calibration_roi": ( + list( + map( + int, + calibration_roi_rects[role], + ) + ) + if calibration_roi_rects.get(role) is not None + else None + ), "roi": ( list(map(int, roi_rects[role])) if roi_rects[role] is not None @@ -2555,8 +2772,238 @@ def run_preflight( runtime.wait_all() print("") - print("[PRE-FLIGHT] Use alvo de foco plano, detalhado e preenchendo o FOV.") - print("[PRE-FLIGHT] ENTER congela exposição/ISO quando aprovado.") + print( + "[PRE-FLIGHT] Arraste o mouse sobre cada câmera para definir " + "a CALIBRATION ROI (área útil do produto)." + ) + print( + "[PRE-FLIGHT] Saturação/escuridão/textura serão medidas SOMENTE " + "dentro dessa ROI." + ) + print( + "[PRE-FLIGHT] ENTER congela exposição/ISO quando o conjunto estiver aprovado." + ) + + ph = int(args.panel_height) + pw = int(args.panel_width) + + calibration_roi_rects = { + role: full_roi_for_shape( + runtime.frames[role].shape[:2] + ) + for role in ROLES + } + + panel_rects = { + "rgb": (0, 0, pw, ph), + "re": (pw, 0, pw * 2, ph), + "nir": (0, ph, pw, ph * 2), + "data": (pw, ph, pw * 2, ph * 2), + } + + selected_role = "rgb" + dragging = False + drag_role = None + drag_start = None + draft_roi = None + last_message = "" + last_message_t = 0.0 + + def message(text): + nonlocal last_message, last_message_t + last_message = str(text) + last_message_t = time.time() + + def inside(rect, x, y): + if rect is None: + return False + + x0, y0, x1, y1 = rect + + return ( + x0 <= x < x1 + and y0 <= y < y1 + ) + + def role_at_point(x, y): + for role in ROLES: + if inside( + panel_rects.get(role), + x, + y, + ): + return role + + return None + + def display_to_source( + role, + x, + y, + ): + rect = panel_rects.get(role) + img = runtime.frames.get(role) + + if rect is None or img is None: + return None + + x0, y0, x1, y1 = rect + + panel_w = max(1, x1 - x0) + panel_h = max(1, y1 - y0) + + src_h, src_w = img.shape[:2] + + lx = max( + 0, + min( + panel_w - 1, + int(x - x0), + ), + ) + + ly = max( + 0, + min( + panel_h - 1, + int(y - y0), + ), + ) + + sx = int( + lx * src_w / panel_w + ) + + sy = int( + ly * src_h / panel_h + ) + + return ( + max( + 0, + min( + src_w - 1, + sx, + ), + ), + max( + 0, + min( + src_h - 1, + sy, + ), + ), + ) + + def on_mouse( + event, + x, + y, + flags, + param, + ): + nonlocal selected_role, dragging, drag_role, drag_start, draft_roi + + if event == cv2.EVENT_LBUTTONDOWN: + role = role_at_point( + x, + y, + ) + + if role is None: + return + + pt = display_to_source( + role, + x, + y, + ) + + if pt is None: + return + + selected_role = role + dragging = True + drag_role = role + drag_start = pt + draft_roi = None + + elif ( + event == cv2.EVENT_MOUSEMOVE + and dragging + and drag_role is not None + and drag_start is not None + ): + pt = display_to_source( + drag_role, + x, + y, + ) + + if pt is None: + return + + draft_roi = ( + drag_start[0], + drag_start[1], + pt[0], + pt[1], + ) + + elif ( + event == cv2.EVENT_LBUTTONUP + and dragging + and drag_role is not None + and drag_start is not None + ): + pt = display_to_source( + drag_role, + x, + y, + ) + + role = drag_role + img = runtime.frames.get(role) + + dragging = False + drag_role = None + + if pt is None or img is None: + drag_start = None + draft_roi = None + return + + roi = sanitize_roi( + ( + drag_start[0], + drag_start[1], + pt[0], + pt[1], + ), + img.shape[:2], + ) + + drag_start = None + draft_roi = None + + if roi is None: + message( + f"CALIBRATION ROI inválida: {role.upper()}" + ) + return + + calibration_roi_rects[ + role + ] = roi + + message( + f"CALIBRATION ROI {role.upper()} = {list(roi)}" + ) + + cv2.setMouseCallback( + "Focus Calibration - Production", + on_mouse, + ) last_report = None @@ -2566,12 +3013,10 @@ def run_preflight( last_report = merge_preflight_by_role( runtime.frames, qa, + calibration_roi_by_role=calibration_roi_rects, ) - ph = int(args.panel_height) - pw = int(args.panel_width) - - panels = [] + panels = {} for role in ROLES: img = runtime.frames[role] @@ -2581,31 +3026,83 @@ def run_preflight( (ph, pw), role.upper(), ) + else: panel = resize_panel( img, (ph, pw), ) - r = last_report["roles"][role] + calibration_roi = ( + calibration_roi_rects[role] + ) + + draw_calibration_roi( + panel, + calibration_roi, + img.shape[:2], + active=( + role == selected_role + ), + ) + + # Durante o arraste, mostra a seleção provisória. + if ( + dragging + and drag_role == role + and draft_roi is not None + ): + provisional = sanitize_roi( + draft_roi, + img.shape[:2], + ) + + if provisional is not None: + draw_roi( + panel, + provisional, + img.shape[:2], + color=(0, 255, 255), + label="NEW CALIB ROI", + thickness=2, + ) + + r = last_report[ + "roles" + ][role] overlay_hud( panel, [ - f"{role.upper()} | {specs[role].sensor_name}", + ( + f"{role.upper()} | " + f"{specs[role].sensor_name} | " + f"{'SELECTED' if role == selected_role else ''}" + ), f"PREFLIGHT={r['status'].upper()}", + ( + f"ROI=" + f"{r.get('calibration_roi')}" + ), f"std={r.get('std', 0):.3f}", - f"sat={r.get('sat_pct', 0):.2f}% dark={r.get('dark_pct', 0):.2f}%", - f"EXP={runtime.controls[role].get('exposure_time_us')}us " - f"ISO={runtime.controls[role].get('sensitivity_iso')}", + ( + f"sat={r.get('sat_pct', 0):.2f}% " + f"dark={r.get('dark_pct', 0):.2f}%" + ), + ( + f"EXP=" + f"{runtime.controls[role].get('exposure_time_us')}us " + f"ISO=" + f"{runtime.controls[role].get('sensitivity_iso')}" + ), ], x=10, y=22, - font_scale=0.42, - line_step=18, + font_scale=0.38, + line_step=17, ) - panels.append(panel) + panels[role] = panel data = np.zeros( (ph, pw, 3), @@ -2613,40 +3110,65 @@ def run_preflight( ) lines = [ - "FOCUS QC - PRE-FLIGHT", + "FOCUS QC - PRE-FLIGHT / CALIBRATION ROI", "", f"STATUS GLOBAL: {last_report['status'].upper()}", + f"CAMERA SELECTED: {selected_role.upper()}", "", - "Use chart DETALHADO em todo o campo.", - "Módulo perpendicular ao alvo.", - "Evite reflexos e saturação.", + "Mouse drag = define CALIBRATION ROI", + "1/2/3 = seleciona RGB/RE/NIR", + "R = ROI da camera selecionada -> FULL FRAME", + "", + "Preflight e FIELD QA usam somente esta ROI.", + "Escolha a area que realmente seguira para fusao.", "", "ENTER = congelar EXP/ISO", "Q/ESC = cancelar", ] - for reason in last_report["reasons"][:8]: - lines.append(f"! {reason}") + for reason in last_report[ + "reasons" + ][:8]: + lines.append( + f"! {reason}" + ) + + if ( + last_message + and time.time() - last_message_t < 3.0 + ): + lines.extend( + [ + "", + last_message, + ] + ) overlay_hud( data, lines, x=14, - y=30, - font_scale=0.45, - line_step=20, + y=26, + font_scale=0.39, + line_step=18, ) - board = np.vstack([ - np.hstack([ - panels[0], - panels[1], - ]), - np.hstack([ - panels[2], - data, - ]), - ]) + board = np.vstack( + [ + np.hstack( + [ + panels["rgb"], + panels["re"], + ] + ), + np.hstack( + [ + panels["nir"], + data, + ] + ), + ] + ) cv2.imshow( "Focus Calibration - Production", @@ -2655,13 +3177,58 @@ def run_preflight( k = cv2.waitKey(1) & 0xFF - if k in (ord("q"), ord("Q"), 27): + if k in ( + ord("q"), + ord("Q"), + 27, + ): raise KeyboardInterrupt( "Cancelado no preflight." ) - if k in (13, 10): - status = last_report["status"] + if k == ord("1"): + selected_role = "rgb" + message( + "Selecionada RGB" + ) + + elif k == ord("2"): + selected_role = "re" + message( + "Selecionada RE" + ) + + elif k == ord("3"): + selected_role = "nir" + message( + "Selecionada NIR" + ) + + elif k in ( + ord("r"), + ord("R"), + ): + img = runtime.frames[ + selected_role + ] + + calibration_roi_rects[ + selected_role + ] = full_roi_for_shape( + img.shape[:2] + ) + + message( + f"{selected_role.upper()} CALIBRATION ROI -> FULL FRAME" + ) + + elif k in ( + 13, + 10, + ): + status = last_report[ + "status" + ] allowed = ( status == "good" @@ -2675,7 +3242,7 @@ def run_preflight( if not allowed: print( f"[BLOCK] PREFLIGHT={status.upper()}. " - "Corrija alvo/luz antes de continuar." + "Corrija ROI/alvo/luz antes de continuar." ) continue @@ -2684,14 +3251,28 @@ def run_preflight( args, ) - runtime.send_manual(locked) + runtime.send_manual( + locked + ) runtime.verify_manual( locked, settle_frames=args.lock_settle_frames, ) - return last_report, locked + return ( + last_report, + locked, + { + role: tuple( + map( + int, + calibration_roi_rects[role], + ) + ) + for role in ROLES + }, + ) time.sleep(0.002) @@ -2772,7 +3353,10 @@ def build_base_report( args.equalize ), "target_requirement": ( - "flat_high_detail_target_across_entire_fov" + "flat_high_detail_target_across_selected_calibration_roi" + ), + "calibration_roi_policy": ( + "manual_per_role_canonical_display_space" ), }, "qa_thresholds": qa, @@ -2804,6 +3388,7 @@ def build_base_report( "candidate_dir": str(candidate_dir), "preflight": None, "locked_controls": None, + "calibration_roi_by_role": None, "results_by_role": {}, "snapshots": [], "promoted": False, @@ -3151,7 +3736,11 @@ def main(): # Preflight + lock # ------------------------------------------------ - preflight, locked = run_preflight( + ( + preflight, + locked, + calibration_roi_rects, + ) = run_preflight( runtime, specs, args, @@ -3160,6 +3749,16 @@ def main(): report["preflight"] = preflight + report["calibration_roi_by_role"] = { + role: list( + map( + int, + calibration_roi_rects[role], + ) + ) + for role in ROLES + } + report["locked_controls"] = { role: asdict(locked[role]) for role in ROLES @@ -3184,7 +3783,10 @@ def main(): } roi_rects = { - role: None + role: default_roi_inside_rect( + calibration_roi_rects[role], + frac=0.36, + ) for role in ROLES } @@ -3352,6 +3954,20 @@ def main(): img.shape[:2], ) + if r is not None: + r = intersect_rects( + r, + calibration_roi_rects[ + selected_role + ], + ) + + if r is not None: + r = sanitize_roi( + r, + img.shape[:2], + ) + dragging = False drag_start = None @@ -3382,25 +3998,17 @@ def main(): print("") print("[FOCUS] Ajuste RGB, depois RE e NIR.") + print( + "[FOCUS] FIELD QA e score respeitam a CALIBRATION ROI " + "definida no PRE-FLIGHT." + ) print("[FOCUS] atravesse o pico, volte ao melhor e pressione A.") while True: fresh_roles = runtime.poll() - # Inicializa ROIs quando os frames existem. - for role in ROLES: - img = runtime.frames[role] - - if ( - img is not None - and roi_rects[role] is None - ): - roi_rects[role] = ( - default_roi_for_shape( - img.shape[:2], - frac=0.36, - ) - ) + # FOCUS ROI já nasce centralizada DENTRO da CALIBRATION ROI. + # Nada aqui usa automaticamente o frame inteiro. # ------------------------------------------------ # SCORE SOMENTE EM FRAME NOVO @@ -3498,6 +4106,9 @@ def main(): img, qa, equalize=args.equalize_runtime, + calibration_roi=calibration_roi_rects[ + selected_role + ], ) ) @@ -3528,6 +4139,9 @@ def main(): img, qa, equalize=args.equalize_runtime, + calibration_roi=calibration_roi_rects[ + role + ], ) ) @@ -3614,6 +4228,7 @@ def main(): runtime, specs, selected_role, + calibration_roi_rects, roi_rects, state, field_qa_by_role, @@ -3745,8 +4360,10 @@ def main(): if img is not None: roi_rects[selected_role] = ( - default_roi_for_shape( - img.shape[:2], + default_roi_inside_rect( + calibration_roi_rects[ + selected_role + ], frac=0.36, ) ) @@ -3812,6 +4429,7 @@ def main(): state, field_qa_by_role, runtime, + calibration_roi_rects, roi_rects, args, qa, diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py index 544669127..0806699bd 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_2_intrinsics_calibration_tool.py @@ -33,17 +33,22 @@ IMPORTANTE: Fluxo: 1) Mostre o ChArUco nas 3 câmeras. 2) ENTER trava EXP/ISO. - 3) G captura views variadas. - 4) Varie X/Y, distância, yaw, pitch e roll. - 5) Ideal: 18-25 views. - 6) A calibra e executa acceptance. - 7) PASS promove o JSON ativo. + 3) R seleciona a ROI operacional da câmera escolhida (opcional). + 4) Enquadre o ChArUco no quadrilátero-guia. + 5) G captura e avança; F força captura fora da tolerância do guia. + 6) O roteiro varia X/Y, distância, yaw, pitch e roll. + 7) Ideal: completar as 25 poses guiadas. + 8) A calibra e executa acceptance. + 9) PASS promove o JSON ativo. Teclas: - G captura view + G captura view quando o guia estiver READY + F força captura válida fora da tolerância do guia A calibra/avalia V limpa views S snapshot + R seleciona ROI operacional da câmera escolhida + C restaura frame completo na câmera escolhida 1/2/3 seleciona câmera do preview U liga/desliga preview undistorted após avaliação Q/ESC cancela @@ -106,9 +111,9 @@ PREVIEW_ORIENTATION_BY_SENSOR = { QA = { "min_markers": 4, - "min_corners": 10, + "min_corners": 20, "min_views": 14, - "recommended_views": 20, + "recommended_views": 25, "min_retained_views": 12, "min_unique_ids": 24, @@ -168,11 +173,26 @@ QA = { "iso_tol": 5, } +GUIDE = { + "center_tolerance_norm": 0.085, + "area_ratio_min": 0.68, + "area_ratio_max": 1.48, + "quad_rms_tolerance_norm": 0.135, + "live_detection_period_s": 0.25, +} + def now_str(): return datetime.now().strftime("%Y-%m-%d %H:%M:%S") +def calc_log(message): + print( + f"[CALC {datetime.now().strftime('%H:%M:%S')}] {message}", + flush=True, + ) + + def stamp(): return datetime.now().strftime("%Y%m%d_%H%M%S_%f") @@ -269,6 +289,197 @@ def apply_preview_orientation(img: np.ndarray, cfg: dict) -> np.ndarray: return np.ascontiguousarray(img) +def oriented_size(shape_hw, cfg): + h, w = shape_hw + cfg = normalize_preview_orientation(cfg) + return (h, w) if cfg["rotate_deg"] in (90, 270) else (w, h) + + +def orient_points(points, shape_hw, cfg): + """Converte pontos SENSOR-NATIVE para o preview canônico.""" + pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy() + h, w = shape_hw + cfg = normalize_preview_orientation(cfg) + rot = cfg["rotate_deg"] + + x, y = pts[:, 0].copy(), pts[:, 1].copy() + if rot == 90: + pts[:, 0], pts[:, 1] = h - 1 - y, x + ow, oh = h, w + elif rot == 180: + pts[:, 0], pts[:, 1] = w - 1 - x, h - 1 - y + ow, oh = w, h + elif rot == 270: + pts[:, 0], pts[:, 1] = y, w - 1 - x + ow, oh = h, w + else: + ow, oh = w, h + + if cfg["flip_horizontal"]: + pts[:, 0] = ow - 1 - pts[:, 0] + if cfg["flip_vertical"]: + pts[:, 1] = oh - 1 - pts[:, 1] + return pts + + +def unorient_points(points, native_shape_hw, cfg): + """Converte pontos do preview canônico de volta ao SENSOR-NATIVE.""" + pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy() + h, w = native_shape_hw + cfg = normalize_preview_orientation(cfg) + ow, oh = oriented_size(native_shape_hw, cfg) + + if cfg["flip_horizontal"]: + pts[:, 0] = ow - 1 - pts[:, 0] + if cfg["flip_vertical"]: + pts[:, 1] = oh - 1 - pts[:, 1] + + x, y = pts[:, 0].copy(), pts[:, 1].copy() + rot = cfg["rotate_deg"] + if rot == 90: + pts[:, 0], pts[:, 1] = y, h - 1 - x + elif rot == 180: + pts[:, 0], pts[:, 1] = w - 1 - x, h - 1 - y + elif rot == 270: + pts[:, 0], pts[:, 1] = w - 1 - y, x + return pts + + +def full_roi(spec): + return {"x": 0, "y": 0, "width": int(spec.width), "height": int(spec.height)} + + +def normalize_roi(roi, image_size): + w, h = image_size + if roi is None: + return {"x": 0, "y": 0, "width": int(w), "height": int(h)} + x = max(0, min(int(roi["x"]), w - 1)) + y = max(0, min(int(roi["y"]), h - 1)) + rw = max(1, min(int(roi["width"]), w - x)) + rh = max(1, min(int(roi["height"]), h - y)) + if rw < max(80, int(0.15 * w)) or rh < max(80, int(0.15 * h)): + raise ValueError("ROI operacional pequena demais.") + return {"x": x, "y": y, "width": rw, "height": rh} + + +def roi_native_corners(roi): + x, y, w, h = roi["x"], roi["y"], roi["width"], roi["height"] + return np.asarray([ + [x, y], [x + w - 1, y], + [x + w - 1, y + h - 1], [x, y + h - 1], + ], dtype=np.float32) + + +def roi_in_preview(roi, shape_hw, orientation): + pts = orient_points(roi_native_corners(roi), shape_hw, orientation) + x0, y0 = pts.min(axis=0) + x1, y1 = pts.max(axis=0) + return {"x": float(x0), "y": float(y0), + "width": float(x1 - x0 + 1), "height": float(y1 - y0 + 1)} + + +def parse_roi_arg(text, spec): + if not text: + return full_roi(spec) + try: + values = [int(x.strip()) for x in str(text).split(",")] + if len(values) != 4: + raise ValueError + except ValueError as exc: + raise ValueError("ROI deve usar x,y,width,height") from exc + return normalize_roi( + {"x": values[0], "y": values[1], + "width": values[2], "height": values[3]}, + (spec.width, spec.height), + ) + + +def select_operational_roi(frame_native, role, orientation): + """Seleção visual canônica; retorno sempre em coordenadas nativas.""" + shown = apply_preview_orientation(frame_native, orientation) + oh, ow = shown.shape[:2] + scale = min(1.0, 1200.0 / ow, 760.0 / oh) + display = cv2.resize( + shown, (int(round(ow * scale)), int(round(oh * scale))), + interpolation=cv2.INTER_AREA, + ) + rect = cv2.selectROI( + f"ROI operacional {role.upper()} | ENTER confirma | C cancela", + display, showCrosshair=True, fromCenter=False, + ) + cv2.destroyWindow(f"ROI operacional {role.upper()} | ENTER confirma | C cancela") + x, y, rw, rh = rect + if rw <= 0 or rh <= 0: + return None + + x0, y0 = x / scale, y / scale + x1, y1 = (x + rw - 1) / scale, (y + rh - 1) / scale + canonical = np.asarray([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], + dtype=np.float32) + native = unorient_points(canonical, frame_native.shape[:2], orientation) + nx0, ny0 = np.floor(native.min(axis=0)).astype(int) + nx1, ny1 = np.ceil(native.max(axis=0)).astype(int) + return normalize_roi( + {"x": nx0, "y": ny0, "width": nx1 - nx0 + 1, "height": ny1 - ny0 + 1}, + (frame_native.shape[1], frame_native.shape[0]), + ) + + +def _guide_quad(cx, cy, width, height, roll=0.0, yaw=0.0, pitch=0.0): + """Quadrilátero normalizado TL,TR,BR,BL dentro da ROI canônica.""" + sx_top = 1.0 - pitch + sx_bottom = 1.0 + pitch + sy_left = 1.0 - yaw + sy_right = 1.0 + yaw + pts = np.asarray([ + [-0.5 * width * sx_top, -0.5 * height * sy_left], + [ 0.5 * width * sx_top, -0.5 * height * sy_right], + [ 0.5 * width * sx_bottom, 0.5 * height * sy_right], + [-0.5 * width * sx_bottom, 0.5 * height * sy_left], + ], dtype=np.float32) + a = math.radians(roll) + rot = np.asarray([[math.cos(a), -math.sin(a)], + [math.sin(a), math.cos(a)]], dtype=np.float32) + pts = pts @ rot.T + pts += np.asarray([cx, cy], dtype=np.float32) + return np.clip(pts, 0.035, 0.965).tolist() + + +def build_guide_targets(): + raw = [ + ("CENTRO LONGE", .50, .50, .30, .26, 0, 0, 0), + ("CENTRO MEDIO", .50, .50, .48, .41, 0, 0, 0), + ("CENTRO PERTO", .50, .50, .68, .58, 0, 0, 0), + ("ESQUERDA", .28, .50, .44, .38, 0, 0, 0), + ("DIREITA", .72, .50, .44, .38, 0, 0, 0), + ("SUPERIOR", .50, .28, .44, .38, 0, 0, 0), + ("INFERIOR", .50, .72, .44, .38, 0, 0, 0), + ("CANTO SUP ESQ", .28, .28, .40, .34, 0, 0, 0), + ("CANTO SUP DIR", .72, .28, .40, .34, 0, 0, 0), + ("CANTO INF ESQ", .28, .72, .40, .34, 0, 0, 0), + ("CANTO INF DIR", .72, .72, .40, .34, 0, 0, 0), + ("ESQ PERTO / INCLINE", .31, .50, .58, .49, 0, .22, 0), + ("DIR PERTO / INCLINE", .69, .50, .58, .49, 0, -.22, 0), + ("SUPERIOR PERTO", .50, .33, .57, .48, 0, 0, .18), + ("INFERIOR PERTO", .50, .67, .57, .48, 0, 0, -.18), + ("YAW ESQUERDA", .37, .48, .50, .42, 0, .28, 0), + ("YAW DIREITA", .63, .52, .50, .42, 0, -.28, 0), + ("YAW SUP ESQ", .34, .34, .45, .38, 0, -.24, 0), + ("YAW INF DIR", .66, .66, .45, .38, 0, .24, 0), + ("PITCH SUPERIOR", .48, .34, .50, .42, 0, 0, .24), + ("PITCH INFERIOR", .52, .66, .50, .42, 0, 0, -.24), + ("PITCH SUP DIR", .66, .35, .44, .37, 0, 0, -.22), + ("PITCH INF ESQ", .34, .65, .44, .37, 0, 0, .22), + ("DIAGONAL +", .43, .48, .52, .42, 14, .18, .14), + ("DIAGONAL -", .57, .52, .42, .35, -14, -.18, -.14), + ] + return [ + {"index": i, "name": x[0], + "quad_norm": _guide_quad(*x[1:])} + for i, x in enumerate(raw, start=1) + ] + + def resolve_preview_orientation(specs, args): """ Resolve orientação visual por sensor, com override opcional por role. @@ -889,17 +1100,109 @@ def detect_triplet(triplet, board, dictionary, params): # Pose/cobertura # ============================================================ -def pose_signature(points, shape_hw): +def order_quad(points): + pts = np.asarray(points, dtype=np.float32).reshape(4, 2) + s = pts.sum(axis=1) + d = np.diff(pts, axis=1).reshape(-1) + return np.asarray([ + pts[np.argmin(s)], pts[np.argmin(d)], + pts[np.argmax(s)], pts[np.argmax(d)], + ], dtype=np.float32) + + +def detected_board_quad(detection, board_pts): + points = detection.get("points", {}) + ids = sorted(int(x) for x in points.keys()) + if len(ids) < 4: + return None + src = np.asarray([board_pts[i][:2] for i in ids], dtype=np.float32) + dst = np.asarray([ + points[str(i)] if str(i) in points else points[i] for i in ids + ], dtype=np.float32) + H, _ = cv2.findHomography(src, dst, method=0) + if H is None: + return None + all_xy = np.asarray(board_pts, dtype=np.float32)[:, :2] + x0, y0 = all_xy.min(axis=0) + x1, y1 = all_xy.max(axis=0) + obj_quad = np.asarray( + [[[x0, y0]], [[x1, y0]], [[x1, y1]], [[x0, y1]]], + dtype=np.float32, + ) + projected = cv2.perspectiveTransform(obj_quad, H).reshape(4, 2) + return order_quad(projected) + + +def target_quad_preview(target, roi_preview): + q = np.asarray(target["quad_norm"], dtype=np.float32) + q[:, 0] = roi_preview["x"] + q[:, 0] * roi_preview["width"] + q[:, 1] = roi_preview["y"] + q[:, 1] * roi_preview["height"] + return order_quad(q) + + +def assess_guide(detection, board_pts, shape_hw, orientation, roi, target): + observed_native = detected_board_quad(detection, board_pts) + if observed_native is None: + return {"ready": False, "instruction": "MOSTRE O CHARUCO", + "observed_preview": None, "target_preview": None} + + observed = order_quad(orient_points(observed_native, shape_hw, orientation)) + rp = roi_in_preview(roi, shape_hw, orientation) + wanted = target_quad_preview(target, rp) + diag = max(math.hypot(rp["width"], rp["height"]), 1.0) + + oc = observed.mean(axis=0) + tc = wanted.mean(axis=0) + delta = (oc - tc) / np.asarray([rp["width"], rp["height"]]) + center_error = float(np.linalg.norm(delta)) + observed_area = abs(float(cv2.contourArea(observed))) + target_area = max(abs(float(cv2.contourArea(wanted))), 1.0) + area_ratio = observed_area / target_area + quad_rms = float(np.sqrt(np.mean(np.sum((observed - wanted) ** 2, axis=1))) / diag) + + ready = ( + center_error <= GUIDE["center_tolerance_norm"] + and GUIDE["area_ratio_min"] <= area_ratio <= GUIDE["area_ratio_max"] + and quad_rms <= GUIDE["quad_rms_tolerance_norm"] + ) + + if abs(delta[0]) > GUIDE["center_tolerance_norm"] * 0.70: + instruction = "MOVA PARA ESQUERDA" if delta[0] > 0 else "MOVA PARA DIREITA" + elif abs(delta[1]) > GUIDE["center_tolerance_norm"] * 0.70: + instruction = "MOVA PARA CIMA" if delta[1] > 0 else "MOVA PARA BAIXO" + elif area_ratio < GUIDE["area_ratio_min"]: + instruction = "APROXIME" + elif area_ratio > GUIDE["area_ratio_max"]: + instruction = "AFASTE" + elif quad_rms > GUIDE["quad_rms_tolerance_norm"]: + instruction = "AJUSTE ANGULO/PERSPECTIVA" + else: + instruction = "READY - PRESSIONE G" + + return { + "ready": bool(ready), + "instruction": instruction, + "center_error_norm": center_error, + "area_ratio": float(area_ratio), + "quad_rms_norm": quad_rms, + "observed_preview": observed.tolist(), + "target_preview": wanted.tolist(), + } + + +def pose_signature(points, shape_hw, roi=None): pts = np.asarray(list(points.values()), dtype=np.float32) h, w = shape_hw + roi = normalize_roi(roi, (w, h)) x0, x1 = float(pts[:, 0].min()), float(pts[:, 0].max()) y0, y1 = float(pts[:, 1].min()), float(pts[:, 1].max()) return { - "cx": ((x0 + x1) * 0.5) / w, - "cy": ((y0 + y1) * 0.5) / h, - "area": ((x1 - x0) * (y1 - y0)) / float(w * h), + "cx": (((x0 + x1) * 0.5) - roi["x"]) / roi["width"], + "cy": (((y0 + y1) * 0.5) - roi["y"]) / roi["height"], + "area": ((x1 - x0) * (y1 - y0)) / + float(roi["width"] * roi["height"]), } @@ -928,19 +1231,26 @@ def duplicate_pose(sig, views): return repeated == 3 -def coverage(points, image_size): +def coverage(points, image_size, roi=None): pts = np.asarray(points, dtype=np.float32) w, h = image_size + roi = normalize_roi(roi, image_size) if len(pts) < 3: - return {"span_x": 0.0, "span_y": 0.0, "hull": 0.0} + return {"span_x": 0.0, "span_y": 0.0, "hull": 0.0, + "operational_roi_native": roi} - hull = cv2.convexHull(pts.reshape(-1, 1, 2)) + clipped = pts.copy() + clipped[:, 0] = np.clip(clipped[:, 0], roi["x"], roi["x"] + roi["width"] - 1) + clipped[:, 1] = np.clip(clipped[:, 1], roi["y"], roi["y"] + roi["height"] - 1) + hull = cv2.convexHull(clipped.reshape(-1, 1, 2)) return { - "span_x": float((pts[:, 0].max() - pts[:, 0].min()) / w), - "span_y": float((pts[:, 1].max() - pts[:, 1].min()) / h), - "hull": float(cv2.contourArea(hull) / (w * h)), + "span_x": float((clipped[:, 0].max() - clipped[:, 0].min()) / roi["width"]), + "span_y": float((clipped[:, 1].max() - clipped[:, 1].min()) / roi["height"]), + "hull": float(cv2.contourArea(hull) / + (roi["width"] * roi["height"])), + "operational_roi_native": roi, } @@ -1107,6 +1417,11 @@ def iterative_reject(views, role, board_pts, image_size, model_name): rounds = [] for round_idx in range(QA["max_reject_rounds"] + 1): + calc_log( + f"{role.upper()}/{model_name}: ajuste robusto " + f"{round_idx + 1}/{QA['max_reject_rounds'] + 1} " + f"| views={len(retained)}" + ) ds = build_dataset(views, role, board_pts, retained) if len(ds["objects"]) < QA["min_retained_views"]: @@ -1156,7 +1471,15 @@ def leave_one_out(views, role, board_pts, image_size, model_name, retained): if len(retained) < 6: return {"valid": False, "views": [], "overall": {}} - for held in retained: + total = len(retained) + progress_step = max(1, math.ceil(total / 5)) + + for held_idx, held in enumerate(retained, start=1): + if held_idx == 1 or held_idx == total or held_idx % progress_step == 0: + calc_log( + f"{role.upper()}/{model_name}: validação cruzada " + f"{held_idx}/{total}" + ) train_ids = [x for x in retained if x != held] train = build_dataset(views, role, board_pts, train_ids) test = build_dataset(views, role, board_pts, [held]) @@ -1208,10 +1531,11 @@ def leave_one_out(views, role, board_pts, image_size, model_name, retained): } -def distortion_shift(K, D, image_size): +def distortion_shift(K, D, image_size, roi=None): w, h = image_size - xs = np.linspace(0, w - 1, 21) - ys = np.linspace(0, h - 1, 15) + roi = normalize_roi(roi, image_size) + xs = np.linspace(roi["x"], roi["x"] + roi["width"] - 1, 21) + ys = np.linspace(roi["y"], roi["y"] + roi["height"] - 1, 15) pts = np.asarray([[x, y] for y in ys for x in xs], dtype=np.float32).reshape(-1, 1, 2) @@ -1221,10 +1545,10 @@ def distortion_shift(K, D, image_size): shift = np.linalg.norm(und - orig, axis=1) edge = ( - (orig[:, 0] < 0.15 * w) - | (orig[:, 0] > 0.85 * w) - | (orig[:, 1] < 0.15 * h) - | (orig[:, 1] > 0.85 * h) + (orig[:, 0] < roi["x"] + 0.15 * roi["width"]) + | (orig[:, 0] > roi["x"] + 0.85 * roi["width"]) + | (orig[:, 1] < roi["y"] + 0.15 * roi["height"]) + | (orig[:, 1] > roi["y"] + 0.85 * roi["height"]) ) e = shift[edge] @@ -1236,6 +1560,7 @@ def distortion_shift(K, D, image_size): "edge_median_px": float(np.median(e)), "edge_p95_px": float(np.percentile(e, 95)), "edge_max_px": float(e.max()), + "operational_roi_native": roi, } @@ -1304,7 +1629,8 @@ def sanity(model, image_size): } -def evaluate_model(views, role, board_pts, image_size, model_name): +def evaluate_model(views, role, board_pts, image_size, model_name, + operational_roi=None): rej = iterative_reject( views, role, board_pts, image_size, model_name ) @@ -1330,11 +1656,12 @@ def evaluate_model(views, role, board_pts, image_size, model_name): for im in ds["images"]: all_points.extend(np.asarray(im).reshape(-1, 2).tolist()) - cov = coverage(all_points, image_size) + cov = coverage(all_points, image_size, operational_roi) retained_views = [v for v in views if int(v["view_id"]) in set(rej["retained"])] div = diversity(retained_views, role) sane = sanity(model, image_size) - shift = distortion_shift(model["K"], model["D"], image_size) + shift = distortion_shift(model["K"], model["D"], image_size, operational_roi) + shift_full_frame = distortion_shift(model["K"], model["D"], image_size) und = undistort_products(model["K"], model["D"], image_size) status = sane["status"] @@ -1420,6 +1747,8 @@ def evaluate_model(views, role, board_pts, image_size, model_name): "unique_charuco_ids": len(unique_ids), "sanity": sane, "distortion_shift": shift, + "distortion_shift_full_frame": shift_full_frame, + "operational_roi_native": normalize_roi(operational_roi, image_size), "undistort_products": und, } @@ -1457,24 +1786,55 @@ def choose_model(brown, rational, requested): } -def evaluate_all(views, board_pts, specs, requested): +def evaluate_all(views, board_pts, specs, requested, operational_rois=None): cameras = {} overall = "good" reasons = [] + operational_rois = operational_rois or { + role: full_roi(specs[role]) for role in ROLES + } - for role in ROLES: + for camera_idx, role in enumerate(ROLES, start=1): size = (specs[role].width, specs[role].height) + roi = normalize_roi(operational_rois.get(role), size) - brown = evaluate_model( - views, role, board_pts, size, "brown5" + calc_log( + f"Câmera {camera_idx}/{len(ROLES)}: {role.upper()} " + f"{size[0]}x{size[1]} | ROI=" + f"{roi['x']},{roi['y']},{roi['width']},{roi['height']}" ) + + started = time.perf_counter() + calc_log(f"{role.upper()}: iniciando modelo Brown5...") + brown = evaluate_model( + views, role, board_pts, size, "brown5", roi + ) + calc_log( + f"{role.upper()}: Brown5 concluído em " + f"{time.perf_counter() - started:.1f}s | " + f"status={brown['status'].upper()} | " + f"reasons={' | '.join(brown.get('reasons', [])) or 'nenhuma'}" + ) + + started = time.perf_counter() + calc_log(f"{role.upper()}: iniciando modelo Rational8...") rational = evaluate_model( - views, role, board_pts, size, "rational8" + views, role, board_pts, size, "rational8", roi + ) + calc_log( + f"{role.upper()}: Rational8 concluído em " + f"{time.perf_counter() - started:.1f}s | " + f"status={rational['status'].upper()} | " + f"reasons={' | '.join(rational.get('reasons', [])) or 'nenhuma'}" ) selected, selection = choose_model( brown, rational, requested ) + calc_log( + f"{role.upper()}: selecionado {selection['selected']} " + f"| motivo={selection['reason']}" + ) edge = selected.get("distortion_shift", {}).get("edge_p95_px", 0.0) @@ -1519,6 +1879,9 @@ def module_fragment(evaluation, specs): "sensor": specs[role].sensor, "stream_source": specs[role].source, "image_size": [specs[role].width, specs[role].height], + "operational_roi_native": m.get( + "operational_roi_native", full_roi(specs[role]) + ), "distortion_model": cam["model_selection"]["selected"], "camera_matrix": m["camera_matrix"], "dist_coeffs": m["dist_coeffs"], @@ -1532,6 +1895,10 @@ def module_fragment(evaluation, specs): "intrinsics_config": { "enabled": True, "calibration_space": CALIBRATION_SPACE, + "operational_roi_contract": ( + "ROI afeta somente guia e acceptance. K/D e corners " + "permanecem no raster nativo completo." + ), "cameras": cameras, "runtime_undistort": { "enabled": False, @@ -1648,6 +2015,10 @@ def build_ui( show_undist, args, preview_orientation, + operational_rois=None, + guide_role="rgb", + guide_target=None, + guide_assessment=None, msg="", ): """ @@ -1662,6 +2033,9 @@ def build_ui( "re": (0, 255, 255), "nir": (255, 255, 0), } + operational_rois = operational_rois or { + role: full_roi(specs[role]) for role in ROLES + } for role in ROLES: frame_native = runtime.frames[role] @@ -1706,6 +2080,39 @@ def build_ui( preview_orientation[role], ) + # ROI é exibida na orientação canônica, mas armazenada em SENSOR-NATIVE. + rp = roi_in_preview( + operational_rois[role], frame_native.shape[:2], + preview_orientation[role], + ) + roi_poly = np.asarray([ + [rp["x"], rp["y"]], + [rp["x"] + rp["width"] - 1, rp["y"]], + [rp["x"] + rp["width"] - 1, rp["y"] + rp["height"] - 1], + [rp["x"], rp["y"] + rp["height"] - 1], + ], dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines(bgr, [roi_poly], True, (255, 180, 0), 4, cv2.LINE_AA) + + if role == guide_role and guide_target is not None and not showing_undist: + target = target_quad_preview(guide_target, rp) + cv2.polylines( + bgr, [np.rint(target).astype(np.int32).reshape(-1, 1, 2)], + True, (0, 220, 255), 6, cv2.LINE_AA, + ) + if guide_assessment and guide_assessment.get("observed_preview") is not None: + observed = np.asarray( + guide_assessment["observed_preview"], dtype=np.float32 + ) + guide_color = ( + (0, 255, 0) if guide_assessment.get("ready") + else (0, 0, 255) + ) + cv2.polylines( + bgr, + [np.rint(observed).astype(np.int32).reshape(-1, 1, 2)], + True, guide_color, 6, cv2.LINE_AA, + ) + p = cv2.resize( bgr, (pw, ph), @@ -1734,6 +2141,12 @@ def build_ui( f"EXP={runtime.ctrl[role].get('exposure_time_us')}us " f"ISO={runtime.ctrl[role].get('sensitivity_iso')}" ), + ( + f"ROI={operational_rois[role]['x']}," + f"{operational_rois[role]['y']}," + f"{operational_rois[role]['width']}," + f"{operational_rois[role]['height']}" + ), ], x=8, y=18, @@ -1752,12 +2165,30 @@ def build_ui( 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", - "1/2/3 select | U undist preview", - "Q/ESC cancel", + "G guided capture | F force valid", + "A calibrate | V clear | S snapshot", + "R select ROI | C full frame | 1/2/3", + "U undist preview | Q/ESC cancel", ] + if guide_target is not None: + instruction = ( + guide_assessment.get("instruction", "PROCURANDO CHARUCO") + if guide_assessment else "PROCURANDO CHARUCO" + ) + lines += [ + "", + f"GUIDE {guide_target['index']:02d}/25 [{guide_role.upper()}]", + guide_target["name"], + instruction, + ] + if guide_assessment and guide_assessment.get("center_error_norm") is not None: + lines.append( + f"center={guide_assessment['center_error_norm']:.3f} " + f"area={guide_assessment['area_ratio']:.2f} " + f"quad={guide_assessment['quad_rms_norm']:.3f}" + ) + if evaluation is not None: lines += ["", f"EVAL={evaluation['status'].upper()}"] @@ -1827,6 +2258,23 @@ def main(): ap.add_argument("--panel-height", type=int, default=400) ap.add_argument("--preview-alpha", type=float, default=0.0, choices=[0.0, 0.5, 1.0]) + ap.add_argument( + "--guide-role", default="rgb", choices=list(ROLES), + help="Câmera usada para o enquadramento visual das poses guiadas.", + ) + ap.add_argument( + "--no-guide", action="store_true", + help="Desativa o roteiro guiado; mantém captura livre por G.", + ) + for role in ROLES: + ap.add_argument( + f"--{role}-operational-roi", default="", + metavar="X,Y,W,H", + help=( + "ROI operacional em coordenadas SENSOR-NATIVE. " + "Vazio usa o frame completo; também pode ser selecionada com R." + ), + ) ap.add_argument( "--candidate-root", @@ -1902,6 +2350,13 @@ def main(): rows, actual_mx, usb = discover(args.mx_id) specs = validate_specs(rows) preview_orientation = resolve_preview_orientation(specs, args) + operational_rois = { + role: parse_roi_arg( + getattr(args, f"{role}_operational_roi"), specs[role] + ) + for role in ROLES + } + guide_targets = [] if args.no_guide else build_guide_targets() pipeline, isp = build_pipeline( specs, @@ -1949,6 +2404,13 @@ def main(): "usb_speed": usb, "hardware_signature": hardware_signature(specs), "calibration_space": CALIBRATION_SPACE, + "operational_rois_native": operational_rois, + "guided_capture": { + "enabled": not args.no_guide, + "guide_role": args.guide_role, + "thresholds": GUIDE, + "targets": guide_targets, + }, "geometry_contract": { "intrinsics_measurement_space": CALIBRATION_SPACE, "measurement_orientation": "sensor_native", @@ -2096,6 +2558,9 @@ def main(): show_undist = False message = "" message_t = 0.0 + live_det = None + guide_assessment = None + last_live_detection_t = 0.0 print( f"[CAPTURE] mínimo={QA['min_views']}, ideal={QA['recommended_views']}. " @@ -2105,13 +2570,46 @@ def main(): while True: runtime.poll() + guide_target = ( + guide_targets[min(len(views), len(guide_targets) - 1)] + if guide_targets and len(views) < len(guide_targets) + else None + ) + + if ( + guide_target is not None + and time.time() - last_live_detection_t + >= GUIDE["live_detection_period_s"] + ): + try: + live_det = detect_triplet( + {"frames": runtime.frames}, board, dictionary, params + ) + guide_assessment = assess_guide( + live_det["detections"][args.guide_role], + board_pts, + runtime.frames[args.guide_role].shape[:2], + preview_orientation[args.guide_role], + operational_rois[args.guide_role], + guide_target, + ) + except Exception: + live_det = None + guide_assessment = None + last_live_detection_t = time.time() + msg = message if ( message and time.time() - message_t < 4.0 ) else "" ui = build_ui( - runtime, specs, views, last_det, evaluation, - selected_role, show_undist, args, preview_orientation, msg + runtime, specs, views, live_det or last_det, evaluation, + selected_role, show_undist, args, preview_orientation, + operational_rois=operational_rois, + guide_role=args.guide_role, + guide_target=guide_target, + guide_assessment=guide_assessment, + msg=msg, ) cv2.imshow(window, ui) @@ -2151,8 +2649,47 @@ def main(): message = f"Snapshot={snap.name}" message_t = time.time() - elif k in (ord("g"), ord("G")): + elif k in (ord("r"), ord("R")): + if views: + message = ( + "ROI bloqueada após capturas. Use V antes de alterar." + ) + message_t = time.time() + continue + chosen = select_operational_roi( + runtime.frames[selected_role], selected_role, + preview_orientation[selected_role], + ) + if chosen is not None: + operational_rois[selected_role] = chosen + report["operational_rois_native"] = operational_rois + save_json_atomic(report_path, report) + message = ( + f"ROI {selected_role.upper()}=" + f"{chosen['x']},{chosen['y']}," + f"{chosen['width']},{chosen['height']}" + ) + else: + message = "Seleção de ROI cancelada." + message_t = time.time() + + elif k in (ord("c"), ord("C")): + if views: + message = ( + "ROI bloqueada após capturas. Use V antes de alterar." + ) + else: + operational_rois[selected_role] = full_roi( + specs[selected_role] + ) + report["operational_rois_native"] = operational_rois + save_json_atomic(report_path, report) + message = f"ROI {selected_role.upper()} = frame completo." + message_t = time.time() + + elif k in (ord("g"), ord("G"), ord("f"), ord("F")): try: + forced = k in (ord("f"), ord("F")) trip = runtime.fresh_triplet() det = detect_triplet( trip, board, dictionary, params @@ -2164,10 +2701,34 @@ def main(): message_t = time.time() continue + current_target = ( + guide_targets[len(views)] + if guide_targets and len(views) < len(guide_targets) + else None + ) + capture_assessment = None + if current_target is not None: + capture_assessment = assess_guide( + det["detections"][args.guide_role], + board_pts, + trip["frames"][args.guide_role].shape[:2], + preview_orientation[args.guide_role], + operational_rois[args.guide_role], + current_target, + ) + if not forced and not capture_assessment["ready"]: + message = ( + "GUIA: " + capture_assessment["instruction"] + + " | F força captura válida" + ) + message_t = time.time() + continue + pose = { role: pose_signature( det["detections"][role]["points"], trip["frames"][role].shape[:2], + operational_rois[role], ) for role in ROLES } @@ -2189,6 +2750,15 @@ def main(): "controls": trip["controls"], "measurement_space": CALIBRATION_SPACE, "measurement_orientation": "sensor_native", + "operational_rois_native": { + role: dict(operational_rois[role]) for role in ROLES + }, + "guided_capture": { + "forced": forced, + "guide_role": args.guide_role, + "target": current_target, + "assessment": capture_assessment, + }, "preview_orientation_by_role": { role: dict(preview_orientation[role]) for role in ROLES @@ -2225,6 +2795,13 @@ def main(): message_t = time.time() print("[VIEW]", message) + if guide_targets and len(views) == len(guide_targets): + message = ( + f"ROTEIRO COMPLETO: {len(views)} views. " + "Pressione A para calibrar." + ) + message_t = time.time() + except Exception as exc: message = f"Captura rejeitada: {exc}" message_t = time.time() @@ -2236,14 +2813,35 @@ def main(): ) message_t = time.time() continue + if guide_targets and len(views) < len(guide_targets): + message = ( + f"Complete o guia: {len(views)}/{len(guide_targets)} views." + ) + message_t = time.time() + continue + calc_started = time.perf_counter() + calc_log( + f"Iniciando avaliação de {len(views)} views. " + "Serão avaliadas 3 câmeras e 2 modelos por câmera." + ) evaluation = evaluate_all( - views, board_pts, specs, args.distortion_model + views, board_pts, specs, args.distortion_model, + operational_rois, + ) + calc_log( + f"Avaliação concluída em " + f"{time.perf_counter() - calc_started:.1f} segundos." ) report["evaluation"] = evaluation - report["module_params_fragment"] = module_fragment( - evaluation, specs + complete_models = all( + "camera_matrix" in evaluation["cameras"][role]["selected_model"] + for role in ROLES + ) + report["module_params_fragment"] = ( + module_fragment(evaluation, specs) + if complete_models else None ) save_json_atomic(report_path, report) diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py index ecaeba364..134d8fc6e 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_3_flatfield_calibration_tool.py @@ -197,6 +197,7 @@ PREVIEW_ORIENTATION_BY_SENSOR = { RAW10_MAX = 1023.0 RAW10_WHITE_SAT = 1018 RAW10_DARK_FLOOR = 16 +COMPARE_EPS = 1e-6 SCHEMA = "multispec_flatfield_production_v2" @@ -1425,11 +1426,24 @@ class AcquisitionContext: spec = self.specs[role] # Produto: RAW10 obrigatório para estes três sensores. - raw_type = str(pkt.getType()).upper() + # RAW10 MIPI pode aparecer como RAW10 ou PACK10, + # dependendo da versão do DepthAI/firmware. + frame_type = pkt.getType() + raw_type = str(frame_type).upper() - if "RAW10" not in raw_type: + accepted_types = { + value + for value in ( + getattr(dai.ImgFrame.Type, "RAW10", None), + getattr(dai.ImgFrame.Type, "PACK10", None), + ) + if value is not None + } + + if frame_type not in accepted_types: raise RuntimeError( - f"{role.upper()}/{spec.sensor_name}: esperado RAW10, recebido {pkt.getType()}" + f"{role.upper()}/{spec.sensor_name}: esperado RAW10/PACK10, " + f"recebido {frame_type}" ) width = int(pkt.getWidth()) @@ -2340,24 +2354,24 @@ def evaluate_gain_channel(gain: np.ndarray, th: dict) -> dict: status = "good" reasons = [] - if st["p99"] > th["gain_p99_bad"]: + if st["p99"] > th["gain_p99_bad"] + COMPARE_EPS: status = merge_status(status, "bad") reasons.append(f"gain_p99_bad:{st['p99']:.3f}") - elif st["p99"] > th["gain_p99_warning"]: + elif st["p99"] > th["gain_p99_warning"] + COMPARE_EPS: status = merge_status(status, "warning") reasons.append(f"gain_p99_warn:{st['p99']:.3f}") - if st["max"] > th["gain_max_bad"]: + if st["max"] > th["gain_max_bad"] + COMPARE_EPS: status = merge_status(status, "bad") reasons.append(f"gain_max_bad:{st['max']:.3f}") - elif st["max"] > th["gain_max_warning"]: + elif st["max"] > th["gain_max_warning"] + COMPARE_EPS: status = merge_status(status, "warning") reasons.append(f"gain_max_warn:{st['max']:.3f}") - if st["min"] < th["gain_min_bad"]: + if st["min"] < th["gain_min_bad"] - COMPARE_EPS: status = merge_status(status, "bad") reasons.append(f"gain_min_bad:{st['min']:.3f}") - elif st["min"] < th["gain_min_warning"]: + elif st["min"] < th["gain_min_warning"] - COMPARE_EPS: status = merge_status(status, "warning") reasons.append(f"gain_min_warn:{st['min']:.3f}") diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py index 98a396e81..d6c33059e 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_4_radiometric_calibration_tool.py @@ -236,9 +236,9 @@ PREVIEW_ORIENTATION_BY_SENSOR = { }, } -SCHEMA = "multispec_radiometric_calibration_v5" +SCHEMA = "multispec_radiometric_calibration_v6" CALIBRATION_DOMAIN = "native_sensor_raw_linear" -NORMALIZATION_METHOD = "oak_ae_frame_controls_v1" +NORMALIZATION_METHOD = "oak_ae_frame_controls_affine_v2" FACTOR_MODEL = "exposure_time_us_x_iso" ISO_BASE = 100.0 @@ -270,8 +270,8 @@ DEFAULT_QA = { # Regressão "r2_warning": 0.995, "r2_bad": 0.985, - "intercept_fraction_warning": 0.035, - "intercept_fraction_bad": 0.080, + "black_offset_warning": 0.10, + "black_offset_bad": 0.20, # Validação do modelo de normalização "norm_median_rel_error_warning": 0.030, @@ -302,14 +302,15 @@ DEFAULT_QA = { DEFAULT_SWEEP_FACTORS = ( 0.30, - 0.42, - 0.55, - 0.70, - 0.85, - 1.00, - 1.18, - 1.38, - 1.58, + 0.45, + 0.65, + 0.90, + 1.20, + 1.60, + 2.10, + 2.80, + 3.60, + 4.20, ) @@ -1083,7 +1084,10 @@ def decode_raw_packet( packet.getType() ).upper() - if "RAW10" in raw_type: + if ( + "RAW10" in raw_type + or "PACK10" in raw_type + ): raw = unpack_raw10( packet.getData(), width, @@ -2282,6 +2286,18 @@ class RadiometricRuntime: role ] ), + + "requested_controls": { + "exposure_time_us": int( + controls_by_role[role].exposure_time_us + ), + "sensitivity_iso": int( + controls_by_role[role].sensitivity_iso + ), + }, + "last_actual_controls": dict( + self.controls[role] + ), } return out @@ -3247,10 +3263,18 @@ def analyze_role_sweep( ) ) - # Testa exatamente o modelo utilizado no runtime: - # normalized = measured * reference_factor / actual_factor + # Normalização considerando o pedestal preto do sensor: + # + # normalized = + # intercept + # + (measured - intercept) + # * reference_factor + # / actual_factor normalized_values = ( - y + intercept + + ( + y - intercept + ) * reference_factor / x ) @@ -3321,36 +3345,18 @@ def analyze_role_sweep( f"r2_warn:{r2:.6f}" ) - if ( - intercept_fraction - > qa[ - "intercept_fraction_bad" - ] - ): - status = merge_status( - status, - "bad", - ) + black_offset = abs(intercept) + if black_offset > qa["black_offset_bad"]: + status = merge_status(status, "bad") reasons.append( - "intercept_fraction_bad:" - f"{intercept_fraction:.4f}" - ) - - elif ( - intercept_fraction - > qa[ - "intercept_fraction_warning" - ] - ): - status = merge_status( - status, - "warning", + f"black_offset_bad:{black_offset:.4f}" ) + elif black_offset > qa["black_offset_warning"]: + status = merge_status(status, "warning") reasons.append( - "intercept_fraction_warn:" - f"{intercept_fraction:.4f}" + f"black_offset_warn:{black_offset:.4f}" ) rel_med = float( @@ -4102,6 +4108,13 @@ def build_normalization_fragment( ), } + black_offset_by_role = { + role: float( + role_analyses[role]["fit"]["intercept"] + ) + for role in ROLES + } + return { "radiometric_normalization": { "enabled": True, @@ -4132,6 +4145,12 @@ def build_normalization_fragment( ), "clip_output": False, "save_debug": True, + "black_offset_model": "per_role_scalar_raw01", + "black_offset_by_role": black_offset_by_role, + "formula": ( + "offset + (value - offset) * " + "reference_factor / actual_factor" + ), } } @@ -5230,47 +5249,64 @@ def main(): role: LockedControl( role=role, exposure_time_us=int( - role_analyses[ - role - ][ - "reference" - ][ - "exposure_time_us" - ] + role_analyses[role]["reference"]["exposure_time_us"] ), sensitivity_iso=int( - role_analyses[ - role - ][ - "reference" - ][ - "sensitivity_iso" - ] - ), - source=( - "radiometric_reference_validation" + role_analyses[role]["reference"]["sensitivity_iso"] ), + source="radiometric_reference_validation", ) for role in ROLES } - runtime.send_controls( - reference_controls - ) + # Validação independente por câmera. + # Para RE/NIR, coloca as duas OV9282 no mesmo controle durante + # a validação, evitando interferência ou acoplamento de exposição. + validation_stats = {} - runtime.verify_controls( - reference_controls, - settle_frames=( - args.settle_frames - ), - ) + for validation_role in ROLES: + validation_controls = dict(reference_controls) - validation_stats = ( - runtime.capture_level( - args.validation_frames, - reference_controls, + if validation_role in ("re", "nir"): + selected = reference_controls[validation_role] + + for mono_role in ("re", "nir"): + validation_controls[mono_role] = LockedControl( + role=mono_role, + exposure_time_us=selected.exposure_time_us, + sensitivity_iso=selected.sensitivity_iso, + source=( + "radiometric_reference_validation_" + f"{validation_role}" + ), + ) + + print( + f"[VALIDATION] {validation_role.upper()} | " + f"EXP={validation_controls[validation_role].exposure_time_us}us " + f"ISO={validation_controls[validation_role].sensitivity_iso}" ) - ) + + # Reenvia para robustez. + for _ in range(3): + runtime.send_controls(validation_controls) + time.sleep(0.05) + + runtime.verify_controls( + validation_controls, + settle_frames=max( + int(args.settle_frames), + 15, + ), + ) + + captured = runtime.capture_level( + args.validation_frames, + validation_controls, + ) + + # Guarda somente a câmera que está sendo validada nesta rodada. + validation_stats[validation_role] = captured[validation_role] validation_report = {} diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py b/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py index b94d92d5f..5e591f63a 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_5_camera_startup_profile.py @@ -432,10 +432,10 @@ def validate_radiometry_artifact( ): schema = str(data.get("schema", "")) - if schema != "multispec_radiometric_calibration_v5": + if schema != "multispec_radiometric_calibration_v6": raise RuntimeError( f"Schema radiométrico inesperado: {schema!r}. " - "Esperado 'multispec_radiometric_calibration_v5'." + "Esperado 'multispec_radiometric_calibration_v6'." ) status = str(data.get("status", "")).lower() diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py index 2cdfe87e4..9f2bca06c 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_6_homography_calibration_tool.py @@ -302,7 +302,7 @@ DEFAULT_QA = { # Overlap útil das imagens "overlap_warning": 0.80, - "overlap_bad": 0.70, + "overlap_bad": 0.65, # Sanidade matricial "condition_number_warning": 1.0e5, diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py b/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py index 156eb7ca5..badbda043 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py +++ b/Python/OAK/datasets/oak-fcc-3/calibration/_8_build_module_params.py @@ -158,7 +158,7 @@ ASSEMBLER_SCHEMA = "multispec_module_params_assembly_v1" EXPECTED_SCHEMAS = { "focus": "multispec_focus_qc_v3", "flatfield": "multispec_flatfield_production_v2", - "radiometry": "multispec_radiometric_calibration_v5", + "radiometry": "multispec_radiometric_calibration_v6", "startup": "multispec_camera_startup_profile_v3", "intrinsics": "multispec_intrinsics_calibration_v1", "homography": "multispec_homography_calibration_v4", @@ -478,13 +478,23 @@ def normalize_role_item( ) if size is None: - width = item.get( - "width" - ) + # Formato padrão. + width = item.get("width") + height = item.get("height") - height = item.get( - "height" - ) + # Formato emitido pelo Focus Calibration. + if width is None: + width = item.get("configured_width") + + if height is None: + height = item.get("configured_height") + + # Último fallback: dimensão anunciada pelo hardware. + if width is None: + width = item.get("feature_width") + + if height is None: + height = item.get("feature_height") if ( width is not None @@ -1111,16 +1121,46 @@ def validate_radiometry( "Radiometric normalization homologada, mas disabled." ) - if ( - cfg.get( - "method" - ) - != "oak_ae_frame_controls_v1" - ): + method = str( + cfg.get("method") or "" + ) + + allowed_methods = { + "oak_ae_frame_controls_v1", + "oak_ae_frame_controls_affine_v2", + } + + if method not in allowed_methods: raise RuntimeError( - f"Método radiométrico inesperado: {cfg.get('method')}" + f"Método radiométrico inesperado: {method}" ) + # O affine_v2 exige um offset preto para cada câmera. + if method == "oak_ae_frame_controls_affine_v2": + offsets = ( + cfg.get("black_offset_by_role", {}) + or {} + ) + + for role in ROLES: + if role not in offsets: + raise RuntimeError( + f"Radiometry affine_v2 sem black_offset para {role}." + ) + + try: + offset = float(offsets[role]) + except Exception as exc: + raise RuntimeError( + f"black_offset inválido para {role}: " + f"{offsets.get(role)!r}" + ) from exc + + if not (0.0 <= offset < 0.50): + raise RuntimeError( + f"black_offset implausível para {role}: {offset}" + ) + if ( cfg.get( "factor_model" diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/flatfield_maps_v1.json b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/flatfield_maps_v1.json new file mode 100644 index 000000000..3af547522 --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/flatfield_maps_v1.json @@ -0,0 +1,1084 @@ +{ + "schema": "multispec_flatfield_production_v2", + "session_id": "20260908_163058", + "created_at": "2026-09-08 16:31:07", + "status": "warning", + "promoted": true, + "depthai_version": "2.30.0.0", + "device": { + "mx_id": "194430108133AC2F00", + "usb_speed": "UsbSpeed.SUPER" + }, + "hardware_inventory": [ + { + "socket_name": "CAM_A", + "sensor_name": "AR0234", + "width": 1920, + "height": 1200, + "supported_types": [ + "CameraSensorType.COLOR", + "CameraSensorType.MONO" + ], + "has_autofocus_ic": 0 + }, + { + "socket_name": "CAM_B", + "sensor_name": "OV9282", + "width": 1280, + "height": 800, + "supported_types": [ + "CameraSensorType.MONO" + ], + "has_autofocus_ic": 0 + }, + { + "socket_name": "CAM_C", + "sensor_name": "OV9282", + "width": 1280, + "height": 800, + "supported_types": [ + "CameraSensorType.MONO" + ], + "has_autofocus_ic": 0 + } + ], + "resolved_setup": { + "rgb": { + "role": "rgb", + "socket_name": "CAM_A", + "sensor_name": "AR0234", + "width": 1920, + "height": 1200, + "resolution_enum": "THE_1200_P", + "stream_name": "raw_rgb", + "control_name": "ctrl_rgb", + "is_color": true, + "bayer_pattern": "GRBG" + }, + "re": { + "role": "re", + "socket_name": "CAM_B", + "sensor_name": "OV9282", + "width": 1280, + "height": 800, + "resolution_enum": "THE_800_P", + "stream_name": "raw_re", + "control_name": "ctrl_re", + "is_color": false, + "bayer_pattern": null + }, + "nir": { + "role": "nir", + "socket_name": "CAM_C", + "sensor_name": "OV9282", + "width": 1280, + "height": 800, + "resolution_enum": "THE_800_P", + "stream_name": "raw_nir", + "control_name": "ctrl_nir", + "is_color": false, + "bayer_pattern": null + } + }, + "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": { + "rgb": { + "sensor": "AR0234", + "rotate_deg": 180, + "flip_horizontal": false, + "flip_vertical": false, + "source": "sensor_default" + }, + "re": { + "sensor": "OV9282", + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "source": "sensor_default" + }, + "nir": { + "sensor": "OV9282", + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "source": "sensor_default" + } + }, + "runtime_order": "decode -> radiometric_normalization -> flat_native -> camera_orientation -> homography", + "scientific_orientation_effect": "none" + }, + "module_params_path": "calibration/module_params.json", + "rgb_processing": { + "mode": "linear_demosaic", + "demosaic_algorithm": "ea", + "enhancement_forced_off": true, + "flatfield_forced_off": true, + "radiometric_normalization_forced_off": true, + "camera_orientation_forced_off_for_measurement": true + }, + "capture": { + "fps": 20.0, + "anti_banding": "OFF", + "frames": 60, + "dark_frames": 60, + "validation_frames": 30, + "discard_frames": 12 + }, + "policy": { + "skip_dark": false, + "allow_no_dark_promote": false, + "allow_warning_preflight": false, + "force_preflight": false, + "promote_warning": true, + "no_promote": false + }, + "thresholds": { + "white_sat_warning_pct": 0.1, + "white_sat_bad_pct": 0.5, + "white_dark_warning_pct": 1.0, + "white_dark_bad_pct": 4.0, + "temporal_cv_warning": 0.005, + "temporal_cv_bad": 0.015, + "frame_global_deviation_reject": 0.05, + "dark_p99_warning": 0.08, + "dark_p99_bad": 0.16, + "gain_p99_warning": 1.75, + "gain_p99_bad": 2.2, + "gain_max_warning": 2.2, + "gain_max_bad": 3.0, + "gain_min_warning": 0.55, + "gain_min_bad": 0.35, + "local_dev_p99_warning": 0.12, + "local_dev_p99_bad": 0.2, + "local_outlier_pct_warning": 1.0, + "local_outlier_pct_bad": 3.0, + "corrected_grid_cv_warning": 0.055, + "corrected_grid_cv_bad": 0.085, + "min_before_grid_cv_for_improvement": 0.025, + "improvement_warning": 0.25, + "improvement_bad": 0.1, + "worsening_bad": -0.05, + "exposure_rel_tolerance": 0.01, + "exposure_abs_tolerance_us": 20, + "iso_tolerance": 5 + }, + "notes": "", + "stages": { + "white_A": { + "rgb": { + "accepted": 60, + "rejected": 0, + "global_median_mean": 216.43333333333334, + "global_median_std": 1.2957194483717873, + "global_median_cv": 0.005986690813361099 + }, + "re": { + "accepted": 60, + "rejected": 0, + "global_median_mean": 200.36666666666667, + "global_median_std": 1.015983376941878, + "global_median_cv": 0.005070620746673821 + }, + "nir": { + "accepted": 60, + "rejected": 0, + "global_median_mean": 150.81666666666666, + "global_median_std": 0.6706133179576903, + "global_median_cv": 0.004446546477783337 + } + }, + "dark": { + "rgb": { + "accepted": 60, + "rejected": 0, + "global_median_mean": 42.0, + "global_median_std": 0.0, + "global_median_cv": 0.0 + }, + "re": { + "accepted": 60, + "rejected": 0, + "global_median_mean": 64.0, + "global_median_std": 0.0, + "global_median_cv": 0.0 + }, + "nir": { + "accepted": 60, + "rejected": 0, + "global_median_mean": 63.0, + "global_median_std": 0.0, + "global_median_cv": 0.0 + } + }, + "white_B_validation": { + "rgb": { + "accepted": 30, + "rejected": 0, + "global_median_mean": 215.8, + "global_median_std": 0.8717797887081348, + "global_median_cv": 0.004039758057034915 + }, + "re": { + "accepted": 30, + "rejected": 0, + "global_median_mean": 199.7, + "global_median_std": 0.6403124237432849, + "global_median_cv": 0.003206371676230771 + }, + "nir": { + "accepted": 30, + "rejected": 0, + "global_median_mean": 150.83333333333334, + "global_median_std": 0.372677996249965, + "global_median_cv": 0.0024707933453036353 + } + } + }, + "outputs": { + "candidate_dir": "calibration\\flatfield_candidates\\20260908_163058", + "candidate_npz": "calibration\\flatfield_candidates\\20260908_163058\\flatfield_maps.npz", + "candidate_report": "calibration\\flatfield_candidates\\20260908_163058\\report.json", + "active_npz": "calibration/flatfield_maps_v1.npz", + "active_json": "calibration/flatfield_maps_v1.json" + }, + "suggested_module_params_patch": { + "flatfield_config": { + "enabled": true, + "npz_file": "calibration/flatfield_maps_v1.npz", + "apply_before_fusion": true, + "apply_after_decode": true, + "apply_space": "native_camera_space", + "map_type": "gain", + "channels": [ + "R", + "G", + "B", + "RE", + "NIR" + ], + "channel_maps": { + "R": "gain_R", + "G": "gain_G", + "B": "gain_B", + "RE": "gain_RE", + "NIR": "gain_NIR" + }, + "subtract_dark": false, + "clip_output": true + } + }, + "white_preflight": { + "status": "good", + "roles": { + "rgb": { + "status": "good", + "stats": { + "mean": 0.20811301469802856, + "std": 0.057767316699028015, + "p01": 0.09384164214134216, + "p05": 0.11534702032804489, + "p50": 0.20918865501880646, + "p95": 0.3049853444099426, + "p99": 0.32160311937332153, + "sat_pct": 0.0, + "dark_pct": 0.0 + }, + "temporal_cv": 0.0017841213202497775, + "reasons": [] + }, + "re": { + "status": "good", + "stats": { + "mean": 0.19895704090595245, + "std": 0.019549375399947166, + "p01": 0.16226783394813538, + "p05": 0.17106549441814423, + "p50": 0.19648094475269318, + "p95": 0.2346041053533554, + "p99": 0.24340176582336426, + "sat_pct": 0.0, + "dark_pct": 0.0 + }, + "temporal_cv": 0.0, + "reasons": [] + }, + "nir": { + "status": "good", + "stats": { + "mean": 0.14913727343082428, + "std": 0.013011377304792404, + "p01": 0.12414467334747314, + "p05": 0.12805473804473877, + "p50": 0.1485826075077057, + "p95": 0.17204301059246063, + "p99": 0.17888562381267548, + "sat_pct": 0.0, + "dark_pct": 0.0 + }, + "temporal_cv": 0.0, + "reasons": [] + } + }, + "reasons": [] + }, + "locked_controls": { + "rgb": { + "role": "rgb", + "exposure_time_us": 1134, + "sensitivity_iso": 100, + "source": "ae_snapshot" + }, + "re": { + "role": "re", + "exposure_time_us": 1005, + "sensitivity_iso": 100, + "source": "ae_snapshot" + }, + "nir": { + "role": "nir", + "exposure_time_us": 1005, + "sensitivity_iso": 100, + "source": "ae_snapshot" + } + }, + "decoded_channel_shapes": { + "R": [ + 1200, + 1920 + ], + "G": [ + 1200, + 1920 + ], + "B": [ + 1200, + 1920 + ], + "RE": [ + 800, + 1280 + ], + "NIR": [ + 800, + 1280 + ] + }, + "dark_quality": { + "status": "good", + "channels": { + "R": { + "status": "good", + "p99": 0.04203323647379875, + "reasons": [] + }, + "G": { + "status": "good", + "p99": 0.04203323647379875, + "reasons": [] + }, + "B": { + "status": "good", + "p99": 0.04105571657419205, + "reasons": [] + }, + "RE": { + "status": "good", + "p99": 0.06353861093521118, + "reasons": [] + }, + "NIR": { + "status": "good", + "p99": 0.06256109476089478, + "reasons": [] + } + }, + "reasons": [] + }, + "gain_processing": { + "sigma_frac": 0.025, + "min_gain": 0.25, + "max_gain": 2.19, + "epsilon": 1e-06, + "channels": { + "R": { + "reference": 0.20438721776008606, + "sigma_px": 30.0, + "gain_unclipped_min": 0.942816436290741, + "gain_unclipped_max": 5.908499717712402, + "clipped_low_pct": 0.0, + "clipped_high_pct": 16.11232638888889 + }, + "G": { + "reference": 0.25285059213638306, + "sigma_px": 30.0, + "gain_unclipped_min": 0.9295645356178284, + "gain_unclipped_max": 5.736047267913818, + "clipped_low_pct": 0.0, + "clipped_high_pct": 15.840104166666666 + }, + "B": { + "reference": 0.18301363289356232, + "sigma_px": 30.0, + "gain_unclipped_min": 0.9241291284561157, + "gain_unclipped_max": 5.800284385681152, + "clipped_low_pct": 0.0, + "clipped_high_pct": 17.29123263888889 + }, + "RE": { + "reference": 0.1584281176328659, + "sigma_px": 20.0, + "gain_unclipped_min": 0.8993920683860779, + "gain_unclipped_max": 1.7049007415771484, + "clipped_low_pct": 0.0, + "clipped_high_pct": 0.0 + }, + "NIR": { + "reference": 0.10205542296171188, + "sigma_px": 20.0, + "gain_unclipped_min": 0.909940779209137, + "gain_unclipped_max": 1.7231675386428833, + "clipped_low_pct": 0.0, + "clipped_high_pct": 0.0 + } + } + }, + "acceptance": { + "status": "warning", + "channels": { + "R": { + "status": "warning", + "reasons": [ + "gain_p99_warn:2.190" + ], + "before_uniformity": { + "grid": [ + [ + 0.10557184368371964, + 0.16226783394813538, + 0.18963831663131714, + 0.170087993144989, + 0.1163245365023613 + ], + [ + 0.11925708502531052, + 0.18084067106246948, + 0.20821115374565125, + 0.19354838132858276, + 0.13587486743927002 + ], + [ + 0.12023460119962692, + 0.1837732195854187, + 0.21309873461723328, + 0.2003910094499588, + 0.14271751046180725 + ], + [ + 0.10850440710783005, + 0.17204302549362183, + 0.20625610649585724, + 0.19354838132858276, + 0.13685238361358643 + ], + [ + 0.09188660234212875, + 0.14565005898475647, + 0.1827957034111023, + 0.170087993144989, + 0.11827956885099411 + ] + ], + "median": 0.170087993144989, + "center": 0.21309873461723328, + "min": 0.09188660234212875, + "max": 0.21309873461723328, + "grid_cv": 0.21244090012643105, + "range_over_median": 0.7126436736294433 + }, + "after_uniformity": { + "grid": [ + [ + 0.199024960398674, + 0.2015395164489746, + 0.20242546498775482, + 0.20251041650772095, + 0.20305617153644562 + ], + [ + 0.20019912719726562, + 0.2017350196838379, + 0.20270082354545593, + 0.20343467593193054, + 0.20424452424049377 + ], + [ + 0.20053943991661072, + 0.20263105630874634, + 0.20419418811798096, + 0.2053588628768921, + 0.20644938945770264 + ], + [ + 0.1995813101530075, + 0.203253835439682, + 0.20561566948890686, + 0.20796820521354675, + 0.20950722694396973 + ], + [ + 0.19480937719345093, + 0.20390647649765015, + 0.2065846472978592, + 0.21192538738250732, + 0.21299505233764648 + ] + ], + "median": 0.203253835439682, + "center": 0.20419418811798096, + "min": 0.19480937719345093, + "max": 0.21299505233764648, + "grid_cv": 0.019253022195151476, + "range_over_median": 0.08947272805384464 + }, + "grid_cv_before": 0.21244090012643105, + "grid_cv_after": 0.019253022195151476, + "improvement": 0.9093723375127232, + "local_after": { + "valid": true, + "blur_ksize": 199, + "local_dev_p95": 0.03549724072217941, + "local_dev_p99": 0.06400225311517715, + "local_dev_max": 0.29221951961517334, + "local_outlier_pct": 0.042010951537805756, + "outlier_threshold": 0.12 + }, + "gain": { + "status": "warning", + "reasons": [ + "gain_p99_warn:2.190" + ], + "stats": { + "min": 0.942816436290741, + "p01": 0.9468656778335571, + "p05": 0.968518853187561, + "p50": 1.3375427722930908, + "p95": 2.190000057220459, + "p99": 2.190000057220459, + "max": 2.190000057220459, + "mean": 1.4789849519729614, + "std": 0.43520286679267883 + } + } + }, + "G": { + "status": "warning", + "reasons": [ + "gain_p99_warn:2.190" + ], + "before_uniformity": { + "grid": [ + [ + 0.1339198350906372, + 0.2033235728740692, + 0.235581636428833, + 0.21212121844291687, + 0.14662757515907288 + ], + [ + 0.1495601236820221, + 0.22482892870903015, + 0.26001957058906555, + 0.23753666877746582, + 0.16911044716835022 + ], + [ + 0.1505376398563385, + 0.22873902320861816, + 0.265884667634964, + 0.24535679817199707, + 0.17595309019088745 + ], + [ + 0.13685238361358643, + 0.21309873461723328, + 0.2541544437408447, + 0.2336266040802002, + 0.1671554148197174 + ], + [ + 0.11534702032804489, + 0.17986315488815308, + 0.22287389636039734, + 0.20527860522270203, + 0.14271751046180725 + ] + ], + "median": 0.20527860522270203, + "center": 0.265884667634964, + "min": 0.11534702032804489, + "max": 0.265884667634964, + "grid_cv": 0.21433405332035926, + "range_over_median": 0.7333333502709855 + }, + "after_uniformity": { + "grid": [ + [ + 0.24596601724624634, + 0.2487930953502655, + 0.24989037215709686, + 0.25014495849609375, + 0.25065189599990845 + ], + [ + 0.24707138538360596, + 0.24906344711780548, + 0.2501871585845947, + 0.2510063052177429, + 0.2519645690917969 + ], + [ + 0.24751797318458557, + 0.25014394521713257, + 0.2520444095134735, + 0.253253310918808, + 0.25445544719696045 + ], + [ + 0.24627235531806946, + 0.2508828639984131, + 0.25389063358306885, + 0.2563158869743347, + 0.25815728306770325 + ], + [ + 0.23975995182991028, + 0.251586377620697, + 0.25492027401924133, + 0.2610570192337036, + 0.2614566683769226 + ] + ], + "median": 0.2508828639984131, + "center": 0.2520444095134735, + "min": 0.23975995182991028, + "max": 0.2614566683769226, + "grid_cv": 0.018551137024034117, + "range_over_median": 0.08648146071526658 + }, + "grid_cv_before": 0.21433405332035926, + "grid_cv_after": 0.018551137024034117, + "improvement": 0.9134475519095128, + "local_after": { + "valid": true, + "blur_ksize": 199, + "local_dev_p95": 0.03927580639719963, + "local_dev_p99": 0.0671364963054657, + "local_dev_max": 0.37058812379837036, + "local_outlier_pct": 0.08658354646206308, + "outlier_threshold": 0.12 + }, + "gain": { + "status": "warning", + "reasons": [ + "gain_p99_warn:2.190" + ], + "stats": { + "min": 0.9295645356178284, + "p01": 0.9355872869491577, + "p05": 0.9619282484054565, + "p50": 1.3364434242248535, + "p95": 2.190000057220459, + "p99": 2.190000057220459, + "max": 2.190000057220459, + "mean": 1.4744594097137451, + "std": 0.4326474964618683 + } + } + }, + "B": { + "status": "warning", + "reasons": [ + "gain_p99_warn:2.190" + ], + "before_uniformity": { + "grid": [ + [ + 0.09188660234212875, + 0.14076247811317444, + 0.166177898645401, + 0.14662757515907288, + 0.0997067466378212 + ], + [ + 0.10361681133508682, + 0.15933528542518616, + 0.18670576810836792, + 0.16911044716835022, + 0.1163245365023613 + ], + [ + 0.10557184368371964, + 0.1642228662967682, + 0.19354838132858276, + 0.17693060636520386, + 0.12121211737394333 + ], + [ + 0.09775171428918839, + 0.15347018837928772, + 0.1847507357597351, + 0.1681329309940338, + 0.11534702032804489 + ], + [ + 0.0840664729475975, + 0.130987286567688, + 0.16226783394813538, + 0.14662757515907288, + 0.1006842628121376 + ] + ], + "median": 0.14662757515907288, + "center": 0.19354838132858276, + "min": 0.0840664729475975, + "max": 0.19354838132858276, + "grid_cv": 0.22399109078652438, + "range_over_median": 0.7466665684282842 + }, + "after_uniformity": { + "grid": [ + [ + 0.1773199737071991, + 0.17982172966003418, + 0.18070760369300842, + 0.1807488650083542, + 0.1809305101633072 + ], + [ + 0.1784760057926178, + 0.18000134825706482, + 0.1809779703617096, + 0.18139882385730743, + 0.1823377162218094 + ], + [ + 0.17879170179367065, + 0.1807791292667389, + 0.18232354521751404, + 0.18317759037017822, + 0.18419823050498962 + ], + [ + 0.17815947532653809, + 0.18134638667106628, + 0.1836192011833191, + 0.18560589849948883, + 0.18683409690856934 + ], + [ + 0.17470957338809967, + 0.18184435367584229, + 0.18429838120937347, + 0.18903261423110962, + 0.18977466225624084 + ] + ], + "median": 0.18134638667106628, + "center": 0.18232354521751404, + "min": 0.17470957338809967, + "max": 0.18977466225624084, + "grid_cv": 0.018757958892834093, + "range_over_median": 0.08307355412306541 + }, + "grid_cv_before": 0.22399109078652438, + "grid_cv_after": 0.018757958892834093, + "improvement": 0.9162557813037687, + "local_after": { + "valid": true, + "blur_ksize": 199, + "local_dev_p95": 0.03361033275723457, + "local_dev_p99": 0.061128970235586166, + "local_dev_max": 0.3078117370605469, + "local_outlier_pct": 0.031098350711522067, + "outlier_threshold": 0.12 + }, + "gain": { + "status": "warning", + "reasons": [ + "gain_p99_warn:2.190" + ], + "stats": { + "min": 0.9241291284561157, + "p01": 0.9313554167747498, + "p05": 0.9613210558891296, + "p50": 1.369065284729004, + "p95": 2.190000057220459, + "p99": 2.190000057220459, + "max": 2.190000057220459, + "mean": 1.4998568296432495, + "std": 0.4377722144126892 + } + } + }, + "RE": { + "status": "good", + "reasons": [], + "before_uniformity": { + "grid": [ + [ + 0.13000977039337158, + 0.14076246321201324, + 0.13880743086338043, + 0.12023460865020752, + 0.10752688348293304 + ], + [ + 0.132942333817482, + 0.1495601236820221, + 0.1524926722049713, + 0.1329423189163208, + 0.10850439965724945 + ], + [ + 0.12805473804473877, + 0.16129031777381897, + 0.1642228662967682, + 0.13880743086338043, + 0.1153470128774643 + ], + [ + 0.11827957630157471, + 0.1505376398563385, + 0.168132945895195, + 0.14662756025791168, + 0.11925709247589111 + ], + [ + 0.12316715717315674, + 0.13000977039337158, + 0.13880743086338043, + 0.1329423189163208, + 0.11241446435451508 + ] + ], + "median": 0.1329423189163208, + "center": 0.1642228662967682, + "min": 0.10752688348293304, + "max": 0.168132945895195, + "grid_cv": 0.1254762601756787, + "range_over_median": 0.45588239250144147 + }, + "after_uniformity": { + "grid": [ + [ + 0.16269387304782867, + 0.16084441542625427, + 0.15855517983436584, + 0.1566985845565796, + 0.15562549233436584 + ], + [ + 0.16032814979553223, + 0.15899819135665894, + 0.15821528434753418, + 0.15679825842380524, + 0.1549057960510254 + ], + [ + 0.15839272737503052, + 0.15800023078918457, + 0.15719106793403625, + 0.15618401765823364, + 0.15492850542068481 + ], + [ + 0.15663495659828186, + 0.15669432282447815, + 0.15690618753433228, + 0.15581318736076355, + 0.15491795539855957 + ], + [ + 0.15720561146736145, + 0.1558159589767456, + 0.15592804551124573, + 0.1556379348039627, + 0.15471550822257996 + ] + ], + "median": 0.1566985845565796, + "center": 0.15719106793403625, + "min": 0.15471550822257996, + "max": 0.16269387304782867, + "grid_cv": 0.01248097844560657, + "range_over_median": 0.050915359879131185 + }, + "grid_cv_before": 0.1254762601756787, + "grid_cv_after": 0.01248097844560657, + "improvement": 0.9005311568249483, + "local_after": { + "valid": true, + "blur_ksize": 133, + "local_dev_p95": 0.02847035601735115, + "local_dev_p99": 0.040106408298015594, + "local_dev_max": 0.17694354057312012, + "local_outlier_pct": 0.0005766959474422382, + "outlier_threshold": 0.12 + }, + "gain": { + "status": "good", + "reasons": [], + "stats": { + "min": 0.8993920683860779, + "p01": 0.9128138422966003, + "p05": 0.9513971209526062, + "p50": 1.220381259918213, + "p95": 1.4703654050827026, + "p99": 1.584238886833191, + "max": 1.7049007415771484, + "mean": 1.22039794921875, + "std": 0.16197332739830017 + } + } + }, + "NIR": { + "status": "good", + "reasons": [], + "before_uniformity": { + "grid": [ + [ + 0.08113391697406769, + 0.08895406126976013, + 0.09188660979270935, + 0.08015640079975128, + 0.0664711594581604 + ], + [ + 0.0830889493227005, + 0.09579667448997498, + 0.10263928771018982, + 0.08797654509544373, + 0.0674486756324768 + ], + [ + 0.0821114331483841, + 0.0997067391872406, + 0.10752688348293304, + 0.09188660979270935, + 0.07135875523090363 + ], + [ + 0.08113391697406769, + 0.09481915831565857, + 0.10459433495998383, + 0.08895406126976013, + 0.07135875523090363 + ], + [ + 0.07917888462543488, + 0.08699902892112732, + 0.09384164214134216, + 0.0840664654970169, + 0.07135875523090363 + ] + ], + "median": 0.08699902892112732, + "center": 0.10752688348293304, + "min": 0.0664711594581604, + "max": 0.10752688348293304, + "grid_cv": 0.12849058780654812, + "range_over_median": 0.4719101412269034 + }, + "after_uniformity": { + "grid": [ + [ + 0.10500280559062958, + 0.10387982428073883, + 0.10250868648290634, + 0.10150126367807388, + 0.1007276251912117 + ], + [ + 0.10344205051660538, + 0.10281629860401154, + 0.10234290361404419, + 0.10143423825502396, + 0.10049798339605331 + ], + [ + 0.10236205160617828, + 0.10204454511404037, + 0.10165475308895111, + 0.10115669667720795, + 0.10055448114871979 + ], + [ + 0.10139243304729462, + 0.10132019221782684, + 0.10111145675182343, + 0.10076688230037689, + 0.10023058950901031 + ], + [ + 0.10112433135509491, + 0.10085234045982361, + 0.10085466504096985, + 0.10058404505252838, + 0.10008348524570465 + ] + ], + "median": 0.10132019221782684, + "center": 0.10165475308895111, + "min": 0.10008348524570465, + "max": 0.10500280559062958, + "grid_cv": 0.011629549665196423, + "range_over_median": 0.04855222080855265 + }, + "grid_cv_before": 0.12849058780654812, + "grid_cv_after": 0.011629549665196423, + "improvement": 0.9094910384976559, + "local_after": { + "valid": true, + "blur_ksize": 133, + "local_dev_p95": 0.038740791380405426, + "local_dev_p99": 0.053006645292043686, + "local_dev_max": 0.24659883975982666, + "local_outlier_pct": 0.005536281095445486, + "outlier_threshold": 0.12 + }, + "gain": { + "status": "good", + "reasons": [], + "stats": { + "min": 0.909940779209137, + "p01": 0.927322268486023, + "p05": 0.9668985605239868, + "p50": 1.2259185314178467, + "p95": 1.5487970113754272, + "p99": 1.6243726015090942, + "max": 1.7231675386428833, + "mean": 1.230853796005249, + "std": 0.17348776757717133 + } + } + } + }, + "reasons": [ + "R:gain_p99_warn:2.190", + "G:gain_p99_warn:2.190", + "B:gain_p99_warn:2.190" + ] + }, + "finished_at": "2026-09-08 16:32:57", + "candidate_sha256": "869a25192d51464a65f55852404fba01089c7aa181d975e5960a8154096cbcd4", + "promotion_decision": { + "allowed_by_quality": true, + "final_status": "warning", + "no_promote": false + }, + "promoted_at": "2026-09-08 16:33:27" +} \ No newline at end of file diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/flatfield_maps_v1.npz b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/flatfield_maps_v1.npz new file mode 100644 index 000000000..67c2374f0 Binary files /dev/null and b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/flatfield_maps_v1.npz differ diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/module_params.json b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/module_params.json new file mode 100644 index 000000000..1e0b60a6c --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/module_params.json @@ -0,0 +1,902 @@ +{ + "schema": "multispec_module_params_v3", + "saved_at": "2026-09-08 17:02:25", + "frame_type": "RAW_BRUTO", + "capture_mode_requested": "AUTO", + "capture_mode_effective": "AUTO", + "raw_policy": "allow_single", + "sensor_width": 1920, + "sensor_height": 1200, + "bayer_pattern": "GRBG", + "sensor_size_by_role": { + "rgb": [ + 1920, + 1200 + ], + "re": [ + 1280, + 800 + ], + "nir": [ + 1280, + 800 + ] + }, + "camera_hardware": { + "rgb": { + "socket": "CAM_A", + "sensor": "AR0234", + "size": [ + 1920, + 1200 + ] + }, + "re": { + "socket": "CAM_B", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + }, + "nir": { + "socket": "CAM_C", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + } + }, + "camera_orientation": { + "schema": "multispec_camera_orientation_v1", + "enabled": true, + "input_space": "native_stream_no_external_undistort", + "output_space": "canonical_oriented_stream_no_external_undistort", + "apply_stage": "after_native_flat_before_fusion", + "by_role": { + "rgb": { + "rotate_deg": 180, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1920, + 1200 + ], + "oriented_size": [ + 1920, + 1200 + ] + }, + "re": { + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1280, + 800 + ], + "oriented_size": [ + 1280, + 800 + ] + }, + "nir": { + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1280, + 800 + ], + "oriented_size": [ + 1280, + 800 + ] + } + } + }, + "rgb_processing": { + "mode": "linear_demosaic", + "demosaic_algorithm": "ea" + }, + "camera_settings": { + "rgb": { + "ae_enable": true, + "awb_enable": true, + "exposure_time_us": 631, + "analogue_gain": 1.0, + "colour_gains": [ + 1.0, + 1.0 + ] + }, + "re": { + "ae_enable": true, + "awb_enable": false, + "exposure_time_us": 694, + "analogue_gain": 1.0, + "colour_gains": null + }, + "nir": { + "ae_enable": true, + "awb_enable": false, + "exposure_time_us": 1048, + "analogue_gain": 1.0, + "colour_gains": null + } + }, + "fusion_config": { + "alignment_mode": "homography", + "baseline_mm": 75.0, + "reference_camera": "rgb", + "homography_profile": "media", + "homography_profile_by_role": { + "re": "media", + "nir": "media" + }, + "homography_profiles": { + "media": { + "description": "Plano de calibração ChArUco.", + "depth": 66.0, + "depth_unit": "cm", + "homography_source": "charuco_oriented_multi_sample_group_cv", + "native_coordinate_space": "native_stream_no_external_undistort", + "coordinate_space": "canonical_oriented_stream_no_external_undistort", + "camera_orientation": { + "schema": "multispec_camera_orientation_v1", + "enabled": true, + "input_space": "native_stream_no_external_undistort", + "output_space": "canonical_oriented_stream_no_external_undistort", + "apply_stage": "after_native_flat_before_fusion", + "by_role": { + "rgb": { + "rotate_deg": 180, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1920, + 1200 + ], + "oriented_size": [ + 1920, + 1200 + ] + }, + "re": { + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1280, + 800 + ], + "oriented_size": [ + 1280, + 800 + ] + }, + "nir": { + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1280, + 800 + ], + "oriented_size": [ + 1280, + 800 + ] + } + } + }, + "reference_size": [ + 1920, + 1200 + ], + "source_size_by_role": { + "re": [ + 1280, + 800 + ], + "nir": [ + 1280, + 800 + ] + }, + "homography_calibration_size": [ + 1920, + 1200 + ], + "homography_stats": { + "re_total_points": 456, + "re_inliers": 456, + "re_inlier_pct": 100.0, + "nir_total_points": 575, + "nir_inliers": 575, + "nir_inlier_pct": 100.0, + "re_frames_used": 9, + "nir_frames_used": 9, + "common_frames_used": 9, + "re_fit_median_error_px": 0.38758599758148193, + "nir_fit_median_error_px": 0.35749897360801697, + "re_cv_median_error_px": 0.5654353499412537, + "nir_cv_median_error_px": 0.45016223192214966, + "re_overlap_pct": 67.63589409722222, + "nir_overlap_pct": 66.90980902777778, + "overlap_common_pct": 66.90980902777778 + }, + "homographies": { + "re_to_rgb": [ + [ + 1.229438054753987, + -0.023146873859868553, + 191.17641195023432 + ], + [ + 0.0068483713406142345, + 1.215811502284338, + 145.9976466757219 + ], + [ + 2.6951263574907942e-06, + -1.7656569375171528e-05, + 1.0 + ] + ], + "nir_to_rgb": [ + [ + 1.1985871377943254, + -0.029840166013639494, + 172.11865189505187 + ], + [ + -0.005034022942030897, + 1.1882575097648682, + 109.71619856540441 + ], + [ + -7.354123495506985e-06, + -3.0667664963341365e-05, + 1.0 + ] + ] + } + } + }, + "homography_calibration_size": [ + 1920, + 1200 + ], + "use_remap_cache": true, + "use_remap_for_rgb": false, + "use_remap_for_spec": true, + "crop_valid_common": true, + "resize_after_crop": true, + "target_size": null + }, + "intrinsics_config": { + "enabled": true, + "calibration_space": "native_stream_no_external_undistort", + "operational_roi_contract": "ROI afeta somente guia e acceptance. K/D e corners permanecem no raster nativo completo.", + "cameras": { + "rgb": { + "socket": "CAM_A", + "sensor": "AR0234", + "stream_source": "ISP", + "image_size": [ + 1920, + 1200 + ], + "operational_roi_native": { + "x": 0, + "y": 0, + "width": 1920, + "height": 1200 + }, + "distortion_model": "brown5", + "camera_matrix": [ + [ + 1166.0979784837227, + 0.0, + 951.5767142097442 + ], + [ + 0.0, + 1168.7643259842318, + 604.1279550529616 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "dist_coeffs": [ + 0.039391307619914294, + 0.01630033368807952, + 0.0009880549612935289, + -0.00013672753943063363, + -0.06837030351164153 + ], + "undistort_products": { + "0.0": { + "alpha": 0.0, + "new_camera_matrix": [ + [ + 1181.756418296315, + 0.0, + 950.9467122149669 + ], + [ + 0.0, + 1186.0086782696878, + 605.3891162485107 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 0, + 0, + 1919, + 1199 + ] + }, + "0.5": { + "alpha": 0.5, + "new_camera_matrix": [ + [ + 1168.6413181324342, + 0.0, + 950.1682075221445 + ], + [ + 0.0, + 1172.1119850711932, + 605.9709290057904 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 10, + 8, + 1898, + 1185 + ] + }, + "1.0": { + "alpha": 1.0, + "new_camera_matrix": [ + [ + 1155.5262179685533, + 0.0, + 949.3897028293222 + ], + [ + 0.0, + 1158.2152918726986, + 606.55274176307 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 20, + 15, + 1876, + 1171 + ] + } + } + }, + "re": { + "socket": "CAM_B", + "sensor": "OV9282", + "stream_source": "MONO_OUT", + "image_size": [ + 1280, + 800 + ], + "operational_roi_native": { + "x": 0, + "y": 0, + "width": 1280, + "height": 800 + }, + "distortion_model": "brown5", + "camera_matrix": [ + [ + 929.0286416157797, + 0.0, + 644.1026229147756 + ], + [ + 0.0, + 931.8815590920124, + 371.82566695003317 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "dist_coeffs": [ + 0.06986318959627301, + -0.013902621968219604, + 0.001143262770443756, + 0.0007927956015904726, + -0.07759630612045954 + ], + "undistort_products": { + "0.0": { + "alpha": 0.0, + "new_camera_matrix": [ + [ + 949.6779297598379, + 0.0, + 645.1710508964194 + ], + [ + 0.0, + 952.5670641843998, + 372.7899174010669 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 0, + 0, + 1279, + 799 + ] + }, + "0.5": { + "alpha": 0.5, + "new_camera_matrix": [ + [ + 948.0566137041557, + 0.0, + 645.3356001200425 + ], + [ + 0.0, + 947.784668586652, + 372.8618574128032 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 1, + 2, + 1277, + 795 + ] + }, + "1.0": { + "alpha": 1.0, + "new_camera_matrix": [ + [ + 946.4352976484735, + 0.0, + 645.5001493436657 + ], + [ + 0.0, + 943.0022729889041, + 372.93379742453953 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 3, + 4, + 1275, + 791 + ] + } + } + }, + "nir": { + "socket": "CAM_C", + "sensor": "OV9282", + "stream_source": "MONO_OUT", + "image_size": [ + 1280, + 800 + ], + "operational_roi_native": { + "x": 0, + "y": 0, + "width": 1280, + "height": 800 + }, + "distortion_model": "brown5", + "camera_matrix": [ + [ + 931.8852709072933, + 0.0, + 655.5142810977045 + ], + [ + 0.0, + 934.1328900504861, + 421.25400633846596 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "dist_coeffs": [ + 0.0722754387140124, + -0.04222665782931207, + 0.001515428546208944, + -0.0005902584650630938, + -0.03362807732640043 + ], + "undistort_products": { + "0.0": { + "alpha": 0.0, + "new_camera_matrix": [ + [ + 952.6875209672814, + 0.0, + 654.749436544307 + ], + [ + 0.0, + 954.3413650330856, + 422.427394072531 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 0, + 0, + 1279, + 799 + ] + }, + "0.5": { + "alpha": 0.5, + "new_camera_matrix": [ + [ + 950.916539490878, + 0.0, + 654.8789818964838 + ], + [ + 0.0, + 949.5012472686025, + 422.0098330004064 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 1, + 2, + 1277, + 795 + ] + }, + "1.0": { + "alpha": 1.0, + "new_camera_matrix": [ + [ + 949.1455580144744, + 0.0, + 655.0085272486606 + ], + [ + 0.0, + 944.6611295041193, + 421.5922719282818 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "valid_roi": [ + 3, + 3, + 1274, + 791 + ] + } + } + } + }, + "runtime_undistort": { + "enabled": false, + "alpha": 0.0, + "interpolation": "linear", + "reason": "Homography atual foi calibrada no espaço nativo distorcido. Ative undistort somente após recalibrar a homografia no novo espaço." + } + }, + "radiometric_config": { + "enabled": false, + "policy": "disabled_product_default", + "reason": "Field radiometric controller is not part of factory calibration. Per-frame exposure variation is handled by radiometric_normalization." + }, + "radiometric_normalization": { + "enabled": true, + "method": "oak_ae_frame_controls_affine_v2", + "apply_stage": "after_dark_before_flat_gain", + "control_source": "stream_meta.frame_controls", + "role_mapping_source": "camera_info", + "factor_model": "exposure_time_us_x_iso", + "iso_base": 100.0, + "reference_mode": "fixed", + "reference_controls": { + "rgb": { + "exposure_time_us": 631, + "sensitivity_iso": 100 + }, + "re": { + "exposure_time_us": 694, + "sensitivity_iso": 100 + }, + "nir": { + "exposure_time_us": 1048, + "sensitivity_iso": 100 + } + }, + "scale_limits": { + "rgb": { + "min": 0.05, + "max": 3.0 + }, + "re": { + "min": 0.05, + "max": 3.0 + }, + "nir": { + "min": 0.05, + "max": 3.0 + }, + "default": { + "min": 0.05, + "max": 3.0 + } + }, + "missing_controls_policy": "skip", + "invalid_controls_policy": "skip", + "clip_output": false, + "save_debug": true, + "black_offset_model": "per_role_scalar_raw01", + "black_offset_by_role": { + "rgb": 0.043317917158965276, + "re": 0.07195387680152925, + "nir": 0.06753712138856606 + }, + "formula": "offset + (value - offset) * reference_factor / actual_factor" + }, + "patch_normalization": { + "enabled": false, + "policy": "disabled_product_default", + "reason": "Reference-patch post-fusion normalization is not active in the homologated product pipeline." + }, + "rgb_calibration": { + "enabled": false, + "gains": { + "R": 1.0, + "G": 1.0, + "B": 1.0 + } + }, + "flatfield_config": { + "enabled": true, + "npz_file": "calibration/flatfield_maps_v1.npz", + "apply_before_fusion": true, + "apply_after_decode": true, + "apply_space": "native_camera_space", + "map_type": "gain", + "channels": [ + "R", + "G", + "B", + "RE", + "NIR" + ], + "channel_maps": { + "R": { + "gain_key": "gain_R", + "dark_median_key": "dark_median_R" + }, + "G": { + "gain_key": "gain_G", + "dark_median_key": "dark_median_G" + }, + "B": { + "gain_key": "gain_B", + "dark_median_key": "dark_median_B" + }, + "RE": { + "gain_key": "gain_RE", + "dark_median_key": "dark_median_RE" + }, + "NIR": { + "gain_key": "gain_NIR", + "dark_median_key": "dark_median_NIR" + } + }, + "subtract_dark": false, + "clip_output": true, + "json_file": "calibration/flatfield_maps_v1.json", + "schema": "multispec_flatfield_production_v2", + "created_at": "2026-09-08 16:31:07" + }, + "calibration_provenance": { + "hardware_signature": { + "rgb": { + "socket": "CAM_A", + "sensor": "AR0234", + "size": [ + 1920, + 1200 + ] + }, + "re": { + "socket": "CAM_B", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + }, + "nir": { + "socket": "CAM_C", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + } + }, + "device_mx_id": "194430108133AC2F00", + "module_id": null, + "camera_orientation": { + "source": "homography.camera_orientation_signature", + "schema": "multispec_camera_orientation_v1", + "enabled": true, + "input_space": "native_stream_no_external_undistort", + "output_space": "canonical_oriented_stream_no_external_undistort", + "apply_stage": "after_native_flat_before_fusion", + "selected_homography_profile": "media" + }, + "artifacts": { + "focus": { + "path": "calibration/focus_qc_active.json", + "sha256": "8c0f3ff2303a64f60b43e5a2f2966548b7720a2e43e7a46504a0a949ae6856f9", + "schema": "multispec_focus_qc_v3", + "status": "pass", + "promoted": true, + "session_id": null, + "created_at": "2026-09-08 16:03:05", + "finished_at": "2026-09-08 16:04:46" + }, + "flatfield": { + "path": "calibration/flatfield_maps_v1.json", + "sha256": "1871312008b14ae4efb5d77ad7c9eb580af9c330775cccc9fa787f58f6f64648", + "schema": "multispec_flatfield_production_v2", + "status": "warning", + "promoted": true, + "session_id": "20260908_163058", + "created_at": "2026-09-08 16:31:07", + "finished_at": "2026-09-08 16:32:57", + "npz_path": "calibration/flatfield_maps_v1.npz", + "npz_sha256": "869a25192d51464a65f55852404fba01089c7aa181d975e5960a8154096cbcd4" + }, + "radiometry": { + "path": "calibration/radiometry_calibration_v5.json", + "sha256": "0c9934f20a7a5e48b8c42e682e193941e841f696e799ae9834576bd9e2522ac3", + "schema": "multispec_radiometric_calibration_v6", + "status": "warning", + "promoted": true, + "session_id": "20260908_154252_449094", + "created_at": "2026-09-08 15:43:01", + "finished_at": "2026-09-08 15:44:01" + }, + "startup": { + "path": "calibration/camera_startup_profile_v3.json", + "sha256": "e63e423a5d7b4fe3231352bd27e5dd3f26d0e03bb5d7c2ee6b2fe48084298599", + "schema": "multispec_camera_startup_profile_v3", + "status": "good", + "promoted": true, + "session_id": null, + "created_at": "2026-09-08 15:45:46", + "finished_at": null + }, + "intrinsics": { + "path": "calibration/intrinsics_calibration_v1.json", + "sha256": "2b5d36825e40d9208cbfb7ab7f49d09c694ad26ab6f5db52fba706ab5a2d4f7d", + "schema": "multispec_intrinsics_calibration_v1", + "status": "warning_promoted", + "promoted": true, + "session_id": "20260908_144510_049990", + "created_at": "2026-09-08 14:45:15", + "finished_at": "2026-09-08 14:55:09" + }, + "homography": { + "path": "calibration/homography_calibration_v4.json", + "sha256": "945af322860e5e704735cb5ac7f37918ee79e67826088e4ff13e1e54adf73378", + "schema": "multispec_homography_calibration_v4", + "status": null, + "promoted": null, + "session_id": null, + "created_at": "2026-09-08 15:11:12", + "finished_at": null, + "selected_profile": "media" + } + } + }, + "assembly_metadata": { + "schema": "multispec_module_params_assembly_v1", + "assembled_at": "2026-09-08 17:02:25", + "assembly_report": "calibration/module_params_assembly_report.json", + "calibration_line": [ + "focus", + "intrinsics", + "flatfield", + "radiometry", + "camera_startup", + "homography", + "module_params_assembler" + ], + "runtime_undistort_enabled": false, + "camera_orientation_schema": "multispec_camera_orientation_v1", + "camera_orientation_enabled": true, + "geometry_chain": { + "intrinsics_space": "native_stream_no_external_undistort", + "orientation_input_space": "native_stream_no_external_undistort", + "orientation_output_space": "canonical_oriented_stream_no_external_undistort", + "orientation_apply_stage": "after_native_flat_before_fusion", + "homography_space": "canonical_oriented_stream_no_external_undistort", + "runtime_order": [ + "decode", + "radiometric_normalization", + "flat_native", + "camera_orientation", + "homography", + "common_crop", + "final_resample" + ] + } + } +} diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/module_params_assembly_report.json b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/module_params_assembly_report.json new file mode 100644 index 000000000..fd973583f --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/calibration/mp_ar0234/module_params_assembly_report.json @@ -0,0 +1,269 @@ +{ + "schema": "multispec_module_params_assembly_v1", + "created_at": "2026-09-08 17:02:25", + "status": "pass", + "check_only": false, + "output": "calibration/module_params.json", + "runtime_base_json": "calibration/module_params.json", + "runtime_base_found": false, + "hardware_signature": { + "rgb": { + "socket": "CAM_A", + "sensor": "AR0234", + "size": [ + 1920, + 1200 + ] + }, + "re": { + "socket": "CAM_B", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + }, + "nir": { + "socket": "CAM_C", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + } + }, + "device_mx_id": "194430108133AC2F00", + "module_id": null, + "bayer_pattern": "GRBG", + "homography_profile": "media", + "geometric_contract": { + "intrinsics_space": "native_stream_no_external_undistort", + "orientation_input_space": "native_stream_no_external_undistort", + "orientation_output_space": "canonical_oriented_stream_no_external_undistort", + "homography_space": "canonical_oriented_stream_no_external_undistort", + "runtime_undistort_enabled": false, + "selected_profile": "media", + "native_sizes": { + "rgb": [ + 1920, + 1200 + ], + "re": [ + 1280, + 800 + ], + "nir": [ + 1280, + 800 + ] + }, + "oriented_sizes": { + "rgb": [ + 1920, + 1200 + ], + "re": [ + 1280, + 800 + ], + "nir": [ + 1280, + 800 + ] + }, + "runtime_order": [ + "decode", + "radiometric_normalization", + "flat_native", + "camera_orientation", + "homography", + "common_crop", + "final_resample" + ] + }, + "camera_orientation": { + "schema": "multispec_camera_orientation_v1", + "enabled": true, + "input_space": "native_stream_no_external_undistort", + "output_space": "canonical_oriented_stream_no_external_undistort", + "apply_stage": "after_native_flat_before_fusion", + "by_role": { + "rgb": { + "rotate_deg": 180, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1920, + 1200 + ], + "oriented_size": [ + 1920, + 1200 + ] + }, + "re": { + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1280, + 800 + ], + "oriented_size": [ + 1280, + 800 + ] + }, + "nir": { + "rotate_deg": 0, + "flip_horizontal": false, + "flip_vertical": false, + "native_size": [ + 1280, + 800 + ], + "oriented_size": [ + 1280, + 800 + ] + } + } + }, + "runtime_policy": { + "onnx_model_path": null, + "frame_type": "RAW_BRUTO", + "capture_mode_requested": "AUTO", + "capture_mode_effective": "AUTO", + "raw_policy": "allow_single", + "rgb_processing": { + "mode": "bayer_planes" + }, + "fusion_runtime": { + "use_remap_cache": true, + "use_remap_for_rgb": false, + "use_remap_for_spec": true, + "crop_valid_common": true, + "resize_after_crop": true, + "target_size": null + } + }, + "calibration_provenance": { + "hardware_signature": { + "rgb": { + "socket": "CAM_A", + "sensor": "AR0234", + "size": [ + 1920, + 1200 + ] + }, + "re": { + "socket": "CAM_B", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + }, + "nir": { + "socket": "CAM_C", + "sensor": "OV9282", + "size": [ + 1280, + 800 + ] + } + }, + "device_mx_id": "194430108133AC2F00", + "module_id": null, + "camera_orientation": { + "source": "homography.camera_orientation_signature", + "schema": "multispec_camera_orientation_v1", + "enabled": true, + "input_space": "native_stream_no_external_undistort", + "output_space": "canonical_oriented_stream_no_external_undistort", + "apply_stage": "after_native_flat_before_fusion", + "selected_homography_profile": "media" + }, + "artifacts": { + "focus": { + "path": "calibration/focus_qc_active.json", + "sha256": "8c0f3ff2303a64f60b43e5a2f2966548b7720a2e43e7a46504a0a949ae6856f9", + "schema": "multispec_focus_qc_v3", + "status": "pass", + "promoted": true, + "session_id": null, + "created_at": "2026-09-08 16:03:05", + "finished_at": "2026-09-08 16:04:46" + }, + "flatfield": { + "path": "calibration/flatfield_maps_v1.json", + "sha256": "1871312008b14ae4efb5d77ad7c9eb580af9c330775cccc9fa787f58f6f64648", + "schema": "multispec_flatfield_production_v2", + "status": "warning", + "promoted": true, + "session_id": "20260908_163058", + "created_at": "2026-09-08 16:31:07", + "finished_at": "2026-09-08 16:32:57", + "npz_path": "calibration/flatfield_maps_v1.npz", + "npz_sha256": "869a25192d51464a65f55852404fba01089c7aa181d975e5960a8154096cbcd4" + }, + "radiometry": { + "path": "calibration/radiometry_calibration_v5.json", + "sha256": "0c9934f20a7a5e48b8c42e682e193941e841f696e799ae9834576bd9e2522ac3", + "schema": "multispec_radiometric_calibration_v6", + "status": "warning", + "promoted": true, + "session_id": "20260908_154252_449094", + "created_at": "2026-09-08 15:43:01", + "finished_at": "2026-09-08 15:44:01" + }, + "startup": { + "path": "calibration/camera_startup_profile_v3.json", + "sha256": "e63e423a5d7b4fe3231352bd27e5dd3f26d0e03bb5d7c2ee6b2fe48084298599", + "schema": "multispec_camera_startup_profile_v3", + "status": "good", + "promoted": true, + "session_id": null, + "created_at": "2026-09-08 15:45:46", + "finished_at": null + }, + "intrinsics": { + "path": "calibration/intrinsics_calibration_v1.json", + "sha256": "2b5d36825e40d9208cbfb7ab7f49d09c694ad26ab6f5db52fba706ab5a2d4f7d", + "schema": "multispec_intrinsics_calibration_v1", + "status": "warning_promoted", + "promoted": true, + "session_id": "20260908_144510_049990", + "created_at": "2026-09-08 14:45:15", + "finished_at": "2026-09-08 14:55:09" + }, + "homography": { + "path": "calibration/homography_calibration_v4.json", + "sha256": "945af322860e5e704735cb5ac7f37918ee79e67826088e4ff13e1e54adf73378", + "schema": "multispec_homography_calibration_v4", + "status": null, + "promoted": null, + "session_id": null, + "created_at": "2026-09-08 15:11:12", + "finished_at": null, + "selected_profile": "media" + } + } + }, + "final_checks": { + "focus_gate": "pass", + "flatfield": "pass", + "radiometry": "pass", + "startup": "pass", + "intrinsics": "pass", + "homography": "pass", + "hardware_consistency": "pass", + "geometry_consistency": "pass", + "camera_orientation": "pass", + "startup_radiometry_consistency": "pass", + "flatfield_npz_integrity": "pass", + "module_params_structure": "pass" + }, + "output_sha256": "efcaddc1d7687cb3ed1c9c9f9f7e2a9a9a1e6c260c3af967473590dec32b313c" +} 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 7198c766d..6282d4e94 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 @@ -28,7 +28,7 @@ module_params is loaded. The strict fail-closed behavior is enabled only for module_params generated by the production assembler. """ -RAW_PROCESSOR_CORE_VERSION = "production_v1_2026_08_24" +RAW_PROCESSOR_CORE_VERSION = "production_v2_2026_09_08" import json import os @@ -3913,20 +3913,68 @@ class RawProcessorCore: "radiometric_normalization.enabled=true." ) - if ( - str( - rad_norm.get( - "method", - "", - ) - ).lower() - != "oak_ae_frame_controls_v1" - ): + radiometric_method = str( + rad_norm.get( + "method", + "", + ) + ).lower() + + allowed_radiometric_methods = { + "oak_ae_frame_controls_v1", + "oak_ae_frame_controls_affine_v2", + } + + if radiometric_method not in allowed_radiometric_methods: raise RuntimeError( "Método radiométrico inválido no produto: " f"{rad_norm.get('method')!r}" ) + if radiometric_method == "oak_ae_frame_controls_affine_v2": + black_offset_model = str( + rad_norm.get( + "black_offset_model", + "", + ) + ).lower() + + if black_offset_model != "per_role_scalar_raw01": + raise RuntimeError( + "black_offset_model inválido para affine_v2: " + f"{rad_norm.get('black_offset_model')!r}. " + "Esperado='per_role_scalar_raw01'." + ) + + black_offsets = ( + rad_norm.get( + "black_offset_by_role", + {}, + ) + or {} + ) + + for role in ( + "rgb", + "re", + "nir", + ): + try: + offset = float( + black_offsets[role] + ) + except Exception as exc: + raise RuntimeError( + "radiometric_normalization sem black offset " + f"válido para role={role}." + ) from exc + + if not np.isfinite(offset) or not (0.0 <= offset < 1.0): + raise RuntimeError( + "black offset fora do domínio RAW01 em " + f"role={role}: {offset!r}" + ) + factor_model = str( rad_norm.get( "factor_model", @@ -4464,16 +4512,45 @@ class RawProcessorCore: # FUSION SPACE # ============================================================ + # No contrato com perfis, coordinate_space pertence ao profile ativo. + # Mantemos fallback para o campo top-level por compatibilidade legada. fusion_space = str( fusion.get( "coordinate_space", "", ) + or "" ).strip() + if not fusion_space: + selected_profile = self._resolve_homography_profile_name_for_role( + "re" + ) + profiles = fusion.get( + "homography_profiles", + {}, + ) or {} + profile = profiles.get(selected_profile) + + if profile is None and isinstance(profiles, dict): + for profile_name, profile_item in profiles.items(): + if str(profile_name).lower() == str(selected_profile).lower(): + profile = profile_item + break + + if isinstance(profile, dict): + fusion_space = str( + profile.get( + "coordinate_space", + "", + ) + or "" + ).strip() + if not fusion_space: raise RuntimeError( - "fusion_config.coordinate_space ausente." + "coordinate_space ausente no fusion_config e no " + "homography_profile ativo." ) if ( @@ -5650,11 +5727,18 @@ class RawProcessorCore: return decoded method = str(cfg.get("method", "oak_ae_frame_controls_v1")).lower() - if method not in ("oak_ae_frame_controls_v1", "exposure_iso_reference"): + if method not in ( + "oak_ae_frame_controls_v1", + "oak_ae_frame_controls_affine_v2", + "exposure_iso_reference", + ): result["warnings"].append(f"unsupported_method:{method}") self.last_radiometric_normalization_result = result return decoded + affine_v2 = method == "oak_ae_frame_controls_affine_v2" + black_offset_by_role = cfg.get("black_offset_by_role", {}) or {} + controls_by_role, controls_by_cam = self._extract_frame_controls_from_meta_by_role(meta) if not controls_by_role: @@ -5677,6 +5761,7 @@ class RawProcessorCore: # Cache por role para não recalcular factor/scale 3x quando RGB tem 3 canais no mesmo item. scale_by_role = {} + offset_by_role = {} debug_by_role = {} normalized = {} @@ -5692,6 +5777,7 @@ class RawProcessorCore: if role in scale_by_role: scale = scale_by_role[role] + offset = offset_by_role[role] debug = dict(debug_by_role[role]) debug["camera_id"] = cam_id else: @@ -5721,6 +5807,44 @@ class RawProcessorCore: raw_scale = float(ref_factor / actual_factor) scale = self._clip_radiometric_scale(raw_scale, role, cfg) + offset = 0.0 + if affine_v2: + try: + offset = float(black_offset_by_role[role]) + except Exception: + normalized[cam_id] = item + result["warnings"].append( + f"{role}:invalid_black_offset" + ) + + if ( + getattr(self, "strict_product_contract", False) + or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise" + ): + raise RuntimeError( + "Black offset radiométrico inválido ou ausente " + f"para role={role}." + ) + + continue + + if not np.isfinite(offset) or not (0.0 <= offset < 1.0): + normalized[cam_id] = item + result["warnings"].append( + f"{role}:invalid_black_offset:{offset!r}" + ) + + if ( + getattr(self, "strict_product_contract", False) + or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise" + ): + raise RuntimeError( + "Black offset radiométrico fora do domínio RAW01 " + f"para role={role}: {offset!r}" + ) + + continue + debug = self._build_radiometric_debug( method=method, role=role, @@ -5732,16 +5856,26 @@ class RawProcessorCore: raw_scale=raw_scale, scale=scale, clip_output=clip_output, + black_offset=offset if affine_v2 else None, ) scale_by_role[role] = scale + offset_by_role[role] = offset debug_by_role[role] = dict(debug) - out, reused = self._apply_radiometric_scale_inplace( - img, - scale=scale, - clip_output=clip_output, - ) + if affine_v2: + out, reused = self._apply_radiometric_affine_inplace( + img, + scale=scale, + black_offset=offset, + clip_output=clip_output, + ) + else: + out, reused = self._apply_radiometric_scale_inplace( + img, + scale=scale, + clip_output=clip_output, + ) new_item = dict(item) new_meta = dict(item.get("meta", {}) or {}) @@ -5753,6 +5887,8 @@ class RawProcessorCore: new_meta["radiometric_normalization"] = debug new_meta["radiometric_normalization_applied"] = True new_meta["radiometric_normalization_scale"] = float(scale) + if affine_v2: + new_meta["radiometric_normalization_black_offset"] = float(offset) new_item["image"] = out new_item["meta"] = new_meta @@ -5775,6 +5911,12 @@ class RawProcessorCore: "inplace_fast_path": True, } + if affine_v2: + result["summary"]["black_offset_by_role"] = { + role: float(value) + for role, value in offset_by_role.items() + } + self.last_radiometric_normalization_result = result return normalized @@ -6324,8 +6466,55 @@ class RawProcessorCore: return out, reused - def _build_radiometric_debug(self, method, role, cam_id, actual_ctrl, ref_ctrl, actual_factor, ref_factor, raw_scale, scale, clip_output): - return { + def _apply_radiometric_affine_inplace( + self, + img, + scale: float, + black_offset: float, + clip_output: bool, + ): + """ + Aplica o contrato radiométrico affine_v2 no domínio RAW01: + + offset + (value - offset) * reference_factor / actual_factor + + O mesmo offset escalar é aplicado aos três canais RGB da role RGB, + conforme black_offset_model='per_role_scalar_raw01'. + """ + out, reused = self._radiometric_get_writable_float32_image(img) + + if out is None: + return img, False + + scale = np.float32(float(scale)) + offset = np.float32(float(black_offset)) + + # Para scale=1 a transformação é identidade, independentemente do offset. + if abs(float(scale) - 1.0) > 1e-6: + np.subtract(out, offset, out=out, casting="unsafe") + np.multiply(out, scale, out=out, casting="unsafe") + np.add(out, offset, out=out, casting="unsafe") + + if clip_output: + np.clip(out, 0.0, 1.0, out=out) + + return out, reused + + def _build_radiometric_debug( + self, + method, + role, + cam_id, + actual_ctrl, + ref_ctrl, + actual_factor, + ref_factor, + raw_scale, + scale, + clip_output, + black_offset=None, + ): + debug = { "applied": True, "method": method, "role": role, @@ -6339,6 +6528,16 @@ class RawProcessorCore: "clip_output": bool(clip_output), } + if black_offset is not None: + debug["black_offset_model"] = "per_role_scalar_raw01" + debug["black_offset"] = float(black_offset) + debug["formula"] = ( + "offset + (value - offset) * " + "reference_factor / actual_factor" + ) + + return debug + def _bayer_pattern_to_code_fast(self, bayer_pattern: str | None) -> int: