ajustado capture e hardware sync

This commit is contained in:
Diego Freitas 2026-09-10 15:54:28 -03:00
parent f48741f92d
commit f1e37c1dae
9 changed files with 2617 additions and 2158 deletions

View File

@ -11,9 +11,6 @@ from .radiometric_controller import RadiometricController
PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR") PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR")
PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES) PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES)
OAK_FCC3_CLIENT_VERSION = "production_v1_2026_08_24"
PRODUCT_SCHEMA = "multispec_module_params_v3"
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
class OakFcc3Client: class OakFcc3Client:
@ -44,110 +41,83 @@ class OakFcc3Client:
capture_mode="AUTO", capture_mode="AUTO",
raw_policy="allow_single", raw_policy="allow_single",
module_calibration_json=None, module_calibration_json=None,
sync_mode="best",
sync_tolerance_ms=25.0,
mx_id=None, mx_id=None,
imu_modo="rotation_vector", imu_modo="rotation_vector",
imu_freq_hz=200, imu_freq_hz=200,
evaluate_quality=True, evaluate_quality=True,
require_product_contract=False,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_mode=None,
sync_tolerance_ms=None,
buffer_size=None,
**kwargs, **kwargs,
): ):
# width/height/bayer recebidos do caller ficam apenas como fallback legado. self.width = width
# Em module_params de produção, hardware real + MP são a autoridade. self.height = height
self.legacy_width = int(width) self.bayer = bayer
self.legacy_height = int(height) self.fps = fps
self.legacy_bayer = str(bayer or "BGGR").upper() self.frame_type = frame_type
self.output_dtype = output_dtype
self.fps = float(fps) self.capture_mode = capture_mode
self.frame_type = str(frame_type).upper() self.raw_policy = raw_policy
self.output_dtype = str(output_dtype).lower()
self.capture_mode = str(capture_mode).upper()
self.raw_policy = str(raw_policy).lower()
self.module_calibration_json = module_calibration_json self.module_calibration_json = module_calibration_json
self.require_product_contract = bool(require_product_contract) self.module_params = self._load_module_params(module_calibration_json)
self.module_params = self._load_module_params(
module_calibration_json,
required=self.require_product_contract,
)
self.product_contract = self._is_product_module_params(self.module_params)
if self.require_product_contract and not self.product_contract:
raise RuntimeError(
"Contrato de produção obrigatório, mas module_params não foi "
"gerado pelo assembler oficial."
)
self.fusion_config = self.module_params.get("fusion_config", {}) or {} self.fusion_config = self.module_params.get("fusion_config", {}) or {}
self.camera_hardware = self._resolve_camera_hardware()
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
rgb_size = self.sensor_size_by_role.get(
"rgb",
[self.legacy_width, self.legacy_height],
)
self.rgb_native_width = int(rgb_size[0])
self.rgb_native_height = int(rgb_size[1])
# Mantidos por compatibilidade, mas agora significam RGB de referência.
self.width = self.rgb_native_width
self.height = self.rgb_native_height
self.bayer = self._resolve_bayer_pattern()
self.default_target_size = self._resolve_default_target_size()
self.imu_modo = str(imu_modo).strip().lower() self.imu_modo = str(imu_modo).strip().lower()
self.imu_freq_hz = int(imu_freq_hz) self.imu_freq_hz = int(imu_freq_hz)
# Auditoria radiométrica completa do Raw5.
#
# True mantém o comportamento histórico e é útil para captura científica,
# normalize/auditoria e ferramentas offline.
#
# No runtime em tempo real deve ficar False: evaluate_frame_quality()
# calcula estatísticas/percentis pesados e não faz parte da montagem
# necessária para a inferência.
self.evaluate_quality = bool(evaluate_quality) self.evaluate_quality = bool(evaluate_quality)
self.mx_id = str(mx_id) if mx_id else None self.mx_id = str(mx_id) if mx_id else None
# Validação antecipada do modo produto. O Manager também repete estes
# checks antes de abrir o hardware, de propósito.
self._validate_static_product_contract()
self.svc = OakFcc3Service( self.svc = OakFcc3Service(
timeout=10, timeout=10,
fps=self.fps, fps=fps,
width=self.width, width=width,
height=self.height, height=height,
frame_type=self.frame_type, frame_type=frame_type,
output_dtype=self.output_dtype, output_dtype=output_dtype,
capture_mode=self.capture_mode, capture_mode=capture_mode,
raw_policy=self.raw_policy, raw_policy=raw_policy,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
mx_id=self.mx_id, mx_id=self.mx_id,
module_calibration_json=module_calibration_json, module_calibration_json=module_calibration_json,
module_params=self.module_params,
require_product_contract=self.require_product_contract,
imu_modo=self.imu_modo, imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz, imu_freq_hz=self.imu_freq_hz,
hardware_sync_enabled=hardware_sync_enabled,
frame_sync_master=frame_sync_master,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
buffer_size=buffer_size,
**kwargs, **kwargs,
) )
self.applied_camera_controls = {} self.applied_camera_controls = {}
self.radiometric_controller = None self.radiometric_controller = None
self.radiometric_controller_enabled = False
self.core = RawProcessorCore( self.core = RawProcessorCore(
sensor_width=self.rgb_native_width, sensor_width=width,
sensor_height=self.rgb_native_height, sensor_height=height,
bayer_pattern=self.bayer, bayer_pattern=bayer,
calibration_json_path=module_calibration_json, calibration_json_path=module_calibration_json,
) )
# Preview é apenas visual, porém também precisa usar o Bayer/raster
# reais do RGB para não mentir sobre a AR0234.
self.preview = RawProcessorPreview( self.preview = RawProcessorPreview(
sensor_width=self.rgb_native_width, sensor_width=width,
sensor_height=self.rgb_native_height, sensor_height=height,
bayer_pattern=self.bayer, bayer_pattern=bayer,
) )
self._preview_cache = {
(self.rgb_native_width, self.rgb_native_height, self.bayer): self.preview,
}
def __enter__(self): def __enter__(self):
self.start() self.start()
@ -156,247 +126,19 @@ class OakFcc3Client:
def __exit__(self, exc_type, exc, tb): def __exit__(self, exc_type, exc, tb):
self.stop() self.stop()
def _load_module_params(self, path, required=False): def _load_module_params(self, path):
if not path: if not path or not os.path.isfile(path):
if required:
raise FileNotFoundError(
"module_params obrigatório no contrato de produção."
)
return {}
if not os.path.isfile(path):
if required:
raise FileNotFoundError(
f"module_params não encontrado: {path}"
)
return {} return {}
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
data = json.load(f) return json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"module_params root deve ser dict/object: {path}"
)
return data
@staticmethod
def _is_product_module_params(module_params):
mp = module_params or {}
assembly = mp.get("assembly_metadata", {}) or {}
return bool(
mp.get("schema") == PRODUCT_SCHEMA
and assembly.get("schema") == ASSEMBLY_SCHEMA
and isinstance(mp.get("camera_hardware"), dict)
and isinstance(mp.get("sensor_size_by_role"), dict)
and isinstance(mp.get("calibration_provenance"), dict)
)
@staticmethod
def _normalize_size(value, label):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{label} inválido: {value!r}")
return [w, h]
def _resolve_camera_hardware(self):
raw = self.module_params.get("camera_hardware", {}) or {}
if not self.product_contract:
return {}
out = {}
expected = {
"rgb": ("CAM_A", ("OV9782", "AR0234")),
"re": ("CAM_B", ("OV9282",)),
"nir": ("CAM_C", ("OV9282",)),
}
for role, (expected_socket, allowed_sensors) in expected.items():
item = raw.get(role)
if not isinstance(item, dict):
raise RuntimeError(f"camera_hardware sem role={role}")
socket = str(item.get("socket") or item.get("socket_name") or "").upper()
sensor = str(item.get("sensor") or item.get("sensor_name") or "").upper()
size = self._normalize_size(item.get("size"), f"camera_hardware.{role}.size")
if socket != expected_socket:
raise RuntimeError(
f"camera_hardware.{role}.socket={socket!r}, esperado={expected_socket!r}"
)
if sensor not in allowed_sensors:
raise RuntimeError(
f"camera_hardware.{role}.sensor={sensor!r}, permitidos={allowed_sensors}"
)
out[role] = {
"role": role,
"socket": socket,
"sensor": sensor,
"size": size,
}
return out
def _resolve_sensor_size_by_role(self):
if not self.product_contract:
return {
"rgb": [self.legacy_width, self.legacy_height],
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
raw = self.module_params.get("sensor_size_by_role", {}) or {}
out = {}
for role in ("rgb", "re", "nir"):
size = self._normalize_size(
raw.get(role),
f"sensor_size_by_role.{role}",
)
expected = self.camera_hardware[role]["size"]
if size != expected:
raise RuntimeError(
f"sensor_size_by_role.{role}={size} != camera_hardware={expected}"
)
out[role] = size
return out
def _resolve_bayer_pattern(self):
value = self.module_params.get("bayer_pattern") if self.product_contract else None
bayer = str(value or self.legacy_bayer).upper()
if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(f"bayer_pattern inválido: {bayer!r}")
return bayer
def _resolve_default_target_size(self):
value = (self.fusion_config or {}).get("target_size")
if value is None:
return None
return self._normalize_size(value, "fusion_config.target_size")
def _resolve_target_size(self, target_size):
value = target_size if target_size is not None else self.default_target_size
if value is None:
return None
size = self._normalize_size(value, "target_size")
if self.product_contract and self.default_target_size is not None:
if size != self.default_target_size:
raise RuntimeError(
"target_size solicitado diverge do module_params homologado: "
f"requested={size}, calibrated_runtime={self.default_target_size}"
)
return size
def _validate_static_product_contract(self):
if not self.product_contract:
return
if self.frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client de produção aceita somente frame_type='RAW_BRUTO'."
)
if self.raw_policy != "require_triple":
raise RuntimeError(
"OakFcc3Client de produção exige raw_policy='require_triple'."
)
if self.capture_mode not in ("TRIPLE", "AUTO"):
raise RuntimeError(
"OakFcc3Client de produção exige capture_mode TRIPLE/AUTO."
)
expected_mx = (
(self.module_params.get("calibration_provenance", {}) or {})
.get("device_mx_id")
)
if expected_mx and self.mx_id and str(expected_mx) != self.mx_id:
raise RuntimeError(
"MX ID solicitado diverge da calibração homologada: "
f"requested={self.mx_id}, calibrated={expected_mx}"
)
def _sync_contract_from_manager_status(self, status):
if not isinstance(status, dict):
return
if self.product_contract and not bool(status.get("product_contract", False)):
raise RuntimeError(
"Client carregou module_params produto, mas Manager não reconheceu o contrato."
)
sizes = status.get("sensor_size_by_role")
if isinstance(sizes, dict):
normalized = {
role: self._normalize_size(sizes.get(role), f"manager.sensor_size_by_role.{role}")
for role in ("rgb", "re", "nir")
}
if self.product_contract and normalized != self.sensor_size_by_role:
raise RuntimeError(
"Manager e Client discordam sobre sensor_size_by_role: "
f"manager={normalized}, client={self.sensor_size_by_role}"
)
self.sensor_size_by_role = normalized
bayer = status.get("bayer_pattern")
if bayer:
bayer = str(bayer).upper()
if self.product_contract and bayer != self.bayer:
raise RuntimeError(
f"Manager Bayer={bayer} != Client/module_params={self.bayer}"
)
def get_contract(self):
return {
"client_version": OAK_FCC3_CLIENT_VERSION,
"product_contract": bool(self.product_contract),
"require_product_contract": bool(self.require_product_contract),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(json.dumps(self.camera_hardware)),
"bayer_pattern": self.bayer,
"default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
}
def apply_module_camera_settings(self): def apply_module_camera_settings(self):
camera_settings = self.module_params.get("camera_settings", {}) or {} camera_settings = self.module_params.get("camera_settings", {}) or {}
if self.product_contract:
missing = [
role
for role in ("rgb", "re", "nir")
if not isinstance(camera_settings.get(role), dict)
]
if missing:
raise RuntimeError(
f"module_params produto sem camera_settings para: {missing}"
)
applied = {} applied = {}
roles = ("rgb", "re", "nir") if self.product_contract else tuple(camera_settings.keys())
for role in roles: for role, settings in camera_settings.items():
settings = camera_settings.get(role)
if not isinstance(settings, dict): if not isinstance(settings, dict):
continue continue
@ -415,43 +157,17 @@ class OakFcc3Client:
} }
self.applied_camera_controls = applied self.applied_camera_controls = applied
if self.product_contract:
failed = {
role: value
for role, value in applied.items()
if not bool((value or {}).get("ok", False))
}
if failed:
raise RuntimeError(
f"Falha reaplicando camera_settings homologado: {failed}"
)
return applied return applied
def enable_radiometric_controller(self): def enable_radiometric_controller(self):
cfg = self.module_params.get("radiometric_config", {}) or {}
enabled = bool(cfg.get("enabled", False))
# No produto atual este controller de exposição em campo é OFF.
# Não instanciamos um segundo piloto para ficar parado dentro do loop.
if not enabled:
self.radiometric_controller = None
self.radiometric_controller_enabled = False
return None
self.radiometric_controller = RadiometricController( self.radiometric_controller = RadiometricController(
client=self, client=self,
config_json_path=self.module_calibration_json, config_json_path=self.module_calibration_json,
) )
self.radiometric_controller.sync_from_camera_controls( self.radiometric_controller.sync_from_camera_controls(self.applied_camera_controls)
self.applied_camera_controls
)
self.radiometric_controller.sync_from_actual_camera_controls() self.radiometric_controller.sync_from_actual_camera_controls()
self.radiometric_controller_enabled = bool(
getattr(self.radiometric_controller, "enabled", True)
)
return self.radiometric_controller return self.radiometric_controller
def update_radiometry(self, decoded, meta=None): def update_radiometry(self, decoded, meta=None):
@ -491,45 +207,17 @@ class OakFcc3Client:
) )
try: try:
status = self.svc.get_status() self.mx_id = self.svc.manager.mx_id
self.mx_id = str(status.get("mx_id") or self.mx_id or "") or None
self._sync_contract_from_manager_status(status)
except Exception: except Exception:
# Se a validação de contrato falhar, fecha hardware antes de propagar. pass
try:
self.svc.disconnect()
except Exception:
pass
raise
# O Manager de produção já aplicou estes controles via initialControl.
# Reaplicamos após start como confirmação operacional e para manter
# compatibilidade com Managers legados durante a migração.
applied = self.apply_module_camera_settings() applied = self.apply_module_camera_settings()
self.enable_radiometric_controller()
if print_debug: if print_debug:
print("[OAK CLIENT] START:", resp) print("[OAK CLIENT] START:", resp)
print("[OAK CLIENT] CONTRACT:", self.get_contract())
print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied) print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied)
if isinstance(resp, dict): self.enable_radiometric_controller()
resp = dict(resp)
resp["client_version"] = OAK_FCC3_CLIENT_VERSION
resp["product_contract"] = bool(self.product_contract)
resp["sensor_size_by_role"] = {
role: list(size)
for role, size in self.sensor_size_by_role.items()
}
resp["bayer_pattern"] = self.bayer
resp["default_target_size"] = (
None if self.default_target_size is None
else list(self.default_target_size)
)
resp["radiometric_controller_enabled"] = bool(
self.radiometric_controller_enabled
)
return resp return resp
@ -541,29 +229,7 @@ class OakFcc3Client:
return self.svc.get_device_metrics() return self.svc.get_device_metrics()
def get_status(self): def get_status(self):
status = self.svc.get_status() return self.svc.get_status()
if not isinstance(status, dict):
status = {}
else:
status = dict(status)
status.update({
"client_version": OAK_FCC3_CLIENT_VERSION,
"client_product_contract": bool(self.product_contract),
"client_require_product_contract": bool(self.require_product_contract),
"client_sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"client_bayer_pattern": self.bayer,
"client_default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
})
return status
def get_next_raw_frame(self, timeout=1.0): def get_next_raw_frame(self, timeout=1.0):
return self.svc.capture_frame(timeout=timeout) return self.svc.capture_frame(timeout=timeout)
@ -578,12 +244,6 @@ class OakFcc3Client:
frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper() frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper()
meta = dict(raw_meta) meta = dict(raw_meta)
if self.product_contract and frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client produto recebeu frame_type não canônico: "
f"{frame_type!r}. Esperado='RAW_BRUTO'."
)
if frame_type == "RAW_BRUTO": if frame_type == "RAW_BRUTO":
decoded = self.decode_stream_cameras(raw_frame, raw_meta) decoded = self.decode_stream_cameras(raw_frame, raw_meta)
@ -680,25 +340,6 @@ class OakFcc3Client:
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None): def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
channels_expected = self._validate_physical_channel_count(channels_expected) channels_expected = self._validate_physical_channel_count(channels_expected)
target_size = self._resolve_target_size(target_size)
frame_type = str(
(meta or {}).get("frame_type", self.frame_type)
if isinstance(meta, dict)
else self.frame_type
).upper()
# No runtime quente evitamos o quality audit pesado. Decodificamos e
# usamos exatamente o mesmo caminho do CameraMultispectral.
if not self.evaluate_quality and frame_type == "RAW_BRUTO":
decoded = self.core.decode_stream_cameras(frame, meta)
return self.build_infer_tensor_from_decoded(
decoded=decoded,
meta=meta,
channels_expected=channels_expected,
target_size=target_size,
)
return self.core.build_infer_tensor_from_stream( return self.core.build_infer_tensor_from_stream(
frame, frame,
meta, meta,
@ -714,49 +355,41 @@ class OakFcc3Client:
target_size=None, target_size=None,
evaluate_quality=None, evaluate_quality=None,
): ):
"""
Monta o Raw5 físico a partir das câmeras já decodificadas.
evaluate_quality:
- None -> usa self.evaluate_quality
- True -> executa evaluate_frame_quality() e atualiza
core.last_frame_quality_result
- False -> não executa a auditoria pesada e limpa
core.last_frame_quality_result
A flag altera somente a auditoria de qualidade. Não altera decode,
radiometria, flat-field, homografia, crop/resize ou patch normalization.
"""
channels_expected = self._validate_physical_channel_count(channels_expected) channels_expected = self._validate_physical_channel_count(channels_expected)
target_size = self._resolve_target_size(target_size)
if evaluate_quality is None: if evaluate_quality is None:
evaluate_quality = self.evaluate_quality evaluate_quality = self.evaluate_quality
evaluate_quality = bool(evaluate_quality) evaluate_quality = bool(evaluate_quality)
# Core novo faz a geometria source->target em uma única etapa. tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected)
# NÃO redimensionar novamente depois da fusão. tensor = self.core.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.core.fuse_multispec_cameras(
decoded,
meta,
channels_expected,
target_size=target_size,
)
patch_cfg = getattr(
self.core,
"patch_normalization_config",
{},
) or {}
# Mantém paridade com build_infer_tensor_from_stream: se a calibração
# habilitar patch normalization, ela também vale no caminho decoded.
patch_cfg = getattr(self.core, "patch_normalization_config", {}) or {}
if bool(patch_cfg.get("enabled", False)): if bool(patch_cfg.get("enabled", False)):
if self.product_contract:
raise RuntimeError(
"patch_normalization não é permitido no contrato produto."
)
tensor = self.core.apply_patch_normalization_to_tensor(tensor) tensor = self.core.apply_patch_normalization_to_tensor(tensor)
if evaluate_quality: if evaluate_quality:
self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor) self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
else: else:
# Evita deixar resultado antigo no objeto e evita percentis no hot path. # Evita deixar um resultado antigo parecer referente ao frame atual.
self.core.last_frame_quality_result = { self.core.last_frame_quality_result = None
"status": "skipped",
"usable_for_training": None,
"reason": "disabled_by_oak_fcc3_client",
"client_version": OAK_FCC3_CLIENT_VERSION,
}
return np.ascontiguousarray( return tensor
tensor.astype(np.float32, copy=False)
)
def decode_stream_cameras(self, frame, meta): def decode_stream_cameras(self, frame, meta):
if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW": if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW":
@ -872,14 +505,10 @@ class OakFcc3Client:
arr = arr[:, :, 0] arr = arr[:, :, 0]
if bit_depth == 10 and arr.ndim == 2: if bit_depth == 10 and arr.ndim == 2:
role_size = self.sensor_size_by_role.get(
str(role).lower(),
[self.rgb_native_width, self.rgb_native_height],
)
raw16 = self.core.unpack_raw10_packed( raw16 = self.core.unpack_raw10_packed(
arr, arr,
sensor_width=int(info.get("width", role_size[0])), sensor_width=int(info.get("width", self.width)),
sensor_height=int(info.get("height", role_size[1])), sensor_height=int(info.get("height", self.height)),
) )
if role == "rgb": if role == "rgb":
@ -941,7 +570,7 @@ class OakFcc3Client:
or cam_meta.get("bayer") or cam_meta.get("bayer")
or stream_meta.get("bayer_pattern") or stream_meta.get("bayer_pattern")
or bayer_pattern or bayer_pattern
or self.bayer or "RGGB"
) )
bayer = str(bayer).upper() bayer = str(bayer).upper()
@ -973,17 +602,19 @@ class OakFcc3Client:
real_w = int(cam_meta.get("width", sensor_width)) real_w = int(cam_meta.get("width", sensor_width))
real_h = int(cam_meta.get("height", sensor_height)) real_h = int(cam_meta.get("height", sensor_height))
cache_key = (real_w, real_h, bayer) core = RawProcessorCore(
preview = self._preview_cache.get(cache_key) sensor_width=real_w,
if preview is None: sensor_height=real_h,
preview = RawProcessorPreview( bayer_pattern=bayer,
sensor_width=real_w, )
sensor_height=real_h,
bayer_pattern=bayer,
)
self._preview_cache[cache_key] = preview
raw16 = self.core.unpack_raw10_packed( preview = RawProcessorPreview(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
raw16 = core.unpack_raw10_packed(
packed, packed,
sensor_width=real_w, sensor_width=real_w,
sensor_height=real_h, sensor_height=real_h,
@ -1050,11 +681,6 @@ class OakFcc3Client:
def decode_oak_aligned_multispec(self, frame, meta): def decode_oak_aligned_multispec(self, frame, meta):
if self.product_contract:
raise RuntimeError(
"MULTISPEC alinhado pela OAK é legado e não faz parte do contrato produto."
)
""" """
Decodifica frames já alinhados pela OAK. Decodifica frames já alinhados pela OAK.
@ -1103,11 +729,6 @@ class OakFcc3Client:
return decoded return decoded
def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None): def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None):
if self.product_contract:
raise RuntimeError(
"Tensor MULTISPEC pré-alinhado pela OAK é legado no contrato produto."
)
""" """
Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia. Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia.
""" """

View File

@ -12,60 +12,27 @@ import numpy as np
from itertools import product from itertools import product
OAK_FCC3_MANAGER_VERSION = "production_v1_2026_08_24"
class OakFcc3Manager: class OakFcc3Manager:
""" """
Hardware manager de produção para OAK-FFC-3 multiespectral. Manager OAK-FFC-3 com dois fluxos principais:
Contrato oficial: 1) RAW_BRUTO
CAM_A = RGB = OV9782 1280x800 OU AR0234 1920x1200 - Mantém o comportamento antigo.
CAM_B = RE = OV9282 1280x800 - CAM_A/CAM_B/CAM_C enviam RAW10 packed direto para o PC.
CAM_C = NIR = OV9282 1280x800 - O PC faz decode, flat/radiometric, homografia/fusão/crop/resize.
Caminho oficial de produto: 2) MULTISPEC
RAW_BRUTO -> RAW10 packed nativo por câmera -> RawProcessorCore. - Câmeras sempre em 800p nativo.
- OAK aplica homografia/crop/resize via ImageManip.
O Manager não calibra imagem. Ele abre/valida hardware, aplica a política - PC recebe frames já alinhados:
inicial da câmera, sincroniza RAW e publica metadata fiel por câmera. CAM_A/rgb -> BGR uint8
CAM_B/re -> GRAY uint8
PREVIEW/MULTISPEC permanecem apenas para compatibilidade legada. CAM_C/nir -> GRAY uint8
Um module_params produzido pelo assembler oficial exige RAW_BRUTO. - O Client deve montar o tensor sem reaplicar homografia.
""" """
PRODUCT_SCHEMA = "multispec_module_params_v3" SENSOR_W = 1280
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1" SENSOR_H = 800
LEGACY_SENSOR_W = 1280
LEGACY_SENSOR_H = 800
PRODUCT_TOPOLOGY = {
"rgb": {"socket": "CAM_A", "allowed_sensors": ("OV9782", "AR0234")},
"re": {"socket": "CAM_B", "allowed_sensors": ("OV9282",)},
"nir": {"socket": "CAM_C", "allowed_sensors": ("OV9282",)},
}
SENSOR_PROFILES = {
"OV9782": {
"kind": "color",
"resolution_name": "THE_800_P",
"width": 1280,
"height": 800,
},
"AR0234": {
"kind": "color",
"resolution_name": "THE_1200_P",
"width": 1920,
"height": 1200,
},
"OV9282": {
"kind": "mono",
"resolution_name": "THE_800_P",
"width": 1280,
"height": 800,
},
}
def __init__( def __init__(
self, self,
@ -77,75 +44,41 @@ class OakFcc3Manager:
capture_mode="AUTO", capture_mode="AUTO",
raw_policy="allow_single", raw_policy="allow_single",
roles=None, roles=None,
sync_mode="best", sync_mode=None,
hardware_sync_enabled=True, hardware_sync_enabled=None,
frame_sync_master="CAM_A", frame_sync_master=None,
sync_tolerance_ms=12.0, sync_tolerance_ms=None,
buffer_size=8, buffer_size=None,
only_camera=None, only_camera=None,
mx_id=None, mx_id=None,
module_calibration_json=None, module_calibration_json=None,
module_params=None, module_params=None,
require_product_contract=False,
imu_modo="rotation_vector", imu_modo="rotation_vector",
imu_freq_hz=200, imu_freq_hz=200,
): ):
self.hardware_sync_enabled = bool(hardware_sync_enabled) self.fps = fps
self.frame_sync_master = str(frame_sync_master).upper()
self.fps = float(fps)
# width/height são apenas saída LEGADA. RAW_BRUTO usa resolução nativa. # Para compatibilidade, mantemos width/height.
# No RAW_BRUTO isso não muda o sensor, pois usamos 800p fixo.
# No MULTISPEC isso representa a saída final alinhada da OAK.
self.width = int(width) self.width = int(width)
self.height = int(height) self.height = int(height)
self.size = (self.width, self.height) self.size = (self.width, self.height)
self.sensor_width = self.SENSOR_W
self.sensor_height = self.SENSOR_H
self.frame_type = str(frame_type).upper() self.frame_type = str(frame_type).upper()
self.output_dtype = output_dtype self.output_dtype = output_dtype
self.capture_mode = str(capture_mode).upper() self.capture_mode = capture_mode
self.raw_policy = str(raw_policy).lower() self.raw_policy = raw_policy
self.only_camera = only_camera self.only_camera = only_camera
self.module_calibration_json = module_calibration_json self.roles = roles or {
self.module_params = ( "CAM_A": "rgb",
copy.deepcopy(module_params) "CAM_B": "re",
if isinstance(module_params, dict) "CAM_C": "nir",
else self._load_module_params(module_calibration_json) }
)
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
self.require_product_contract = bool(require_product_contract)
self.product_contract = self._is_product_module_params()
self.expected_device_mx_id = self._expected_device_mx_id()
if self.product_contract:
self.roles = {
contract["socket"]: role
for role, contract in self.PRODUCT_TOPOLOGY.items()
}
else:
self.roles = roles or {
"CAM_A": "rgb",
"CAM_B": "re",
"CAM_C": "nir",
}
self.expected_camera_hardware = self._resolve_expected_camera_hardware()
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
rgb_size = self.sensor_size_by_role.get(
"rgb",
[self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
)
self.sensor_width = int(rgb_size[0])
self.sensor_height = int(rgb_size[1])
self.bayer_pattern = str(
(self.module_params or {}).get("bayer_pattern", "BGGR")
).upper()
self.sync_mode = str(sync_mode).lower()
self.sync_tolerance_ms = float(sync_tolerance_ms)
self.buffer_size = int(buffer_size)
self.mx_id = str(mx_id) if mx_id else None self.mx_id = str(mx_id) if mx_id else None
self.dev_info = None self.dev_info = None
@ -157,12 +90,14 @@ class OakFcc3Manager:
self.imu_modo = self._validate_imu_modo(imu_modo) self.imu_modo = self._validate_imu_modo(imu_modo)
self.imu_freq_hz = int(imu_freq_hz) self.imu_freq_hz = int(imu_freq_hz)
if self.imu_freq_hz <= 0: if self.imu_freq_hz <= 0:
raise ValueError( raise ValueError(
f"imu_freq_hz deve ser maior que zero: {self.imu_freq_hz}" f"imu_freq_hz deve ser maior que zero: {self.imu_freq_hz}"
) )
self.imu_sensor_type = None self.imu_sensor_type = None
self.has_imu_pipeline = False self.has_imu_pipeline = False
self.tem_imu = False self.tem_imu = False
self.q_imu = None self.q_imu = None
@ -170,18 +105,32 @@ class OakFcc3Manager:
self.running = False self.running = False
self.frame_id = 0 self.frame_id = 0
# Serializa start/stop e leituras nativas de telemetria.
# Evita getChipTemperature/getUsbSpeed concorrendo com device.close().
self._device_lock = threading.RLock() self._device_lock = threading.RLock()
self.control_queues = {} self.control_queues = {}
self._last_raw_dims = {}
self.aligned_geometry = None
# Startup Profile entra ANTES do pipeline existir.
self.camera_controls = { self.camera_controls = {
cam_id: self._default_controls_for_role(role) cam_id: self._default_controls_for_role(role)
for cam_id, role in self.roles.items() for cam_id, role in self.roles.items()
} }
self._hydrate_camera_controls_from_module_params() self._last_raw_dims = {}
self._validate_static_product_contract()
self.module_calibration_json = module_calibration_json
self.module_params = module_params if isinstance(module_params, dict) else self._load_module_params(module_calibration_json)
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
self.aligned_geometry = None
sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {})
if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False)
if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A")
if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best")
if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0)
if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8)
self.hardware_sync_enabled = bool(hardware_sync_enabled)
self.frame_sync_master = str(frame_sync_master)
self.sync_mode = str(sync_mode)
self.sync_tolerance_ms = float(sync_tolerance_ms)
self.buffer_size = int(buffer_size)
self.async_capture_enabled = True self.async_capture_enabled = True
self.async_capture_mode = "latest" # latest | queue self.async_capture_mode = "latest" # latest | queue
@ -228,327 +177,11 @@ class OakFcc3Manager:
# ============================================================ # ============================================================
def _load_module_params(self, path): def _load_module_params(self, path):
if not path: if not path or not os.path.isfile(path):
return {} return {}
if not os.path.isfile(path):
raise FileNotFoundError(
f"module_params não encontrado: {path}"
)
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
data = json.load(f) return json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"module_params root deve ser dict/object: {path}"
)
return data
def _is_product_module_params(self):
mp = self.module_params or {}
assembly = mp.get("assembly_metadata", {}) or {}
return bool(
mp.get("schema") == self.PRODUCT_SCHEMA
and assembly.get("schema") == self.ASSEMBLY_SCHEMA
and isinstance(mp.get("camera_hardware"), dict)
and isinstance(mp.get("sensor_size_by_role"), dict)
and isinstance(mp.get("calibration_provenance"), dict)
)
def _expected_device_mx_id(self):
prov = (self.module_params or {}).get("calibration_provenance", {}) or {}
value = prov.get("device_mx_id")
return str(value) if value else None
def _normalize_size(self, value, *, label):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{label} inválido: {value!r}")
return [w, h]
def _resolve_expected_camera_hardware(self):
if not self.product_contract:
return {}
raw = (self.module_params or {}).get("camera_hardware", {}) or {}
out = {}
for role, contract in self.PRODUCT_TOPOLOGY.items():
item = raw.get(role)
if not isinstance(item, dict):
raise RuntimeError(
f"module_params.camera_hardware sem role={role}"
)
socket = str(item.get("socket") or "").upper()
sensor = str(item.get("sensor") or "").upper()
size = self._normalize_size(
item.get("size"),
label=f"camera_hardware.{role}.size",
)
if socket != contract["socket"]:
raise RuntimeError(
f"camera_hardware.{role}.socket={socket!r}, "
f"esperado={contract['socket']!r}"
)
if sensor not in contract["allowed_sensors"]:
raise RuntimeError(
f"camera_hardware.{role}.sensor={sensor!r}, "
f"permitidos={contract['allowed_sensors']}"
)
profile = self.SENSOR_PROFILES.get(sensor)
if profile is None:
raise RuntimeError(
f"Sensor sem profile de runtime: {sensor}"
)
native = [int(profile["width"]), int(profile["height"])]
if size != native:
raise RuntimeError(
f"camera_hardware.{role}.size={size} "
f"não corresponde ao nativo de {sensor}: {native}"
)
out[role] = {
"role": role,
"socket": socket,
"sensor": sensor,
"size": size,
"kind": profile["kind"],
"resolution_name": profile["resolution_name"],
}
return out
def _resolve_sensor_size_by_role(self):
if self.product_contract:
raw = (self.module_params or {}).get("sensor_size_by_role", {}) or {}
out = {}
for role in ("rgb", "re", "nir"):
size = self._normalize_size(
raw.get(role),
label=f"sensor_size_by_role.{role}",
)
expected = self.expected_camera_hardware[role]["size"]
if size != expected:
raise RuntimeError(
f"sensor_size_by_role.{role}={size} != "
f"camera_hardware={expected}"
)
out[role] = size
return out
return {
"rgb": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
"re": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
"nir": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
}
def _validate_static_product_contract(self):
if self.require_product_contract and not self.product_contract:
raise RuntimeError(
"Contrato de produção obrigatório, mas o module_params "
"não foi gerado pelo assembler oficial."
)
if not self.product_contract:
return
if self.frame_type != "RAW_BRUTO":
raise RuntimeError(
"module_params de produção aceita somente frame_type=RAW_BRUTO. "
f"Recebido={self.frame_type!r}"
)
if self.capture_mode not in ("TRIPLE", "AUTO"):
raise RuntimeError(
"Produto multiespectral exige capture_mode TRIPLE "
f"(ou AUTO com require_triple). Recebido={self.capture_mode!r}"
)
if self.raw_policy != "require_triple":
raise RuntimeError(
"module_params de produção exige raw_policy='require_triple'. "
f"Recebido={self.raw_policy!r}"
)
if self.only_camera is not None:
raise RuntimeError(
"only_camera não é permitido no contrato de produção."
)
if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(
f"bayer_pattern inválido no module_params: {self.bayer_pattern!r}"
)
if (
self.mx_id is not None
and self.expected_device_mx_id is not None
and self.mx_id != self.expected_device_mx_id
):
raise RuntimeError(
"MX ID solicitado diverge da calibração homologada: "
f"requested={self.mx_id}, calibrated={self.expected_device_mx_id}"
)
def _role_to_cam_id(self, role):
role = str(role).lower()
for cam_id, mapped_role in self.roles.items():
if str(mapped_role).lower() == role:
return cam_id
return None
def _hydrate_camera_controls_from_module_params(self):
settings = (self.module_params or {}).get("camera_settings", {}) or {}
if not isinstance(settings, dict):
if self.product_contract:
raise RuntimeError(
"module_params de produção sem camera_settings."
)
return
for role, cfg in settings.items():
if not isinstance(cfg, dict):
continue
cam_id = self._role_to_cam_id(role)
if cam_id is None:
continue
state = self.camera_controls.setdefault(
cam_id,
self._default_controls_for_role(role),
)
for key in (
"ae_enable",
"awb_enable",
"exposure_time_us",
"analogue_gain",
"colour_gains",
):
if key in cfg:
state[key] = copy.deepcopy(cfg[key])
if self.product_contract:
for role in ("rgb", "re", "nir"):
cam_id = self._role_to_cam_id(role)
if cam_id is None or cam_id not in self.camera_controls:
raise RuntimeError(
f"camera_settings não resolveu role={role}"
)
def _sensor_profile(self, sensor_name, role=None):
sensor = str(sensor_name or "").upper()
profile = self.SENSOR_PROFILES.get(sensor)
if profile is None:
raise RuntimeError(
f"Sensor não suportado pelo runtime: {sensor!r}"
)
if role is not None:
role = str(role).lower()
expected_kind = "color" if role == "rgb" else "mono"
if profile["kind"] != expected_kind:
raise RuntimeError(
f"Sensor {sensor} é {profile['kind']}, "
f"mas role={role} exige {expected_kind}."
)
return profile
def _feature_rows(self, features):
rows = []
for f in features:
socket = f.socket.name
sensor = str(f.sensorName or "").upper()
rows.append({
"socket": socket,
"sensor": sensor,
"width": int(getattr(f, "width", 0) or 0),
"height": int(getattr(f, "height", 0) or 0),
"role": self.roles.get(socket, "unknown"),
})
return rows
def _validate_connected_hardware(self, features):
rows = self._feature_rows(features)
by_socket = {row["socket"]: row for row in rows}
if not self.product_contract:
return rows
if (
self.expected_device_mx_id is not None
and self.mx_id is not None
and self.mx_id != self.expected_device_mx_id
):
raise RuntimeError(
"OAK conectada não corresponde ao módulo calibrado: "
f"connected={self.mx_id}, calibrated={self.expected_device_mx_id}"
)
errors = []
for role, expected in self.expected_camera_hardware.items():
row = by_socket.get(expected["socket"])
if row is None:
errors.append(f"{role}: {expected['socket']} ausente")
continue
if row["sensor"] != expected["sensor"]:
errors.append(
f"{role}: {expected['socket']} sensor={row['sensor']}, "
f"esperado={expected['sensor']}"
)
if row["width"] > 0 and row["height"] > 0:
advertised = [row["width"], row["height"]]
if advertised != expected["size"]:
errors.append(
f"{role}: {expected['socket']}/{row['sensor']} anunciou "
f"{advertised}, esperado={expected['size']}"
)
if errors:
raise RuntimeError(
"Hardware OAK não corresponde ao module_params homologado:\n - "
+ "\n - ".join(errors)
)
return rows
def _default_controls_for_role(self, role: str): def _default_controls_for_role(self, role: str):
role = str(role).lower() role = str(role).lower()
@ -579,31 +212,12 @@ class OakFcc3Manager:
with dai.Device(dev_info) as dev: with dai.Device(dev_info) as dev:
result = [] result = []
features = dev.getConnectedCameraFeatures() for f in dev.getConnectedCameraFeatures():
for row in self._feature_rows(features):
role = str(row.get("role", "unknown")).lower()
expected = self.expected_camera_hardware.get(role)
result.append({ result.append({
**row, "socket": f.socket.name,
"expected": copy.deepcopy(expected), "sensor": f.sensorName,
"matches_product_contract": ( "role": self.roles.get(f.socket.name, "unknown"),
None
if not self.product_contract
else bool(
expected is not None
and row["socket"] == expected["socket"]
and row["sensor"] == expected["sensor"]
and (
row["width"] <= 0
or row["height"] <= 0
or [row["width"], row["height"]] == expected["size"]
)
)
),
}) })
return result return result
def _device_id_from_info(self, dev_info): def _device_id_from_info(self, dev_info):
@ -677,48 +291,35 @@ class OakFcc3Manager:
def _create_camera_node_classic(self, socket, sensor_name: str, role: str): def _create_camera_node_classic(self, socket, sensor_name: str, role: str):
""" """
RAW/PREVIEW sensor-aware. Fluxo clássico.
Produto: RGB/OV9782:
OV9782 -> ColorCamera THE_800_P 1280x800 ColorCamera raw para RAW_BRUTO.
AR0234 -> ColorCamera THE_1200_P 1920x1200
OV9282 -> MonoCamera THE_800_P 1280x800
No produto não existe fallback silencioso de resolução. MONO/OV9282:
MonoCamera raw quando disponível.
""" """
sensor = str(sensor_name or "").upper() sensor_name_u = str(sensor_name or "").upper()
role = str(role or "").lower() role_u = str(role or "").lower()
if self.product_contract: is_rgb = (
profile = self._sensor_profile(sensor, role=role) role_u == "rgb"
else: or "OV9782" in sensor_name_u
profile = self.SENSOR_PROFILES.get(sensor) or socket == dai.CameraBoardSocket.CAM_A
)
if profile is None: if is_rgb:
profile = {
"kind": "color" if role == "rgb" else "mono",
"resolution_name": "THE_800_P",
"width": self.LEGACY_SENSOR_W,
"height": self.LEGACY_SENSOR_H,
}
if profile["kind"] == "color":
cam = self.pipeline.createColorCamera() cam = self.pipeline.createColorCamera()
cam.setBoardSocket(socket) cam.setBoardSocket(socket)
resolution = getattr( try:
dai.ColorCameraProperties.SensorResolution, cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_800_P)
profile["resolution_name"], except Exception:
None, try:
) cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_1080_P)
except Exception:
pass
if resolution is None:
raise RuntimeError(
f"DepthAI não expõe ColorCamera "
f"{profile['resolution_name']} para {sensor}."
)
cam.setResolution(resolution)
cam.setInterleaved(False) cam.setInterleaved(False)
cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.RGB) cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.RGB)
cam.setFps(float(self.fps)) cam.setFps(float(self.fps))
@ -730,42 +331,31 @@ class OakFcc3Manager:
except Exception: except Exception:
return cam, cam.preview return cam, cam.preview
if not hasattr(cam, "raw"):
raise RuntimeError(
f"ColorCamera {sensor} não expõe raw output."
)
return cam, cam.raw return cam, cam.raw
mono = self.pipeline.create(dai.node.MonoCamera) mono = self.pipeline.create(dai.node.MonoCamera)
mono.setBoardSocket(socket) mono.setBoardSocket(socket)
resolution = getattr( try:
dai.MonoCameraProperties.SensorResolution, mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_800_P)
profile["resolution_name"], except Exception:
None, try:
) mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_720_P)
except Exception:
try:
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P)
except Exception:
pass
if resolution is None:
raise RuntimeError(
f"DepthAI não expõe MonoCamera "
f"{profile['resolution_name']} para {sensor}."
)
mono.setResolution(resolution)
mono.setFps(float(self.fps)) mono.setFps(float(self.fps))
if self._is_preview_mode(): if self._is_preview_mode():
return mono, mono.out return mono, mono.out
if not hasattr(mono, "raw"): if hasattr(mono, "raw"):
if self.product_contract: return mono, mono.raw
raise RuntimeError(
f"MonoCamera {sensor} não expõe raw output."
)
return mono, mono.out
return mono, mono.raw return mono, mono.out
def _create_imu_node(self, pipeline): def _create_imu_node(self, pipeline):
self.has_imu_pipeline = False self.has_imu_pipeline = False
@ -1306,13 +896,6 @@ class OakFcc3Manager:
self.pipeline = dai.Pipeline() self.pipeline = dai.Pipeline()
features = self.device.getConnectedCameraFeatures() features = self.device.getConnectedCameraFeatures()
self._validate_connected_hardware(features)
if self.product_contract and self._is_multispec_mode():
raise RuntimeError(
"MULTISPEC alinhado na OAK é legado e não faz parte do "
"contrato de produção. Use RAW_BRUTO."
)
self.queues.clear() self.queues.clear()
self.buffers.clear() self.buffers.clear()
@ -1360,44 +943,11 @@ class OakFcc3Manager:
self.buffers[cam_id] = deque(maxlen=self.buffer_size) self.buffers[cam_id] = deque(maxlen=self.buffer_size)
self.control_queues[cam_id] = None self.control_queues[cam_id] = None
sensor_u = str(f.sensorName or "").upper()
if self.product_contract or sensor_u in self.SENSOR_PROFILES:
profile = self._sensor_profile(sensor_u, role=role)
else:
profile = {
"kind": "unknown",
"width": int(getattr(f, "width", 0) or self.LEGACY_SENSOR_W),
"height": int(getattr(f, "height", 0) or self.LEGACY_SENSOR_H),
"resolution_name": None,
}
self.camera_info[cam_id] = { self.camera_info[cam_id] = {
"id": cam_id, "id": cam_id,
"socket": socket_name, "socket": socket_name,
"sensor": sensor_u, "sensor": f.sensorName,
"role": role, "role": role,
"native_width": int(profile["width"]),
"native_height": int(profile["height"]),
"native_size": [
int(profile["width"]),
int(profile["height"]),
],
"sensor_kind": profile["kind"],
"resolution_mode": profile.get("resolution_name"),
"bayer_pattern": (
self.bayer_pattern
if str(role).lower() == "rgb"
else None
),
"product_expected": (
copy.deepcopy(
self.expected_camera_hardware.get(
str(role).lower()
)
)
if self.product_contract
else None
),
} }
self._validate_capture_mode() self._validate_capture_mode()
@ -1599,14 +1149,6 @@ class OakFcc3Manager:
"height": self.height, "height": self.height,
"sensor_width": self.sensor_width, "sensor_width": self.sensor_width,
"sensor_height": self.sensor_height, "sensor_height": self.sensor_height,
"sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role),
"camera_hardware_expected": copy.deepcopy(self.expected_camera_hardware),
"bayer_pattern": self.bayer_pattern,
"product_contract": bool(self.product_contract),
"require_product_contract": bool(self.require_product_contract),
"manager_version": OAK_FCC3_MANAGER_VERSION,
"module_params_schema": (self.module_params or {}).get("schema"),
"module_calibration_json": self.module_calibration_json,
"frame_type": self.frame_type, "frame_type": self.frame_type,
"output_dtype": self.output_dtype, "output_dtype": self.output_dtype,
"capture_mode": self.capture_mode, "capture_mode": self.capture_mode,
@ -2184,32 +1726,6 @@ class OakFcc3Manager:
item["shape"] = list(arr.shape) item["shape"] = list(arr.shape)
item["dtype"] = str(arr.dtype) item["dtype"] = str(arr.dtype)
item["packed"] = True item["packed"] = True
item["native_width"] = int(
item.get("native_width", item["width"])
)
item["native_height"] = int(
item.get("native_height", item["height"])
)
item["native_size"] = [
item["native_width"],
item["native_height"],
]
if str(item.get("role", "")).lower() == "rgb":
item["bayer_pattern"] = self.bayer_pattern
if self.product_contract:
role = str(item.get("role", "")).lower()
expected = self.expected_camera_hardware.get(role)
if expected is not None:
actual = [int(item["width"]), int(item["height"])]
if actual != expected["size"]:
raise RuntimeError(
f"{cam_id}/{role}: RAW frame {actual} != "
f"hardware homologado {expected['size']}"
)
camera_info[cam_id] = item camera_info[cam_id] = item
@ -2217,18 +1733,6 @@ class OakFcc3Manager:
"frame_id": self.frame_id, "frame_id": self.frame_id,
"backend": "oak_fcc3", "backend": "oak_fcc3",
"frame_type": self.frame_type, "frame_type": self.frame_type,
"product_contract": bool(self.product_contract),
"module_params_schema": (self.module_params or {}).get("schema"),
"module_calibration_json": self.module_calibration_json,
"device_mx_id": self.mx_id,
"reference_camera": "rgb",
"sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role),
"camera_hardware": copy.deepcopy(
self.expected_camera_hardware
if self.product_contract
else {}
),
"bayer_pattern": self.bayer_pattern,
"capture_mode": self.capture_mode, "capture_mode": self.capture_mode,
"output_dtype": self.output_dtype, "output_dtype": self.output_dtype,
"dtype": self.output_dtype, "dtype": self.output_dtype,
@ -2417,65 +1921,18 @@ class OakFcc3Manager:
return result return result
def apply_initial_camera_controls_to_node(self, cam, cam_id): def apply_initial_camera_controls_to_node(self, cam, cam_id):
"""
Aplica Startup Profile antes de startPipeline().
O Client pode reaplicar depois do start como confirmação, mas os
primeiros frames já nascem sob a política homologada.
"""
ctrl_state = self.camera_controls.get(cam_id, {}) ctrl_state = self.camera_controls.get(cam_id, {})
ae = bool(ctrl_state.get("ae_enable", False)) ae = bool(ctrl_state.get("ae_enable", False))
awb = bool(ctrl_state.get("awb_enable", False))
exp_us = int(ctrl_state.get("exposure_time_us") or 15000) exp_us = int(ctrl_state.get("exposure_time_us") or 15000)
gain = float(ctrl_state.get("analogue_gain") or 1.0) gain = float(ctrl_state.get("analogue_gain") or 1.0)
if ae: if not ae:
fn = getattr(cam.initialControl, "setAutoExposureEnable", None)
if callable(fn):
fn()
elif self.product_contract:
raise RuntimeError(
f"{cam_id}: DepthAI sem setAutoExposureEnable no initialControl."
)
else:
cam.initialControl.setManualExposure( cam.initialControl.setManualExposure(
exp_us, exp_us,
self._gain_to_iso(gain), self._gain_to_iso(gain),
) )
role = str(self.roles.get(cam_id, "")).lower()
if role == "rgb":
if awb:
fn = getattr(
cam.initialControl,
"setAutoWhiteBalanceLock",
None,
)
if callable(fn):
fn(False)
else:
try:
cam.initialControl.setAutoWhiteBalanceMode(
dai.CameraControl.AutoWhiteBalanceMode.AUTO
)
except Exception:
if self.product_contract:
raise RuntimeError(
f"{cam_id}: não foi possível habilitar AWB inicial."
)
else:
fn = getattr(
cam.initialControl,
"setAutoWhiteBalanceLock",
None,
)
if callable(fn):
fn(True)
def _send_control(self, cam_id, ctrl): def _send_control(self, cam_id, ctrl):
if cam_id not in self.control_queues: if cam_id not in self.control_queues:
raise RuntimeError(f"Fila de controle não existe para {cam_id}") raise RuntimeError(f"Fila de controle não existe para {cam_id}")

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,8 @@ arquivo que só deve existir ao final da linha de calibração.
Responsabilidades: Responsabilidades:
- selecionar e descobrir o dispositivo DepthAI; - selecionar e descobrir o dispositivo DepthAI;
- validar CAM_A RGB + CAM_B RE + CAM_C NIR; - validar CAM_A RGB + CAM_B RE + CAM_C NIR;
- resolver/confirmar Bayer da RGB usando preview RAW10; - resolver/confirmar Bayer da RGB usando o RawProcessorPreview oficial;
- confirmar a orientação física/canônica das três câmeras;
- registrar o domínio de decode RGB escolhido; - registrar o domínio de decode RGB escolhido;
- criar a pasta oficial do perfil (ex.: calibration/mp_ar0234); - criar a pasta oficial do perfil (ex.: calibration/mp_ar0234);
- declarar caminhos oficiais para todas as calibrações seguintes; - declarar caminhos oficiais para todas as calibrações seguintes;
@ -32,6 +33,11 @@ Exemplos:
Teclas no preview: Teclas no preview:
B alterna Bayer RGB B alterna Bayer RGB
1/2/3 seleciona RGB/RE/NIR
R gira a câmera selecionada 90 graus no sentido horário
H alterna flip horizontal da câmera selecionada
V alterna flip vertical da câmera selecionada
D restaura orientação padrão da câmera selecionada
S salva snapshot S salva snapshot
ENTER confirma e promove o profile ENTER confirma e promove o profile
Q/ESC cancela sem alterar o profile ativo Q/ESC cancela sem alterar o profile ativo
@ -55,6 +61,8 @@ import cv2
import depthai as dai import depthai as dai
import numpy as np import numpy as np
from core.raw_processor_preview import RawProcessorPreview
SCHEMA = "multispec_module_profile_v1" SCHEMA = "multispec_module_profile_v1"
SELECTOR_SCHEMA = "multispec_active_module_profile_v1" SELECTOR_SCHEMA = "multispec_active_module_profile_v1"
@ -256,6 +264,8 @@ class CameraSpec:
resolution_name: str resolution_name: str
is_color: bool is_color: bool
preview_rotate_deg: int preview_rotate_deg: int
preview_flip_horizontal: bool
preview_flip_vertical: bool
stream_name: str stream_name: str
@ -302,6 +312,8 @@ def validate_and_build_specs(rows: list[dict]) -> Dict[str, CameraSpec]:
resolution_name=str(cfg["resolution_enum"]), resolution_name=str(cfg["resolution_enum"]),
is_color=(role == "rgb"), is_color=(role == "rgb"),
preview_rotate_deg=int(cfg["preview_rotate_deg"]), preview_rotate_deg=int(cfg["preview_rotate_deg"]),
preview_flip_horizontal=False,
preview_flip_vertical=False,
stream_name=f"profile_{role}", stream_name=f"profile_{role}",
) )
@ -364,77 +376,12 @@ def build_preview_pipeline(specs: Dict[str, CameraSpec], fps: float):
return pipeline return pipeline
def unpack_raw10(data, width: int, height: int, stride: Optional[int] = None): def rgb_raw_preview(
width = int(width) pkt,
height = int(height) spec: CameraSpec,
if width <= 0 or height <= 0 or width % 4: processor: RawProcessorPreview,
raise ValueError(f"Dimensão RAW10 inválida: {width}x{height}") ):
"""Preview RGB oficial; não mantém um segundo mini-ISP neste script."""
raw = np.asarray(data, dtype=np.uint8).reshape(-1)
payload_bytes = (width // 4) * 5
candidates = []
if stride is not None:
try:
candidates.append(int(stride))
except Exception:
pass
if height > 0 and raw.size % height == 0:
candidates.append(raw.size // height)
candidates.append(payload_bytes)
row_stride = next(
(x for x in candidates if x >= payload_bytes and x * height <= raw.size),
None,
)
if row_stride is None:
raise ValueError(
f"RAW10 curto: bytes={raw.size}, payload mínimo={payload_bytes*height}"
)
rows = raw[: row_stride * height].reshape(height, row_stride)
groups = rows[:, :payload_bytes].reshape(height, width // 4, 5)
out = np.empty((height, width // 4, 4), dtype=np.uint16)
out[:, :, 0] = (
groups[:, :, 0].astype(np.uint16) << 2
) | (groups[:, :, 4] & 0x03)
out[:, :, 1] = (
groups[:, :, 1].astype(np.uint16) << 2
) | ((groups[:, :, 4] >> 2) & 0x03)
out[:, :, 2] = (
groups[:, :, 2].astype(np.uint16) << 2
) | ((groups[:, :, 4] >> 4) & 0x03)
out[:, :, 3] = (
groups[:, :, 3].astype(np.uint16) << 2
) | ((groups[:, :, 4] >> 6) & 0x03)
return np.ascontiguousarray(out.reshape(height, width))
def bayer_code(pattern: str, algorithm: str):
pattern = str(pattern).upper()
algorithm = str(algorithm).lower()
if algorithm == "ea":
table = {
"BGGR": cv2.COLOR_BayerRG2RGB_EA,
"RGGB": cv2.COLOR_BayerBG2RGB_EA,
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
}
else:
table = {
"BGGR": cv2.COLOR_BayerRG2RGB,
"RGGB": cv2.COLOR_BayerBG2RGB,
"GRBG": cv2.COLOR_BayerGR2RGB,
"GBRG": cv2.COLOR_BayerGB2RGB,
}
return table[pattern]
def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str):
raw_type = str(pkt.getType()).upper() raw_type = str(pkt.getType()).upper()
if "PACK10" not in raw_type and "RAW10" not in raw_type: if "PACK10" not in raw_type and "RAW10" not in raw_type:
raise RuntimeError( raise RuntimeError(
@ -454,31 +401,50 @@ def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str):
except Exception: except Exception:
stride = None stride = None
raw16 = unpack_raw10(pkt.getData(), width, height, stride) packed_data = np.asarray(pkt.getData(), dtype=np.uint8).reshape(-1)
rgb16 = cv2.cvtColor(raw16, bayer_code(pattern, algorithm)) payload_per_row = (width // 4) * 5
rgb = np.clip(rgb16.astype(np.float32) / 1023.0, 0.0, 1.0) if (
stride is None
or stride < payload_per_row
or stride * height > packed_data.size
):
# Algumas versões do DepthAI reportam getStride() no domínio dos
# pixels, não dos bytes PACK10. Nesse caso o tamanho real vence.
stride = None
# Stretch comum aos canais: melhora a inspeção sem alterar o balanço RGB. raw16 = processor.unpack_raw10_packed(
sample = rgb[::8, ::8] packed_data,
lo = float(np.percentile(sample, 0.5)) stride=stride,
hi = float(np.percentile(sample, 99.5)) )
if hi <= lo + 1e-6: return processor.raw16_to_preview_bgr(raw16)
hi = lo + 1e-6
rgb = np.clip((rgb - lo) / (hi - lo), 0.0, 1.0)
# OpenCV exibe BGR.
return np.ascontiguousarray((rgb[:, :, ::-1] * 255.0).astype(np.uint8))
def apply_preview_orientation(img: np.ndarray, rotate_deg: int): def normalize_orientation(cfg: dict) -> dict:
rotate_deg = int(rotate_deg) % 360 rotate_deg = int((cfg or {}).get("rotate_deg", 0)) % 360
if rotate_deg not in (0, 90, 180, 270):
raise ValueError(f"Rotação inválida: {rotate_deg}")
return {
"rotate_deg": rotate_deg,
"flip_horizontal": bool((cfg or {}).get("flip_horizontal", False)),
"flip_vertical": bool((cfg or {}).get("flip_vertical", False)),
"source": str((cfg or {}).get("source") or "unknown"),
}
def apply_preview_orientation(img: np.ndarray, cfg: dict):
cfg = normalize_orientation(cfg)
rotate_deg = cfg["rotate_deg"]
if rotate_deg == 90: if rotate_deg == 90:
return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
if rotate_deg == 180: elif rotate_deg == 180:
return cv2.rotate(img, cv2.ROTATE_180) img = cv2.rotate(img, cv2.ROTATE_180)
if rotate_deg == 270: elif rotate_deg == 270:
return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
return img if cfg["flip_horizontal"]:
img = cv2.flip(img, 1)
if cfg["flip_vertical"]:
img = cv2.flip(img, 0)
return np.ascontiguousarray(img)
def overlay(img, lines, x=12, y=24, scale=0.48, step=21): def overlay(img, lines, x=12, y=24, scale=0.48, step=21):
@ -499,6 +465,7 @@ def preview_and_confirm(
dev_info, dev_info,
specs: Dict[str, CameraSpec], specs: Dict[str, CameraSpec],
initial_bayer: str, initial_bayer: str,
initial_orientation: Dict[str, dict],
algorithm: str, algorithm: str,
fps: float, fps: float,
panel_width: int, panel_width: int,
@ -506,8 +473,25 @@ def preview_and_confirm(
snapshots_dir: Path, snapshots_dir: Path,
): ):
pattern = initial_bayer pattern = initial_bayer
orientation = {
role: normalize_orientation(initial_orientation[role])
for role in ROLES
}
orientation_defaults = {
role: dict(orientation[role])
for role in ROLES
}
selected_role = "rgb"
preview_processors = {
candidate: RawProcessorPreview(
sensor_width=specs["rgb"].width,
sensor_height=specs["rgb"].height,
bayer_pattern=candidate,
)
for candidate in BAYER_PATTERNS
}
pipeline = build_preview_pipeline(specs, fps) pipeline = build_preview_pipeline(specs, fps)
window = "Module Profile - Bayer Confirmation" window = "Module Profile - Physical Sanity Check"
cv2.namedWindow(window, cv2.WINDOW_NORMAL) cv2.namedWindow(window, cv2.WINDOW_NORMAL)
try: try:
@ -538,7 +522,11 @@ def preview_and_confirm(
spec = specs[role] spec = specs[role]
pkt = latest[role] pkt = latest[role]
if role == "rgb": if role == "rgb":
view = rgb_raw_preview(pkt, spec, pattern, algorithm) view = rgb_raw_preview(
pkt,
spec,
preview_processors[pattern],
)
else: else:
view = pkt.getCvFrame() view = pkt.getCvFrame()
if view.ndim == 2: if view.ndim == 2:
@ -546,7 +534,7 @@ def preview_and_confirm(
view = apply_preview_orientation( view = apply_preview_orientation(
view, view,
spec.preview_rotate_deg, orientation[role],
) )
panel = cv2.resize( panel = cv2.resize(
view, view,
@ -554,16 +542,33 @@ def preview_and_confirm(
interpolation=cv2.INTER_AREA, interpolation=cv2.INTER_AREA,
) )
lines = [ lines = [
f"{role.upper()} | {spec.socket} | {spec.sensor}", (
f"> {role.upper()} | {spec.socket} | {spec.sensor}"
if role == selected_role
else f" {role.upper()} | {spec.socket} | {spec.sensor}"
),
f"native={spec.width}x{spec.height}", f"native={spec.width}x{spec.height}",
f"packet={latest_type[role]}", f"packet={latest_type[role]}",
(
f"ROT={orientation[role]['rotate_deg']} "
f"FH={int(orientation[role]['flip_horizontal'])} "
f"FV={int(orientation[role]['flip_vertical'])}"
),
] ]
if role == "rgb": if role == "rgb":
lines += [ lines += [
f"BAYER={pattern} | DEMOSAIC={algorithm}", f"BAYER={pattern} | PREVIEW=RawProcessorPreview",
"Confira vermelho/azul em objeto conhecido", "Confira vermelho/azul em objeto conhecido",
] ]
overlay(panel, lines) overlay(panel, lines)
if role == selected_role:
cv2.rectangle(
panel,
(2, 2),
(panel_width - 3, panel_height - 3),
(0, 255, 255),
4,
)
panels.append(panel) panels.append(panel)
info = np.zeros( info = np.zeros(
@ -575,14 +580,19 @@ def preview_and_confirm(
"", "",
f"RGB Bayer selecionado: {pattern}", f"RGB Bayer selecionado: {pattern}",
f"RGB processing: linear_demosaic / {algorithm}", f"RGB processing: linear_demosaic / {algorithm}",
f"Camera selecionada: {selected_role.upper()}",
"", "",
"B = alternar Bayer", "B = alternar Bayer",
"1/2/3 = selecionar RGB/RE/NIR",
"R = girar 90 graus horario",
"H/V = flip horizontal/vertical",
"D = restaurar orientacao padrao",
"S = snapshot", "S = snapshot",
"ENTER = confirmar e promover", "ENTER = confirmar e promover",
"Q/ESC = cancelar", "Q/ESC = cancelar",
"", "",
"Use um objeto vermelho e outro azul", "Confirme cores e o mesmo sentido fisico",
"para confirmar que R/B não estão trocados.", "nas tres cameras antes de pressionar ENTER.",
]) ])
last_board = np.vstack([ last_board = np.vstack([
@ -597,21 +607,48 @@ def preview_and_confirm(
key = cv2.waitKey(1) & 0xFF key = cv2.waitKey(1) & 0xFF
if key in (ord("q"), ord("Q"), 27): if key in (ord("q"), ord("Q"), 27):
raise KeyboardInterrupt("Cancelado durante confirmação do Bayer.") raise KeyboardInterrupt("Cancelado durante sanity check físico.")
if key == ord("1"):
selected_role = "rgb"
elif key == ord("2"):
selected_role = "re"
elif key == ord("3"):
selected_role = "nir"
if key in (ord("b"), ord("B")): if key in (ord("b"), ord("B")):
idx = (BAYER_PATTERNS.index(pattern) + 1) % len(BAYER_PATTERNS) idx = (BAYER_PATTERNS.index(pattern) + 1) % len(BAYER_PATTERNS)
pattern = BAYER_PATTERNS[idx] pattern = BAYER_PATTERNS[idx]
if key in (ord("r"), ord("R")):
orientation[selected_role]["rotate_deg"] = (
int(orientation[selected_role]["rotate_deg"]) + 90
) % 360
orientation[selected_role]["source"] = "raw_preview_confirmed"
if key in (ord("h"), ord("H")):
orientation[selected_role]["flip_horizontal"] = not bool(
orientation[selected_role]["flip_horizontal"]
)
orientation[selected_role]["source"] = "raw_preview_confirmed"
if key in (ord("v"), ord("V")):
orientation[selected_role]["flip_vertical"] = not bool(
orientation[selected_role]["flip_vertical"]
)
orientation[selected_role]["source"] = "raw_preview_confirmed"
if key in (ord("d"), ord("D")):
orientation[selected_role] = dict(
orientation_defaults[selected_role]
)
if key in (ord("s"), ord("S")) and last_board is not None: if key in (ord("s"), ord("S")) and last_board is not None:
ensure_dir(snapshots_dir) ensure_dir(snapshots_dir)
cv2.imwrite( cv2.imwrite(
str(snapshots_dir / f"bayer_{pattern}_{stamp()}.png"), str(snapshots_dir / f"physical_check_{pattern}_{stamp()}.png"),
last_board, last_board,
) )
if key in (10, 13) and last_board is not None: if key in (10, 13) and last_board is not None:
ensure_dir(snapshots_dir) ensure_dir(snapshots_dir)
confirmed_path = snapshots_dir / "bayer_confirmed.png" for role in ROLES:
orientation[role]["source"] = "raw_preview_confirmed"
confirmed_path = snapshots_dir / "physical_sanity_confirmed.png"
cv2.imwrite(str(confirmed_path), last_board) cv2.imwrite(str(confirmed_path), last_board)
return pattern, confirmed_path return pattern, orientation, confirmed_path
time.sleep(0.002) time.sleep(0.002)
finally: finally:
@ -625,9 +662,9 @@ def artifact_paths(profile_dir: Path) -> dict:
"intrinsics_json": norm_path(profile_dir / "intrinsics_calibration_v1.json"), "intrinsics_json": norm_path(profile_dir / "intrinsics_calibration_v1.json"),
"flatfield_npz": norm_path(profile_dir / "flatfield_maps_v1.npz"), "flatfield_npz": norm_path(profile_dir / "flatfield_maps_v1.npz"),
"flatfield_json": norm_path(profile_dir / "flatfield_maps_v1.json"), "flatfield_json": norm_path(profile_dir / "flatfield_maps_v1.json"),
"radiometry_json": norm_path(profile_dir / "radiometry_calibration_v1.json"), "radiometry_json": norm_path(profile_dir / "radiometry_calibration_v5.json"),
"camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v1.json"), "camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v3.json"),
"homography_json": norm_path(profile_dir / "homography_calibration_v1.json"), "homography_json": norm_path(profile_dir / "homography_calibration_v4.json"),
"module_params_json": norm_path(profile_dir / "module_params.json"), "module_params_json": norm_path(profile_dir / "module_params.json"),
"module_params_assembly_report_json": norm_path( "module_params_assembly_report_json": norm_path(
profile_dir / "module_params_assembly_report.json" profile_dir / "module_params_assembly_report.json"
@ -652,19 +689,27 @@ def build_profile(
profile_name: str, profile_name: str,
profile_dir: Path, profile_dir: Path,
bayer: str, bayer: str,
preview_orientation: Dict[str, dict],
confirmed_with_raw_preview: bool, confirmed_with_raw_preview: bool,
confirmation_snapshot: Optional[Path], confirmation_snapshot: Optional[Path],
): ):
orientation = { orientation = {
role: { role: normalize_orientation(preview_orientation[role])
"rotate_deg": int(specs[role].preview_rotate_deg),
"flip_horizontal": False,
"flip_vertical": False,
"source": "sensor_default",
}
for role in ROLES for role in ROLES
} }
resolved_setup = {}
for role in ROLES:
item = asdict(specs[role])
item["preview_rotate_deg"] = int(orientation[role]["rotate_deg"])
item["preview_flip_horizontal"] = bool(
orientation[role]["flip_horizontal"]
)
item["preview_flip_vertical"] = bool(
orientation[role]["flip_vertical"]
)
resolved_setup[role] = item
hardware_signature = { hardware_signature = {
role: { role: {
"socket": specs[role].socket, "socket": specs[role].socket,
@ -686,10 +731,7 @@ def build_profile(
"depthai_version": getattr(dai, "__version__", "unknown"), "depthai_version": getattr(dai, "__version__", "unknown"),
"usb_speed": usb_speed, "usb_speed": usb_speed,
"hardware_signature": hardware_signature, "hardware_signature": hardware_signature,
"resolved_setup": { "resolved_setup": resolved_setup,
role: asdict(specs[role])
for role in ROLES
},
"camera_inventory": rows, "camera_inventory": rows,
"rgb_decode": { "rgb_decode": {
"bayer_pattern": bayer, "bayer_pattern": bayer,
@ -708,9 +750,23 @@ def build_profile(
), ),
}, },
"preview_orientation_defaults": orientation, "preview_orientation_defaults": orientation,
"mounting_sanity_check": {
"confirmed_with_live_preview": bool(confirmed_with_raw_preview),
"preview_engine": "core.raw_processor_preview.RawProcessorPreview",
"orientation_by_role": orientation,
"confirmation_snapshot": (
norm_path(confirmation_snapshot)
if confirmation_snapshot is not None
else None
),
},
"geometry_bootstrap": { "geometry_bootstrap": {
"native_space": "native_stream_no_external_undistort", "native_space": "native_stream_no_external_undistort",
"orientation_status": "preview_default_until_homography", "orientation_status": (
"raw_preview_confirmed_until_homography"
if confirmed_with_raw_preview
else "default_or_cli_headless_until_homography"
),
"note": ( "note": (
"A Homography homologada será a autoridade final da orientação " "A Homography homologada será a autoridade final da orientação "
"científica e do espaço canônico." "científica e do espaço canônico."
@ -730,6 +786,15 @@ def build_profile(
"mode": args.rgb_processing_mode, "mode": args.rgb_processing_mode,
"demosaic_algorithm": args.demosaic_algorithm, "demosaic_algorithm": args.demosaic_algorithm,
}, },
"camera_orientation_bootstrap": {
"enabled": True,
"input_space": "native_stream_no_external_undistort",
"output_space": (
"canonical_oriented_stream_no_external_undistort"
),
"by_role": orientation,
"authority": "module_profile_until_homography",
},
}, },
"artifact_paths": artifact_paths(profile_dir), "artifact_paths": artifact_paths(profile_dir),
"traceability": { "traceability": {
@ -740,6 +805,13 @@ def build_profile(
}, },
"notes": args.notes or "", "notes": args.notes or "",
}, },
"capture_synchronization": {
"hardware_sync_enabled": bool(args.hardware_sync_enabled),
"frame_sync_master": args.frame_sync_master,
"software_sync_mode": "best",
"sync_tolerance_ms": 12.0,
"buffer_size": 8,
},
} }
@ -789,6 +861,29 @@ def backup_if_exists(path: Path):
return None return None
def resolve_initial_orientation(
specs: Dict[str, CameraSpec],
args,
) -> Dict[str, dict]:
result = {}
for role in ROLES:
rotate_override = getattr(args, f"{role}_preview_rotate_deg")
flip_h = bool(getattr(args, f"{role}_preview_flip_horizontal"))
flip_v = bool(getattr(args, f"{role}_preview_flip_vertical"))
has_cli_override = rotate_override is not None or flip_h or flip_v
result[role] = normalize_orientation({
"rotate_deg": (
specs[role].preview_rotate_deg
if rotate_override is None
else rotate_override
),
"flip_horizontal": flip_h,
"flip_vertical": flip_v,
"source": "cli" if has_cli_override else "sensor_default",
})
return result
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Bootstrap do profile físico/decode do módulo multiespectral.", description="Bootstrap do profile físico/decode do módulo multiespectral.",
@ -826,6 +921,25 @@ def main():
parser.add_argument("--fps", type=float, default=10.0) parser.add_argument("--fps", type=float, default=10.0)
parser.add_argument("--panel-width", type=int, default=640) parser.add_argument("--panel-width", type=int, default=640)
parser.add_argument("--panel-height", type=int, default=400) parser.add_argument("--panel-height", type=int, default=400)
for role in ROLES:
parser.add_argument(
f"--{role}-preview-rotate-deg",
type=int,
default=None,
choices=[0, 90, 180, 270],
help=(
f"Orientação inicial de {role.upper()}; pode ser ajustada "
"interativamente no preview."
),
)
parser.add_argument(
f"--{role}-preview-flip-horizontal",
action="store_true",
)
parser.add_argument(
f"--{role}-preview-flip-vertical",
action="store_true",
)
parser.add_argument( parser.add_argument(
"--no-preview", "--no-preview",
action="store_true", action="store_true",
@ -841,6 +955,16 @@ def main():
parser.add_argument("--lens-re", default="") parser.add_argument("--lens-re", default="")
parser.add_argument("--lens-nir", default="") parser.add_argument("--lens-nir", default="")
parser.add_argument("--notes", default="") parser.add_argument("--notes", default="")
parser.add_argument(
"--hardware-sync-enabled",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--frame-sync-master",
default="CAM_A",
choices=["CAM_A", "CAM_B", "CAM_C"],
)
args = parser.parse_args() args = parser.parse_args()
if args.no_preview and not args.yes: if args.no_preview and not args.yes:
@ -849,6 +973,7 @@ def main():
dev_info = select_device_info(args.mx_id) dev_info = select_device_info(args.mx_id)
rows, actual_mx, usb_speed = discover(dev_info) rows, actual_mx, usb_speed = discover(dev_info)
specs = validate_and_build_specs(rows) specs = validate_and_build_specs(rows)
selected_orientation = resolve_initial_orientation(specs, args)
rgb_sensor = specs["rgb"].sensor rgb_sensor = specs["rgb"].sensor
default_profile_name = f"mp_{rgb_sensor.lower()}" default_profile_name = f"mp_{rgb_sensor.lower()}"
@ -898,6 +1023,13 @@ def main():
f"RGB decode : Bayer={selected_bayer} ({bayer_initial_source}) | " f"RGB decode : Bayer={selected_bayer} ({bayer_initial_source}) | "
f"mode={args.rgb_processing_mode} | algo={args.demosaic_algorithm}" f"mode={args.rgb_processing_mode} | algo={args.demosaic_algorithm}"
) )
for role in ROLES:
orient = selected_orientation[role]
print(
f"{role.upper():3s} orient. : rotate={orient['rotate_deg']:3d} | "
f"flip_h={orient['flip_horizontal']} | "
f"flip_v={orient['flip_vertical']} | source={orient['source']}"
)
print("=" * 88) print("=" * 88)
confirmed_with_raw_preview = False confirmed_with_raw_preview = False
@ -905,10 +1037,15 @@ def main():
try: try:
if not args.no_preview: if not args.no_preview:
selected_bayer, confirmation_snapshot = preview_and_confirm( (
selected_bayer,
selected_orientation,
confirmation_snapshot,
) = preview_and_confirm(
dev_info=dev_info, dev_info=dev_info,
specs=specs, specs=specs,
initial_bayer=selected_bayer, initial_bayer=selected_bayer,
initial_orientation=selected_orientation,
algorithm=args.demosaic_algorithm, algorithm=args.demosaic_algorithm,
fps=args.fps, fps=args.fps,
panel_width=args.panel_width, panel_width=args.panel_width,
@ -926,6 +1063,7 @@ def main():
profile_name=profile_name, profile_name=profile_name,
profile_dir=profile_dir, profile_dir=profile_dir,
bayer=selected_bayer, bayer=selected_bayer,
preview_orientation=selected_orientation,
confirmed_with_raw_preview=confirmed_with_raw_preview, confirmed_with_raw_preview=confirmed_with_raw_preview,
confirmation_snapshot=confirmation_snapshot, confirmation_snapshot=confirmation_snapshot,
) )
@ -965,6 +1103,14 @@ def main():
print(f"[SELECTOR] {active_selector}") print(f"[SELECTOR] {active_selector}")
print(f"[SHA256] {selector['profile_sha256']}") print(f"[SHA256] {selector['profile_sha256']}")
print(f"[BAYER] {selected_bayer}") print(f"[BAYER] {selected_bayer}")
for role in ROLES:
orient = selected_orientation[role]
print(
f"[ORIENTATION] {role.upper():3s} | "
f"rotate={orient['rotate_deg']} | "
f"flip_h={orient['flip_horizontal']} | "
f"flip_v={orient['flip_vertical']}"
)
if backup_profile: if backup_profile:
print(f"[BACKUP PROFILE] {backup_profile}") print(f"[BACKUP PROFILE] {backup_profile}")
if backup_selector: if backup_selector:

View File

@ -108,6 +108,7 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import json import json
import math
import os import os
import shutil import shutil
import zipfile import zipfile
@ -536,6 +537,78 @@ def resolve_output_path(value: str | Path) -> Path:
return Path(value).expanduser() return Path(value).expanduser()
def normalize_capture_synchronization(
value: dict,
hardware_signature: dict,
) -> dict:
"""Valida e normaliza o contrato de sincronismo vindo do Module Profile."""
if not isinstance(value, dict):
raise RuntimeError(
"Module Profile sem capture_synchronization. "
"Execute novamente o Script 0 revisado."
)
enabled = value.get("hardware_sync_enabled")
if not isinstance(enabled, bool):
raise RuntimeError(
"capture_synchronization.hardware_sync_enabled deve ser bool."
)
master = str(value.get("frame_sync_master") or "").upper().strip()
valid_sockets = {
str(item["socket"]).upper()
for item in normalize_signature(hardware_signature).values()
}
if master not in valid_sockets:
raise RuntimeError(
"capture_synchronization.frame_sync_master inválido: "
f"{master!r}. Disponíveis={sorted(valid_sockets)}"
)
software_mode = str(
value.get("software_sync_mode") or ""
).lower().strip()
if not software_mode:
raise RuntimeError(
"capture_synchronization.software_sync_mode ausente."
)
try:
tolerance_ms = float(value.get("sync_tolerance_ms"))
except Exception as exc:
raise RuntimeError(
"capture_synchronization.sync_tolerance_ms inválido."
) from exc
if not math.isfinite(tolerance_ms) or tolerance_ms <= 0.0:
raise RuntimeError(
"capture_synchronization.sync_tolerance_ms deve ser finito e > 0."
)
buffer_size = value.get("buffer_size")
if isinstance(buffer_size, bool):
raise RuntimeError(
"capture_synchronization.buffer_size deve ser inteiro positivo."
)
try:
buffer_size = int(buffer_size)
except Exception as exc:
raise RuntimeError(
"capture_synchronization.buffer_size inválido."
) from exc
if buffer_size <= 0:
raise RuntimeError(
"capture_synchronization.buffer_size deve ser > 0."
)
return {
"hardware_sync_enabled": enabled,
"frame_sync_master": master,
"software_sync_mode": software_mode,
"sync_tolerance_ms": tolerance_ms,
"buffer_size": buffer_size,
}
def validate_module_profile(profile: dict, profile_path: Path): def validate_module_profile(profile: dict, profile_path: Path):
if profile.get("schema") != MODULE_PROFILE_SCHEMA: if profile.get("schema") != MODULE_PROFILE_SCHEMA:
raise RuntimeError( raise RuntimeError(
@ -602,6 +675,11 @@ def validate_module_profile(profile: dict, profile_path: Path):
if not artifacts.get(key): if not artifacts.get(key):
raise RuntimeError(f"Module Profile sem artifact_paths.{key}.") raise RuntimeError(f"Module Profile sem artifact_paths.{key}.")
normalize_capture_synchronization(
profile.get("capture_synchronization"),
signature,
)
def load_module_profile_contract( def load_module_profile_contract(
module_profile_arg: Optional[str], module_profile_arg: Optional[str],
@ -2602,6 +2680,10 @@ def runtime_policy_from_module_profile(profile: dict) -> dict:
sensor nem herança de um module_params anterior. sensor nem herança de um module_params anterior.
""" """
decode = profile["rgb_decode"] decode = profile["rgb_decode"]
capture_sync = normalize_capture_synchronization(
profile.get("capture_synchronization"),
profile.get("hardware_signature"),
)
declared = profile.get("runtime_policy") declared = profile.get("runtime_policy")
if declared is None: if declared is None:
declared = {} declared = {}
@ -2614,6 +2696,7 @@ def runtime_policy_from_module_profile(profile: dict) -> dict:
"capture_mode_requested": "AUTO", "capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO", "capture_mode_effective": "AUTO",
"raw_policy": "allow_single", "raw_policy": "allow_single",
"capture_synchronization": capture_sync,
"rgb_processing": { "rgb_processing": {
"mode": str(decode["mode"]).lower(), "mode": str(decode["mode"]).lower(),
"demosaic_algorithm": str(decode["demosaic_algorithm"]).lower(), "demosaic_algorithm": str(decode["demosaic_algorithm"]).lower(),
@ -2781,6 +2864,9 @@ def build_final_module_params(
"raw_policy": runtime_policy[ "raw_policy": runtime_policy[
"raw_policy" "raw_policy"
], ],
"capture_synchronization": deepcopy(
runtime_policy["capture_synchronization"]
),
# Compatibilidade root: RGB reference camera # Compatibilidade root: RGB reference camera
"sensor_width": int( "sensor_width": int(
@ -2958,6 +3044,7 @@ def validate_final_module_params(
"bayer_pattern", "bayer_pattern",
"sensor_size_by_role", "sensor_size_by_role",
"camera_hardware", "camera_hardware",
"capture_synchronization",
"camera_orientation", "camera_orientation",
"rgb_processing", "rgb_processing",
"camera_settings", "camera_settings",
@ -3013,6 +3100,15 @@ def validate_final_module_params(
if str(mp.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS: if str(mp.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS:
raise RuntimeError("bayer_pattern final inválido.") raise RuntimeError("bayer_pattern final inválido.")
normalized_sync = normalize_capture_synchronization(
mp.get("capture_synchronization"),
mp.get("camera_hardware"),
)
if mp.get("capture_synchronization") != normalized_sync:
raise RuntimeError(
"capture_synchronization final não está normalizado."
)
rgb_processing = mp.get("rgb_processing") rgb_processing = mp.get("rgb_processing")
if not isinstance(rgb_processing, dict): if not isinstance(rgb_processing, dict):
raise RuntimeError("rgb_processing final inválido.") raise RuntimeError("rgb_processing final inválido.")
@ -3698,6 +3794,9 @@ def main():
provenance = { provenance = {
"module_profile_contract": module_profile_provenance(profile_contract), "module_profile_contract": module_profile_provenance(profile_contract),
"configuration_source": "module_profile_only", "configuration_source": "module_profile_only",
"capture_synchronization": deepcopy(
runtime_policy["capture_synchronization"]
),
"hardware_signature": deepcopy( "hardware_signature": deepcopy(
common_signature common_signature
), ),
@ -3832,6 +3931,9 @@ def main():
"camera_orientation": deepcopy( "camera_orientation": deepcopy(
camera_orientation camera_orientation
), ),
"capture_synchronization": deepcopy(
runtime_policy["capture_synchronization"]
),
"runtime_policy": runtime_policy, "runtime_policy": runtime_policy,
"calibration_provenance": provenance, "calibration_provenance": provenance,
"final_checks": { "final_checks": {
@ -3847,6 +3949,7 @@ def main():
"hardware_consistency": "pass", "hardware_consistency": "pass",
"geometry_consistency": "pass", "geometry_consistency": "pass",
"camera_orientation": "pass", "camera_orientation": "pass",
"capture_synchronization": "pass",
"orientation_bootstrap_consistency": "pass", "orientation_bootstrap_consistency": "pass",
"startup_radiometry_consistency": "pass", "startup_radiometry_consistency": "pass",
"flatfield_npz_integrity": "pass", "flatfield_npz_integrity": "pass",
@ -3874,6 +3977,15 @@ def main():
f"{common_signature['nir']['size'][0]}x{common_signature['nir']['size'][1]}" f"{common_signature['nir']['size'][0]}x{common_signature['nir']['size'][1]}"
) )
print(f"Bayer : {bayer}") print(f"Bayer : {bayer}")
capture_sync = runtime_policy["capture_synchronization"]
print(
"Capture sync: "
f"hardware={'ON' if capture_sync['hardware_sync_enabled'] else 'OFF'} | "
f"master={capture_sync['frame_sync_master']} | "
f"software={capture_sync['software_sync_mode']} | "
f"tolerance={capture_sync['sync_tolerance_ms']:.1f}ms | "
f"buffer={capture_sync['buffer_size']}"
)
print( print(
f"RGB process : " f"RGB process : "
f"{runtime_policy['rgb_processing'].get('mode')}" f"{runtime_policy['rgb_processing'].get('mode')}"

View File

@ -41,12 +41,17 @@ class OakFcc3Client:
capture_mode="AUTO", capture_mode="AUTO",
raw_policy="allow_single", raw_policy="allow_single",
module_calibration_json=None, module_calibration_json=None,
sync_mode="best",
sync_tolerance_ms=25.0,
mx_id=None, mx_id=None,
imu_modo="rotation_vector", imu_modo="rotation_vector",
imu_freq_hz=200, imu_freq_hz=200,
evaluate_quality=True, evaluate_quality=True,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_mode=None,
sync_tolerance_ms=None,
buffer_size=None,
**kwargs, **kwargs,
): ):
self.width = width self.width = width
@ -85,14 +90,18 @@ class OakFcc3Client:
output_dtype=output_dtype, output_dtype=output_dtype,
capture_mode=capture_mode, capture_mode=capture_mode,
raw_policy=raw_policy, raw_policy=raw_policy,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
mx_id=self.mx_id, mx_id=self.mx_id,
module_calibration_json=module_calibration_json, module_calibration_json=module_calibration_json,
imu_modo=self.imu_modo, imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz, imu_freq_hz=self.imu_freq_hz,
hardware_sync_enabled=hardware_sync_enabled,
frame_sync_master=frame_sync_master,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
buffer_size=buffer_size,
**kwargs, **kwargs,
) )

View File

@ -44,11 +44,11 @@ class OakFcc3Manager:
capture_mode="AUTO", capture_mode="AUTO",
raw_policy="allow_single", raw_policy="allow_single",
roles=None, roles=None,
sync_mode="best", sync_mode=None,
hardware_sync_enabled=True, hardware_sync_enabled=None,
frame_sync_master="CAM_A", frame_sync_master=None,
sync_tolerance_ms=12.0, sync_tolerance_ms=None,
buffer_size=8, buffer_size=None,
only_camera=None, only_camera=None,
mx_id=None, mx_id=None,
module_calibration_json=None, module_calibration_json=None,
@ -56,9 +56,6 @@ class OakFcc3Manager:
imu_modo="rotation_vector", imu_modo="rotation_vector",
imu_freq_hz=200, imu_freq_hz=200,
): ):
self.hardware_sync_enabled = hardware_sync_enabled
self.frame_sync_master = frame_sync_master
self.fps = fps self.fps = fps
# Para compatibilidade, mantemos width/height. # Para compatibilidade, mantemos width/height.
@ -83,10 +80,6 @@ class OakFcc3Manager:
"CAM_C": "nir", "CAM_C": "nir",
} }
self.sync_mode = sync_mode
self.sync_tolerance_ms = sync_tolerance_ms
self.buffer_size = buffer_size
self.mx_id = str(mx_id) if mx_id else None self.mx_id = str(mx_id) if mx_id else None
self.dev_info = None self.dev_info = None
self.device = None self.device = None
@ -127,6 +120,18 @@ class OakFcc3Manager:
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {} self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
self.aligned_geometry = None self.aligned_geometry = None
sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {})
if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False)
if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A")
if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best")
if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0)
if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8)
self.hardware_sync_enabled = bool(hardware_sync_enabled)
self.frame_sync_master = str(frame_sync_master)
self.sync_mode = str(sync_mode)
self.sync_tolerance_ms = float(sync_tolerance_ms)
self.buffer_size = int(buffer_size)
self.async_capture_enabled = True self.async_capture_enabled = True
self.async_capture_mode = "latest" # latest | queue self.async_capture_mode = "latest" # latest | queue
self.async_capture_max_queue = 2 self.async_capture_max_queue = 2

View File

@ -1,126 +1,689 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RawProcessorPreview
===================
Mini-ISP exclusivamente visual para gerar previews de anotação a partir do
RAW Bayer da câmera RGB.
Contrato:
- entrada científica permanece imutável;
- saída sempre BGR uint8 para OpenCV;
- preserva exatamente HxW do raster RGB;
- não aplica resize, crop, rotate/flip, undistort, Flat-Field ou Homography;
- não deve ser usado para formar o tensor de treino/inferência.
Pipeline visual padrão:
RAW10/RAW16 -> níveis robustos -> gamma -> demosaico EA/BGR ->
WB por regiões claras/neutras -> CLAHE suave em luminância ->
saturação leve -> contraste -> unsharp suave.
As APIs antigas foram preservadas. Chamadas que antes usavam
raw16_to_preview_bgr() ou raw16_to_preview_jpg_bytes() continuam válidas.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Optional
import cv2 import cv2
import numpy as np import numpy as np
from typing import Optional
class RawProcessorPreview: class RawProcessorPreview:
def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"): VERSION = "raw_processor_preview_v2_2026_09_09"
self.sensor_width = sensor_width CONTRACT_SCHEMA = "rgb_raw_annotation_preview_v2"
self.sensor_height = sensor_height BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG")
self.bayer_pattern = bayer_pattern.upper()
def raw16_to_vis8( DEFAULT_CONFIG = {
self, raw16: np.ndarray, "bit_depth": 10,
black_level: Optional[int] = None, "levels": {
white_level: Optional[int] = None, "mode": "robust_percentile",
gamma: float = 2.2, "black_percentile": 0.20,
bit_depth: int = 10 "white_percentile": 99.80,
) -> np.ndarray: "sample_max_pixels": 500_000,
""" "minimum_span_codes": 16.0,
Conversão para visualização: },
- auto-level "gamma": 2.20,
- gamma "demosaic": {
""" "algorithm": "edge_aware",
max_val = float((1 << bit_depth) - 1) "output_color_order": "BGR",
},
"white_balance": {
"enabled": True,
"method": "neutral_bright_with_gray_world_fallback",
"strength": 0.65,
"gain_min": 0.60,
"gain_max": 1.70,
"bright_percentile": 55.0,
"neutral_chroma_percentile": 40.0,
"min_neutral_pixels": 256,
},
"clahe": {
"enabled": True,
"clip_limit": 1.55,
"tile_grid_size": 8,
},
"saturation": {
"enabled": True,
"factor": 1.04,
},
"contrast": {
"enabled": True,
"alpha": 1.04,
"beta": 0.0,
},
"sharpen": {
"enabled": True,
"sigma": 0.85,
"amount": 0.55,
"threshold": 2.0,
},
}
raw = raw16.astype(np.float32) def __init__(
self,
sensor_width: int,
sensor_height: int,
bayer_pattern: str = "GBRG",
config: Optional[dict] = None,
):
self.sensor_width = int(sensor_width)
self.sensor_height = int(sensor_height)
self.bayer_pattern = str(bayer_pattern).upper()
self.config = self._deep_merge(self.DEFAULT_CONFIG, config or {})
self.last_preview_report: dict[str, Any] = {}
self._validate_contract()
if black_level is None: @staticmethod
black_level = float(raw.min()) def _deep_merge(base: dict, override: dict) -> dict:
if white_level is None: result = deepcopy(base)
white_level = float(raw.max()) if not isinstance(override, dict):
raise TypeError("config do RawProcessorPreview deve ser dict.")
for key, value in override.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = RawProcessorPreview._deep_merge(result[key], value)
else:
result[key] = deepcopy(value)
return result
if white_level <= black_level: def _validate_contract(self):
norm = raw / max_val if self.sensor_width <= 0 or self.sensor_height <= 0:
else: raise ValueError(
norm = (raw - black_level) / (white_level - black_level) f"Dimensão de sensor inválida: {self.sensor_width}x{self.sensor_height}"
)
norm = np.clip(norm, 0.0, 1.0) if self.sensor_width % 4 != 0:
raise ValueError(
if gamma is not None and gamma > 0: f"RAW10 packed exige largura múltipla de 4: {self.sensor_width}"
norm = np.power(norm, 1.0 / gamma) )
if self.bayer_pattern not in self.BAYER_PATTERNS:
return (norm * 255.0).clip(0, 255).astype(np.uint8)
def _debayer_code(self):
mapping = {
"RGGB": cv2.COLOR_BayerRG2RGB_EA,
"BGGR": cv2.COLOR_BayerBG2RGB_EA,
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
}
if self.bayer_pattern not in mapping:
raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}") raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}")
return mapping[self.bayer_pattern] bit_depth = int(self.config.get("bit_depth", 10))
if bit_depth < 8 or bit_depth > 16:
raise ValueError(f"bit_depth inválido: {bit_depth}")
def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray: levels = self.config["levels"]
low = float(levels["black_percentile"])
high = float(levels["white_percentile"])
if not (0.0 <= low < high <= 100.0):
raise ValueError(f"Percentis de níveis inválidos: {low}, {high}")
gamma = float(self.config["gamma"])
if not np.isfinite(gamma) or gamma <= 0.0:
raise ValueError(f"Gamma inválido: {gamma}")
wb = self.config["white_balance"]
if float(wb["gain_min"]) <= 0.0 or float(wb["gain_max"]) < float(wb["gain_min"]):
raise ValueError("Limites de ganho do white balance inválidos.")
def get_preview_contract(self) -> dict:
"""Contrato serializável para registrar no meta do dataset."""
return {
"schema": self.CONTRACT_SCHEMA,
"version": self.VERSION,
"purpose": "human_annotation_only",
"geometry": "rgb_sensor_native_no_geometric_transform",
"sensor_size": [self.sensor_width, self.sensor_height],
"bayer_pattern": self.bayer_pattern,
"output": {
"layout": "HWC",
"color_order": "BGR",
"dtype": "uint8",
"range": [0, 255],
},
"calibrations_applied": [],
"config": deepcopy(self.config),
}
def get_last_preview_report(self) -> dict:
return deepcopy(self.last_preview_report)
# Nome explícito e amigável para integrações que só precisam registrar
# a configuração, sem necessariamente gerar um frame antes.
def describe_config(self) -> dict:
return self.get_preview_contract()
@staticmethod
def _finite_float(value, name: str) -> float:
result = float(value)
if not np.isfinite(result):
raise ValueError(f"{name} deve ser finito: {value!r}")
return result
@staticmethod
def _sample_for_stats(raw: np.ndarray, max_pixels: int) -> np.ndarray:
total = int(raw.size)
if total <= max_pixels:
return raw.reshape(-1)
stride = max(1, int(np.ceil(np.sqrt(total / float(max_pixels)))))
return raw[::stride, ::stride].reshape(-1)
def _resolve_levels(
self,
raw16: np.ndarray,
black_level: Optional[float],
white_level: Optional[float],
bit_depth: int,
black_percentile: Optional[float],
white_percentile: Optional[float],
) -> tuple[float, float, dict]:
max_code = float((1 << int(bit_depth)) - 1)
levels = self.config["levels"]
low_pct = float(
levels["black_percentile"]
if black_percentile is None
else black_percentile
)
high_pct = float(
levels["white_percentile"]
if white_percentile is None
else white_percentile
)
if not (0.0 <= low_pct < high_pct <= 100.0):
raise ValueError(f"Percentis inválidos: {low_pct}, {high_pct}")
sample = self._sample_for_stats(
raw16,
max(1, int(levels["sample_max_pixels"])),
).astype(np.float32, copy=False)
sample = sample[np.isfinite(sample)]
if sample.size == 0:
raise RuntimeError("RAW não contém pixels finitos para calcular níveis.")
black_source = "explicit"
white_source = "explicit"
if black_level is None:
black = float(np.percentile(sample, low_pct))
black_source = "percentile"
else:
black = self._finite_float(black_level, "black_level")
if white_level is None:
white = float(np.percentile(sample, high_pct))
white_source = "percentile"
else:
white = self._finite_float(white_level, "white_level")
black = float(np.clip(black, 0.0, max_code))
white = float(np.clip(white, 0.0, max_code))
minimum_span = float(levels["minimum_span_codes"])
fallback = False
if white <= black + minimum_span:
black = float(np.clip(np.min(sample), 0.0, max_code))
white = float(np.clip(np.max(sample), 0.0, max_code))
fallback = True
if white <= black:
black, white = 0.0, max_code
fallback = True
return black, white, {
"black_level": black,
"white_level": white,
"black_source": black_source,
"white_source": white_source,
"black_percentile": low_pct,
"white_percentile": high_pct,
"fallback_to_range": fallback,
"sample_pixels": int(sample.size),
}
def raw16_to_vis8(
self,
raw16: np.ndarray,
black_level: Optional[int] = None,
white_level: Optional[int] = None,
gamma: Optional[float] = None,
bit_depth: Optional[int] = None,
black_percentile: Optional[float] = None,
white_percentile: Optional[float] = None,
) -> np.ndarray:
"""Converte mosaico RAW16 para mosaico uint8 de visualização."""
raw = np.asarray(raw16)
if raw.ndim != 2:
raise ValueError(f"RAW16 deve ser 2D; shape={raw.shape}")
if raw.shape != (self.sensor_height, self.sensor_width):
raise ValueError(
f"RAW16 shape={raw.shape}; esperado="
f"{(self.sensor_height, self.sensor_width)}"
)
if not np.issubdtype(raw.dtype, np.integer):
raise TypeError(f"RAW16 deve possuir dtype inteiro; recebido={raw.dtype}")
bit_depth = int(
self.config["bit_depth"] if bit_depth is None else bit_depth
)
gamma_value = self._finite_float(
self.config["gamma"] if gamma is None else gamma,
"gamma",
)
if gamma_value <= 0.0:
raise ValueError("gamma deve ser > 0.")
black, white, level_report = self._resolve_levels(
raw,
black_level,
white_level,
bit_depth,
black_percentile,
white_percentile,
)
norm = (raw.astype(np.float32) - black) / max(white - black, 1.0)
np.clip(norm, 0.0, 1.0, out=norm)
if gamma_value != 1.0:
norm = np.power(norm, 1.0 / gamma_value)
self.last_preview_report = {
"stage": "raw16_to_vis8",
"bit_depth": bit_depth,
"gamma": gamma_value,
"levels": level_report,
}
return np.clip(norm * 255.0 + 0.5, 0, 255).astype(np.uint8)
def _debayer_code(self):
# A API desta classe promete BGR para cv2.imshow/cv2.imwrite.
mapping = {
"RGGB": ("COLOR_BayerRG2BGR_EA", "COLOR_BayerRG2BGR"),
"BGGR": ("COLOR_BayerBG2BGR_EA", "COLOR_BayerBG2BGR"),
"GRBG": ("COLOR_BayerGR2BGR_EA", "COLOR_BayerGR2BGR"),
"GBRG": ("COLOR_BayerGB2BGR_EA", "COLOR_BayerGB2BGR"),
}
preferred, fallback = mapping[self.bayer_pattern]
return getattr(cv2, preferred, getattr(cv2, fallback))
def apply_preview_white_balance(
self,
bgr: np.ndarray,
strength: float = 1.0,
method: Optional[str] = None,
gain_min: Optional[float] = None,
gain_max: Optional[float] = None,
return_report: bool = False,
):
""" """
Gray-world simples para deixar o preview mais agradável. WB visual robusto para campo agrícola.
Não usar no raw de treino.
Primeiro procura pixels claros e de baixo croma, reduzindo o risco de
o gray-world neutralizar toda a vegetação verde. Se não houver amostra
neutra suficiente, usa gray-world com ganhos limitados.
""" """
img = bgr.astype(np.float32) image = np.asarray(bgr)
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError(f"WB requer BGR HWC; shape={image.shape}")
mean_b = float(img[:, :, 0].mean()) cfg = self.config["white_balance"]
mean_g = float(img[:, :, 1].mean()) method = str(method or cfg["method"]).lower()
mean_r = float(img[:, :, 2].mean()) strength = float(np.clip(self._finite_float(strength, "wb_strength"), 0.0, 1.0))
gain_min = float(cfg["gain_min"] if gain_min is None else gain_min)
gain_max = float(cfg["gain_max"] if gain_max is None else gain_max)
if gain_min <= 0.0 or gain_max < gain_min:
raise ValueError("Limites de ganho WB inválidos.")
mean_gray = (mean_b + mean_g + mean_r) / 3.0 sample = image[::4, ::4].astype(np.float32)
flat = sample.reshape(-1, 3)
intensity = flat.mean(axis=1)
chroma = (flat.max(axis=1) - flat.min(axis=1)) / np.maximum(intensity, 1.0)
eps = 1e-6 selected = flat
gain_b = mean_gray / max(mean_b, eps) source = "gray_world"
gain_g = mean_gray / max(mean_g, eps) if method.startswith("neutral_bright") and flat.shape[0] > 0:
gain_r = mean_gray / max(mean_r, eps) bright_limit = float(np.percentile(intensity, float(cfg["bright_percentile"])))
bright_mask = intensity >= bright_limit
bright_chroma = chroma[bright_mask]
if bright_chroma.size:
neutral_limit = float(
np.percentile(
bright_chroma,
float(cfg["neutral_chroma_percentile"]),
)
)
neutral_mask = bright_mask & (chroma <= neutral_limit)
neutral = flat[neutral_mask]
if neutral.shape[0] >= int(cfg["min_neutral_pixels"]):
selected = neutral
source = "neutral_bright"
# strength=1 aplica total, strength=0 não aplica means = selected.mean(axis=0)
gain_b = 1.0 + (gain_b - 1.0) * strength target = float(means.mean())
gain_g = 1.0 + (gain_g - 1.0) * strength gains = target / np.maximum(means, 1e-6)
gain_r = 1.0 + (gain_r - 1.0) * strength gains = np.clip(gains, gain_min, gain_max)
gains = 1.0 + (gains - 1.0) * strength
img[:, :, 0] *= gain_b out = np.clip(
img[:, :, 1] *= gain_g image.astype(np.float32) * gains[None, None, :],
img[:, :, 2] *= gain_r 0,
255,
).astype(np.uint8)
report = {
"method_requested": method,
"source_used": source,
"strength": strength,
"means_bgr": [float(x) for x in means],
"gains_bgr": [float(x) for x in gains],
"selected_pixels": int(selected.shape[0]),
}
return (out, report) if return_report else out
return np.clip(img, 0, 255).astype(np.uint8) def apply_preview_contrast(
self,
bgr: np.ndarray,
alpha: float = 1.08,
beta: float = 0.0,
) -> np.ndarray:
"""Compatibilidade: contraste/brilho visual sem alterar geometria."""
alpha = self._finite_float(alpha, "contrast_alpha")
beta = self._finite_float(beta, "contrast_beta")
return cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta)
def apply_preview_contrast(self, bgr: np.ndarray, alpha: float = 1.08, beta: float = 0.0) -> np.ndarray: @staticmethod
""" def _apply_clahe_luminance(
Ajuste leve de contraste/brilho para preview. bgr: np.ndarray,
""" clip_limit: float,
out = cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta) tile_grid_size: int,
return out ) -> np.ndarray:
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
luma, a_ch, b_ch = cv2.split(lab)
clahe = cv2.createCLAHE(
clipLimit=float(clip_limit),
tileGridSize=(int(tile_grid_size), int(tile_grid_size)),
)
return cv2.cvtColor(
cv2.merge([clahe.apply(luma), a_ch, b_ch]),
cv2.COLOR_LAB2BGR,
)
@staticmethod
def _apply_saturation(bgr: np.ndarray, factor: float) -> np.ndarray:
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
sat = hsv[:, :, 1].astype(np.float32) * float(factor)
hsv[:, :, 1] = np.clip(sat, 0, 255).astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
@staticmethod
def _apply_unsharp(
bgr: np.ndarray,
sigma: float,
amount: float,
threshold: float,
) -> np.ndarray:
image = bgr.astype(np.float32)
blurred = cv2.GaussianBlur(
image,
(0, 0),
sigmaX=float(sigma),
sigmaY=float(sigma),
)
sharpened = image + float(amount) * (image - blurred)
if threshold > 0.0:
mask = np.max(np.abs(image - blurred), axis=2) >= float(threshold)
result = image.copy()
result[mask] = sharpened[mask]
else:
result = sharpened
return np.clip(result, 0, 255).astype(np.uint8)
def raw16_to_preview_bgr( def raw16_to_preview_bgr(
self, self,
raw16: np.ndarray, raw16: np.ndarray,
gamma: float = 2.2, gamma: Optional[float] = None,
wb_strength: float = 0.8, wb_strength: Optional[float] = None,
apply_wb: bool = True, apply_wb: bool = True,
apply_contrast: bool = True, apply_contrast: bool = True,
bit_depth: int = 10, bit_depth: Optional[int] = None,
) -> np.ndarray: *,
""" black_level: Optional[float] = None,
Pipeline de preview bonito: white_level: Optional[float] = None,
1. auto-level + gamma no mosaico black_percentile: Optional[float] = None,
2. demosaic white_percentile: Optional[float] = None,
3. white balance simples apply_clahe: Optional[bool] = None,
4. leve contraste final apply_saturation: Optional[bool] = None,
""" apply_sharpen: Optional[bool] = None,
vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth) return_report: bool = False,
):
"""Gera preview BGR bonito preservando exatamente o raster de entrada."""
source = np.asarray(raw16)
expected_shape = (self.sensor_height, self.sensor_width)
if source.shape != expected_shape:
raise ValueError(f"RAW16 shape={source.shape}; esperado={expected_shape}")
effective_gamma = float(
self.config["gamma"] if gamma is None else gamma
)
effective_bit_depth = int(
self.config["bit_depth"] if bit_depth is None else bit_depth
)
effective_wb_strength = float(
self.config["white_balance"]["strength"]
if wb_strength is None
else wb_strength
)
vis8 = self.raw16_to_vis8(
source,
black_level=black_level,
white_level=white_level,
gamma=effective_gamma,
bit_depth=effective_bit_depth,
black_percentile=black_percentile,
white_percentile=white_percentile,
)
level_report = deepcopy(self.last_preview_report.get("levels", {}))
bgr = cv2.cvtColor(vis8, self._debayer_code()) bgr = cv2.cvtColor(vis8, self._debayer_code())
wb_report = {"enabled": False}
if apply_wb: if apply_wb:
bgr = self.apply_preview_white_balance(bgr, strength=wb_strength) bgr, wb_report = self.apply_preview_white_balance(
bgr,
strength=effective_wb_strength,
return_report=True,
)
wb_report["enabled"] = True
clahe_cfg = self.config["clahe"]
clahe_enabled = bool(clahe_cfg["enabled"] if apply_clahe is None else apply_clahe)
if clahe_enabled:
bgr = self._apply_clahe_luminance(
bgr,
float(clahe_cfg["clip_limit"]),
int(clahe_cfg["tile_grid_size"]),
)
saturation_cfg = self.config["saturation"]
saturation_enabled = bool(
saturation_cfg["enabled"]
if apply_saturation is None
else apply_saturation
)
if saturation_enabled:
bgr = self._apply_saturation(bgr, float(saturation_cfg["factor"]))
contrast_cfg = self.config["contrast"]
if apply_contrast: if apply_contrast:
bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0) bgr = self.apply_preview_contrast(
bgr,
alpha=float(contrast_cfg["alpha"]),
beta=float(contrast_cfg["beta"]),
)
return bgr sharpen_cfg = self.config["sharpen"]
sharpen_enabled = bool(
sharpen_cfg["enabled"] if apply_sharpen is None else apply_sharpen
)
if sharpen_enabled:
bgr = self._apply_unsharp(
bgr,
sigma=float(sharpen_cfg["sigma"]),
amount=float(sharpen_cfg["amount"]),
threshold=float(sharpen_cfg["threshold"]),
)
def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes: if bgr.shape != (self.sensor_height, self.sensor_width, 3):
raise RuntimeError(
f"Preview alterou geometria: {bgr.shape}; esperado="
f"{(self.sensor_height, self.sensor_width, 3)}"
)
if bgr.dtype != np.uint8:
raise RuntimeError(f"Preview dtype={bgr.dtype}; esperado=uint8")
self.last_preview_report = {
**self.get_preview_contract(),
"effective": {
"bit_depth": effective_bit_depth,
"gamma": effective_gamma,
"levels": level_report,
"white_balance": wb_report,
"clahe_enabled": clahe_enabled,
"saturation_enabled": saturation_enabled,
"contrast_enabled": bool(apply_contrast),
"sharpen_enabled": sharpen_enabled,
},
}
result = np.ascontiguousarray(bgr)
return (result, self.get_last_preview_report()) if return_report else result
def unpack_raw10_packed(
self,
packed_frame,
sensor_width: Optional[int] = None,
sensor_height: Optional[int] = None,
stride: Optional[int] = None,
) -> np.ndarray:
"""
Desempacota RAW10 MIPI (4 pixels/5 bytes), aceitando ndarray 1D/2D,
bytes e stride/padding por linha.
"""
width = self.sensor_width if sensor_width is None else int(sensor_width)
height = self.sensor_height if sensor_height is None else int(sensor_height)
if width != self.sensor_width or height != self.sensor_height:
raise ValueError(
"RawProcessorPreview preserva o raster configurado; dimensão "
f"solicitada={width}x{height}, configurada="
f"{self.sensor_width}x{self.sensor_height}."
)
if width % 4 != 0:
raise ValueError(f"RAW10 exige largura múltipla de 4: {width}")
if isinstance(packed_frame, (bytes, bytearray, memoryview)):
packed = np.frombuffer(packed_frame, dtype=np.uint8)
else:
packed = np.asarray(packed_frame)
if packed.ndim == 3 and packed.shape[2] == 1:
packed = packed[:, :, 0]
useful_width = (width // 4) * 5
if packed.dtype != np.uint8:
raise TypeError(
f"RAW10 packed deve ser uint8; shape={packed.shape}, dtype={packed.dtype}"
)
if packed.ndim == 1:
if stride is None:
if packed.size % height != 0:
raise ValueError(
f"RAW10 1D possui {packed.size} bytes, não divisível por "
f"height={height}; informe stride."
)
stride = packed.size // height
stride = int(stride)
expected_bytes = stride * height
if stride < useful_width or packed.size < expected_bytes:
raise ValueError(
f"RAW10 1D curto/inválido: bytes={packed.size}, stride={stride}, "
f"esperado>={expected_bytes}, payload/linha={useful_width}."
)
packed = packed[:expected_bytes].reshape(height, stride)
elif packed.ndim == 2:
if stride is not None and int(stride) != packed.shape[1]:
raise ValueError(
f"stride={stride} diverge da largura do array={packed.shape[1]}."
)
else:
raise TypeError(
f"RAW10 packed deve ser 1D ou 2D; shape={packed.shape}."
)
if packed.shape[0] != height or packed.shape[1] < useful_width:
raise ValueError(
f"RAW10 packed shape={packed.shape}; esperado >=({height}, {useful_width})"
)
padding = int(packed.shape[1] - useful_width)
if padding > max(4096, useful_width):
raise ValueError(
f"Padding RAW10 implausível: {padding} bytes/linha; "
"verifique width/height/stride."
)
payload = np.ascontiguousarray(packed[:, :useful_width])
groups = payload.reshape(height, width // 4, 5)
high = groups[:, :, :4].astype(np.uint16)
low = groups[:, :, 4].astype(np.uint16)
out = np.empty((height, width // 4, 4), dtype=np.uint16)
out[:, :, 0] = (high[:, :, 0] << 2) | (low & 0x03)
out[:, :, 1] = (high[:, :, 1] << 2) | ((low >> 2) & 0x03)
out[:, :, 2] = (high[:, :, 2] << 2) | ((low >> 4) & 0x03)
out[:, :, 3] = (high[:, :, 3] << 2) | ((low >> 6) & 0x03)
return np.ascontiguousarray(out.reshape(height, width))
def packed_raw10_to_preview_bgr(self, packed_frame: np.ndarray, **kwargs):
"""Atalho oficial RAW10 packed -> preview BGR."""
raw16 = self.unpack_raw10_packed(packed_frame)
return self.raw16_to_preview_bgr(raw16, **kwargs)
def raw16_to_preview_jpg_bytes(
self,
raw16: np.ndarray,
jpeg_quality: int = 95,
) -> bytes:
quality = int(np.clip(int(jpeg_quality), 1, 100))
bgr = self.raw16_to_preview_bgr(raw16) bgr = self.raw16_to_preview_bgr(raw16)
ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)]) ok, encoded = cv2.imencode(
".jpg",
bgr,
[int(cv2.IMWRITE_JPEG_QUALITY), quality],
)
if not ok: if not ok:
raise RuntimeError("Falha ao codificar preview JPG") raise RuntimeError("Falha ao codificar preview JPG")
return enc.tobytes() return encoded.tobytes()
def raw16_to_preview_png_bytes(
self,
raw16: np.ndarray,
compression: int = 3,
) -> bytes:
level = int(np.clip(int(compression), 0, 9))
bgr = self.raw16_to_preview_bgr(raw16)
ok, encoded = cv2.imencode(
".png",
bgr,
[int(cv2.IMWRITE_PNG_COMPRESSION), level],
)
if not ok:
raise RuntimeError("Falha ao codificar preview PNG")
return encoded.tobytes()