Merge branch 'op_control' of http://zendioninc.com.br:3000/Zendion/agrobot_base into op_control

This commit is contained in:
Diego Freitas 2026-05-15 07:53:34 -03:00
commit dc5120d9cd
6 changed files with 731 additions and 123 deletions

View File

@ -126,6 +126,28 @@ def get_camera_map_from_status(status: dict) -> dict:
return result
def build_preview_to_save(cam, frame_type_save, last_preview_bgr, last_packed_raw_by_camera, last_meta_stream, raw_w, raw_h, bayer):
preview_to_save = last_preview_bgr
method = "last_screen_preview"
if frame_type_save == "RAW_BRUTO" and last_packed_raw_by_camera is not None:
rebuilt_preview = cam.build_save_preview_from_cam_a(
packed_raw_by_camera=last_packed_raw_by_camera,
meta_stream=last_meta_stream,
sensor_width=raw_w,
sensor_height=raw_h,
bayer_pattern=bayer,
)
if rebuilt_preview is not None:
preview_to_save = rebuilt_preview
method = "cam_a_reconstructed_raw10"
else:
method = "last_screen_preview_fallback"
return preview_to_save, method
# =========================
# MAIN
# =========================
@ -234,7 +256,6 @@ def main():
packed_by_camera = frame
preview_bgr, raw3_preview, preview_source_id = cam.build_preview_from_raw_payload(frame=frame, meta=meta)
preview_bgr = cv2.cvtColor(preview_bgr, cv2.COLOR_RGB2BGR)
if beauty_preview:
rgb_preview = None
@ -348,13 +369,31 @@ def main():
else:
line_rad = "RAD | OFF"
camera_info = meta.get("camera_info", {}) or {}
frame_controls = meta.get("frame_controls", {}) or {}
role_controls = {}
for cam_id, info in camera_info.items():
role = info.get("role", cam_id)
role_controls[role] = frame_controls.get(cam_id, {})
line_ae = (
f"AE_REAL | "
f"RGB exp={role_controls.get('rgb', {}).get('exposure_time_us')} "
f"iso={role_controls.get('rgb', {}).get('sensitivity_iso')} | "
f"RE exp={role_controls.get('re', {}).get('exposure_time_us')} "
f"iso={role_controls.get('re', {}).get('sensitivity_iso')} | "
f"NIR exp={role_controls.get('nir', {}).get('exposure_time_us')} "
f"iso={role_controls.get('nir', {}).get('sensitivity_iso')}"
)
lines = [
f"CANA: {args.cana} | HORA: {args.horario} | Pasta: {os.path.basename(session_dir)}",
f"Type={meta.get('frame_type')} | CaptureMode={effective_capture_mode} | RAW policy={args.raw_policy}",
f"Sources={active_sources} | FPS_STREAM={fps_stream:.1f} | FPS_VIEW={fps_view:.1f}",
f"frame_id={meta.get('frame_id')} | layout={meta.get('output_layout')} | dtype={meta.get('dtype') or meta.get('output_dtype')}",
f"codec={meta.get('codec_name', meta.get('codec_family', '-'))} | comp={meta.get('dt_comp', 0):.4f}s | send={meta.get('dt_send_payload_prev', 0):.4f}s",
f"CAM_PARAMS={os.path.basename(args.module_calibration_json)} | controles fixos aplicados",
f"CAM_PARAMS={os.path.basename(args.module_calibration_json)}",
line_ae,
line_rad,
"Keys: C/SPACE=save | A=auto-save | M=preview | R=rad | Q/Esc=quit"
]
@ -410,10 +449,23 @@ def main():
"raw_preview_reference_camera": preview_source_id,
}
preview_to_save, preview_method = build_preview_to_save(
cam=cam,
frame_type_save=frame_type_save,
last_preview_bgr=last_preview_bgr,
last_packed_raw_by_camera=last_packed_raw_by_camera,
last_meta_stream=last_meta_stream,
raw_w=raw_w,
raw_h=raw_h,
bayer=args.bayer,
)
meta_save["saved_preview_method"] = preview_method
save_sample(
session_dir,
frame_type=frame_type_save,
preview_bgr=last_preview_bgr,
preview_bgr=preview_to_save,
meta=meta_save,
raw_payload=last_payload_float,
packed_raw=last_packed_raw,
@ -472,10 +524,23 @@ def main():
"raw_preview_reference_camera": preview_source_id,
}
preview_to_save, preview_method = build_preview_to_save(
cam=cam,
frame_type_save=frame_type_save,
last_preview_bgr=last_preview_bgr,
last_packed_raw_by_camera=last_packed_raw_by_camera,
last_meta_stream=last_meta_stream,
raw_w=raw_w,
raw_h=raw_h,
bayer=args.bayer,
)
meta_save["saved_preview_method"] = preview_method
save_sample(
session_dir,
frame_type=frame_type_save,
preview_bgr=last_preview_bgr,
preview_bgr=preview_to_save,
meta=meta_save,
raw_payload=last_payload_float,
packed_raw=last_packed_raw,

View File

@ -13,8 +13,8 @@
},
"camera_settings": {
"rgb": {
"ae_enable": false,
"awb_enable": false,
"ae_enable": true,
"awb_enable": true,
"exposure_time_us": 2000,
"analogue_gain": 1.0,
"colour_gains": [
@ -23,14 +23,14 @@
]
},
"re": {
"ae_enable": false,
"ae_enable": true,
"awb_enable": false,
"exposure_time_us": 5000,
"analogue_gain": 1.0,
"colour_gains": null
},
"nir": {
"ae_enable": false,
"ae_enable": true,
"awb_enable": false,
"exposure_time_us": 5000,
"analogue_gain": 1.0,
@ -94,7 +94,7 @@
"target_size": null
},
"radiometric_config": {
"enabled": true,
"enabled": false,
"interval_s": 0.25,
"verbose": true,
"metering_mode": "reference_patches",
@ -570,27 +570,58 @@
"patch_white_roi_reject_p95": 0.995
},
"radiometric_normalization": {
"enabled": false,
"method": "exposure_gain_reference",
"enabled": true,
"method": "oak_ae_frame_controls_v1",
"apply_stage": "after_dark_before_flat_gain",
"control_source": "stream_meta.frame_controls",
"role_mapping_source": "camera_info",
"factor_model": "exposure_time_us_x_iso",
"iso_base": 100.0,
"reference_mode": "fixed",
"reference_controls": {
"rgb": {
"exposure_time_us": 3000,
"analogue_gain": 1.0
"exposure_time_us": 10000,
"sensitivity_iso": 400
},
"re": {
"exposure_time_us": 7000,
"analogue_gain": 1.0
"exposure_time_us": 15000,
"sensitivity_iso": 400
},
"nir": {
"exposure_time_us": 7000,
"analogue_gain": 1.0
"exposure_time_us": 15000,
"sensitivity_iso": 400
}
},
"clip_output": true
"scale_limits": {
"default": {
"min": 0.15,
"max": 6.0
},
"rgb": {
"min": 0.15,
"max": 6.0
},
"re": {
"min": 0.15,
"max": 8.0
},
"nir": {
"min": 0.15,
"max": 8.0
}
},
"missing_controls_policy": "skip",
"invalid_controls_policy": "skip",
"clip_output": false,
"save_debug": true
},
"patch_normalization": {
"enabled": true,
"enabled": false,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",

View File

@ -255,6 +255,12 @@ class OakFcc3Client:
except Exception:
return None
def get_last_radiometric_normalization_result(self):
try:
return self.core.get_last_radiometric_normalization_result()
except Exception:
return None
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
return self.core.build_infer_tensor_from_stream(
frame,
@ -405,6 +411,96 @@ class OakFcc3Client:
return previews
def build_save_preview_from_cam_a(
self,
packed_raw_by_camera: dict | None,
meta_stream: dict,
sensor_width: int,
sensor_height: int,
bayer_pattern: str,
) -> np.ndarray | None:
"""
Gera o preview salvo no mesmo padrão do 'CAM_A reconstruido'.
Usa apenas CAM_A do RAW_BRUTO:
CAM_A packed RAW10
-> unpack_raw10_packed
-> RawProcessorPreview.raw16_to_preview_bgr
Retorna BGR uint8 pronto para cv2.imwrite.
"""
if not packed_raw_by_camera or "CAM_A" not in packed_raw_by_camera:
return None
stream_meta = meta_stream or {}
camera_info = stream_meta.get("camera_info", {}) or {}
cam_meta = camera_info.get("CAM_A", {}) or {}
bit_depth = int(cam_meta.get("bit_depth", 10))
bayer = (
cam_meta.get("bayer_pattern")
or cam_meta.get("bayer")
or stream_meta.get("bayer_pattern")
or bayer_pattern
or "RGGB"
)
bayer = str(bayer).upper()
arr = packed_raw_by_camera["CAM_A"]
if arr is None:
return None
packed = arr
if packed.ndim == 3 and packed.shape[2] == 1:
packed = packed[:, :, 0]
if packed.ndim != 2:
return None
# Mantém a mesma lógica do validador:
# RAW10 packed => sensor_w = packed_w * 4 // 5
packed_h, packed_w = packed.shape[:2]
if bit_depth == 10:
real_w = int(cam_meta.get("width", sensor_width))
real_h = int(cam_meta.get("height", sensor_height))
# Fallback caso o meta não tenha width/height confiáveis
if real_w <= 0 or real_h <= 0:
real_w = int((packed_w * 4) // 5)
real_h = int(packed_h)
else:
real_w = int(cam_meta.get("width", sensor_width))
real_h = int(cam_meta.get("height", sensor_height))
core = RawProcessorCore(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
preview = RawProcessorPreview(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
raw16 = core.unpack_raw10_packed(
packed,
sensor_width=real_w,
sensor_height=real_h,
)
preview_bgr = preview.raw16_to_preview_bgr(
raw16,
bit_depth=bit_depth,
)
return preview_bgr
def _find_decoded_by_role(self, decoded, role):
role = str(role).lower()

View File

@ -218,10 +218,16 @@ class OakFcc3Manager:
synced = self._try_get_synced_packet()
if synced is not None:
frames, timestamps, sync_dt_ms, sync_ok = synced
frames, timestamps, sync_dt_ms, sync_ok, frame_controls = synced
self.frame_id += 1
meta = self._build_meta(frames, timestamps, sync_dt_ms, sync_ok)
meta = self._build_meta(
frames,
timestamps,
sync_dt_ms,
sync_ok,
frame_controls=frame_controls,
)
return frames, meta
@ -233,6 +239,69 @@ class OakFcc3Manager:
f"Tente aumentar para 25 ou 35 ms para diagnóstico."
)
def _extract_frame_controls(self, msg):
controls = {
"exposure_time_us": None,
"sensitivity_iso": None,
"analogue_gain_est": None,
"color_temperature_k": None,
"lens_position": None,
"sequence_num": None,
"errors": [],
}
try:
if hasattr(msg, "getSequenceNum"):
controls["sequence_num"] = int(msg.getSequenceNum())
except Exception as e:
controls["errors"].append(f"sequence_num:{type(e).__name__}:{e}")
try:
if hasattr(msg, "getExposureTime"):
exp = msg.getExposureTime()
if hasattr(exp, "total_seconds"):
controls["exposure_time_us"] = int(exp.total_seconds() * 1_000_000)
else:
controls["exposure_time_us"] = int(exp)
else:
controls["errors"].append("missing:getExposureTime")
except Exception as e:
controls["errors"].append(f"exposure:{type(e).__name__}:{e}")
try:
if hasattr(msg, "getSensitivity"):
iso = msg.getSensitivity()
controls["sensitivity_iso"] = int(iso)
controls["analogue_gain_est"] = float(iso) / 100.0
else:
controls["errors"].append("missing:getSensitivity")
except Exception as e:
controls["errors"].append(f"sensitivity:{type(e).__name__}:{e}")
try:
if hasattr(msg, "getColorTemperature"):
ct = int(msg.getColorTemperature())
controls["color_temperature_k"] = ct if ct > 0 else None
else:
controls["errors"].append("missing:getColorTemperature")
except Exception as e:
controls["errors"].append(f"color_temperature:{type(e).__name__}:{e}")
try:
if hasattr(msg, "getLensPosition"):
lp = int(msg.getLensPosition())
controls["lens_position"] = lp
else:
controls["errors"].append("missing:getLensPosition")
except Exception as e:
controls["errors"].append(f"lens_position:{type(e).__name__}:{e}")
if not controls["errors"]:
controls.pop("errors", None)
return controls
def _drain_queues_to_buffers(self):
for cam_id, q in self.queues.items():
while q.has():
@ -270,9 +339,12 @@ class OakFcc3Manager:
"packed_width": stride,
}
frame_controls = self._extract_frame_controls(msg)
self.buffers[cam_id].append({
"frame": frame,
"timestamp": ts,
"controls": frame_controls,
})
def _try_get_synced_packet(self):
@ -309,6 +381,11 @@ class OakFcc3Manager:
for cam_id, item in selected.items()
}
frame_controls = {
cam_id: item.get("controls", {})
for cam_id, item in selected.items()
}
ts_values = list(timestamps.values())
sync_dt_ms = (max(ts_values) - min(ts_values)) * 1000.0 if len(ts_values) >= 2 else 0.0
sync_ok = sync_dt_ms <= self.sync_tolerance_ms
@ -331,7 +408,7 @@ class OakFcc3Manager:
if item is used_item:
break
return frames, timestamps, sync_dt_ms, sync_ok
return frames, timestamps, sync_dt_ms, sync_ok, frame_controls
def _get_required_cam_ids(self):
available = self._get_available_cam_ids_ordered()
@ -352,7 +429,7 @@ class OakFcc3Manager:
return available
def _build_meta(self, frames, timestamps, sync_dt_ms, sync_ok):
def _build_meta(self, frames, timestamps, sync_dt_ms, sync_ok, frame_controls):
payload_sources = list(frames.keys())
shapes = {
@ -407,6 +484,7 @@ class OakFcc3Manager:
"payload_sources": payload_sources,
"camera_info": camera_info,
"timestamps": timestamps,
"frame_controls": frame_controls or {},
"sync_dt_ms": sync_dt_ms,
"sync_ok": sync_ok,
"sync_tolerance_ms": self.sync_tolerance_ms,
@ -538,17 +616,34 @@ class OakFcc3Manager:
result = dict(self.camera_controls.get(cam_id, {}))
ae_requested = controls.get("ae_enable", None)
if "ae_enable" in controls:
result = self.set_ae_enable(cam_id, bool(controls["ae_enable"]))
if "awb_enable" in controls:
result = self.set_awb_enable(cam_id, bool(controls["awb_enable"]))
if "exposure_time_us" in controls and controls["exposure_time_us"] is not None:
result = self.set_exposure_time(cam_id, int(controls["exposure_time_us"]))
# Se AE está ligado, NÃO aplicar exposição/ganho manual.
# exposure_time_us e analogue_gain ficam apenas como referência/snapshot.
ae_is_on = bool(self.camera_controls[cam_id].get("ae_enable", False))
if "analogue_gain" in controls and controls["analogue_gain"] is not None:
result = self.set_analogue_gain(cam_id, float(controls["analogue_gain"]))
if not ae_is_on:
if "exposure_time_us" in controls and controls["exposure_time_us"] is not None:
result = self.set_exposure_time(cam_id, int(controls["exposure_time_us"]))
if "analogue_gain" in controls and controls["analogue_gain"] is not None:
result = self.set_analogue_gain(cam_id, float(controls["analogue_gain"]))
else:
# Mantém os valores no estado interno só como referência, sem mandar manual exposure.
if "exposure_time_us" in controls and controls["exposure_time_us"] is not None:
self.camera_controls[cam_id]["exposure_time_us"] = int(controls["exposure_time_us"])
if "analogue_gain" in controls and controls["analogue_gain"] is not None:
self.camera_controls[cam_id]["analogue_gain"] = float(controls["analogue_gain"])
result = dict(self.camera_controls[cam_id])
return result

View File

@ -60,10 +60,57 @@ class RawProcessorCore:
self.radiometric_normalization_config = {
"enabled": False,
"method": "exposure_gain_reference",
"reference_controls": {},
"method": "oak_ae_frame_controls_v1",
"apply_stage": "after_dark_before_flat_gain",
"control_source": "stream_meta.frame_controls",
"role_mapping_source": "camera_info",
"factor_model": "exposure_time_us_x_iso",
"iso_base": 100.0,
"reference_mode": "fixed",
"reference_controls": {
"rgb": {
"exposure_time_us": 10000,
"sensitivity_iso": 400,
},
"re": {
"exposure_time_us": 15000,
"sensitivity_iso": 400,
},
"nir": {
"exposure_time_us": 15000,
"sensitivity_iso": 400,
},
},
"scale_limits": {
"default": {
"min": 0.15,
"max": 6.0,
},
"rgb": {
"min": 0.15,
"max": 6.0,
},
"re": {
"min": 0.15,
"max": 8.0,
},
"nir": {
"min": 0.15,
"max": 8.0,
},
},
"missing_controls_policy": "skip",
"invalid_controls_policy": "skip",
"clip_output": False,
"save_debug": True,
}
self.last_radiometric_normalization_result = None
self.radiometric_config = {}
self.patch_normalization_config = {
"enabled": False,
@ -377,9 +424,18 @@ class RawProcessorCore:
raise RuntimeError("RGB obrigatório")
channel_names = self._channel_names_from_decoded(decoded)
tensor = self.fuse_multispec_cameras(decoded, meta=None, channels_expected=len(channel_names))
tensor = self.fuse_multispec_cameras(
decoded,
meta=None,
channels_expected=len(channel_names),
)
tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor)
if bool((self.patch_normalization_config or {}).get("enabled", False)):
tensor = self.apply_patch_normalization_to_tensor(tensor)
self.last_frame_quality_result = self.evaluate_frame_quality(tensor)
return tensor, channel_names
@ -391,7 +447,13 @@ class RawProcessorCore:
decoded = self.decode_stream_cameras(frame, meta)
tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor)
# Sem cartões neste modo novo.
# patch_normalization deve ficar desligado no JSON.
# Se quiser manter compatibilidade futura, deixe gateado:
if bool((self.patch_normalization_config or {}).get("enabled", False)):
tensor = self.apply_patch_normalization_to_tensor(tensor)
self.last_frame_quality_result = self.evaluate_frame_quality(tensor)
return tensor
@ -402,6 +464,8 @@ class RawProcessorCore:
if frame.ndim != 3:
raise RuntimeError(f"Frame {frame_type} inválido: shape={frame.shape}")
dtype_str = str(meta.get("dtype") or meta.get("output_dtype") or "float32").lower()
if dtype_str == "uint8":
raw_np = frame.astype(np.float32) / 255.0
elif dtype_str == "float32":
@ -412,10 +476,12 @@ class RawProcessorCore:
raise RuntimeError(f"dtype {frame_type} não suportado: {dtype_str}")
if raw_np.shape[0] != channels_expected:
raise RuntimeError(f"Frame {frame_type} com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected}")
tensor = raw_np
raise RuntimeError(
f"Frame {frame_type} com canais inesperados: "
f"{raw_np.shape[0]} | esperado={channels_expected}"
)
tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.resize_tensor_chw(raw_np, target_size=target_size)
self.last_frame_quality_result = self.evaluate_frame_quality(tensor)
return tensor
@ -2095,16 +2161,38 @@ class RawProcessorCore:
def normalize_decoded_by_capture_controls(self, decoded: dict, meta: dict | None = None) -> dict:
cfg = self.radiometric_normalization_config or {}
self.last_radiometric_normalization_result = None
result = {
"enabled": bool(cfg.get("enabled", False)),
"applied": False,
"method": cfg.get("method", "oak_ae_frame_controls_v1"),
"warnings": [],
"by_role": {},
"by_camera": {},
"summary": {},
}
if not cfg.get("enabled", False):
result["warnings"].append("radiometric_normalization_disabled")
self.last_radiometric_normalization_result = result
return decoded
method = str(cfg.get("method", "exposure_gain_reference")).lower()
if method != "exposure_gain_reference":
method = str(cfg.get("method", "oak_ae_frame_controls_v1")).lower()
if method not in ("oak_ae_frame_controls_v1", "exposure_iso_reference"):
result["warnings"].append(f"unsupported_method:{method}")
self.last_radiometric_normalization_result = result
return decoded
controls = self._extract_actual_controls_from_meta(meta)
if not controls:
controls_by_role, controls_by_cam = self._extract_frame_controls_from_meta_by_role(meta)
if not controls_by_role:
result["warnings"].append("missing_frame_controls")
self.last_radiometric_normalization_result = result
if str(cfg.get("missing_controls_policy", "skip")).lower() == "raise":
raise RuntimeError("radiometric_normalization ativo, mas meta.frame_controls ausente.")
return decoded
reference_controls = cfg.get("reference_controls", {}) or {}
@ -2118,47 +2206,225 @@ class RawProcessorCore:
if img is None or not role:
normalized[cam_id] = item
result["warnings"].append(f"{cam_id}:missing_image_or_role")
continue
actual_ctrl = controls.get(role, {}) or {}
actual_ctrl = controls_by_role.get(role, {}) or {}
ref_ctrl = reference_controls.get(role, {}) or {}
ref_ctrl = (
reference_controls.get(role)
or self.camera_settings.get(role)
or actual_ctrl
or {}
)
actual_factor = self._exposure_gain_factor(actual_ctrl)
ref_factor = self._exposure_gain_factor(ref_ctrl)
actual_factor = self._radiometric_factor_from_controls(actual_ctrl, cfg)
ref_factor = self._radiometric_factor_from_controls(ref_ctrl, cfg)
if actual_factor <= 0 or ref_factor <= 0:
normalized[cam_id] = item
result["warnings"].append(
f"{role}:invalid_factor actual={actual_factor:.6g} ref={ref_factor:.6g}"
)
if str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise":
raise RuntimeError(f"Controles radiométricos inválidos para role={role}: {actual_ctrl}")
continue
scale = ref_factor / actual_factor
raw_scale = float(ref_factor / actual_factor)
scale = self._clip_radiometric_scale(raw_scale, role, cfg)
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
out = img.astype(np.float32) * float(scale)
out = img.astype(np.float32) * scale
if clip_output:
out = np.clip(out, 0.0, 1.0)
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
debug = {
"applied": True,
"method": method,
"role": role,
"camera_id": cam_id,
"actual_controls": dict(actual_ctrl),
"reference_controls": dict(ref_ctrl),
"actual_factor": float(actual_factor),
"reference_factor": float(ref_factor),
"scale_raw": float(raw_scale),
"scale_applied": float(scale),
"clip_output": clip_output,
}
new_meta["radiometric_normalization"] = debug
new_meta["radiometric_normalization_applied"] = True
new_meta["radiometric_normalization_method"] = method
new_meta["radiometric_normalization_scale"] = float(scale)
new_meta["radiometric_actual_factor"] = float(actual_factor)
new_meta["radiometric_reference_factor"] = float(ref_factor)
new_item["image"] = out.astype(np.float32, copy=False)
new_item["meta"] = new_meta
normalized[cam_id] = new_item
result["by_role"][role] = debug
result["by_camera"][cam_id] = debug
result["applied"] = any(
bool(v.get("applied", False))
for v in result["by_camera"].values()
if isinstance(v, dict)
)
scales = [
float(v.get("scale_applied", 1.0))
for v in result["by_camera"].values()
if isinstance(v, dict) and v.get("applied", False)
]
result["summary"] = {
"applied_count": int(len(scales)),
"scale_min": float(min(scales)) if scales else None,
"scale_max": float(max(scales)) if scales else None,
"scale_mean": float(np.mean(scales)) if scales else None,
"warning_count": int(len(result["warnings"])),
}
self.last_radiometric_normalization_result = result
return normalized
def _extract_frame_controls_from_meta_by_role(self, meta: dict | None) -> tuple[dict, dict]:
"""
Retorna:
controls_by_role = {
"rgb": {...},
"re": {...},
"nir": {...}
}
controls_by_cam = {
"CAM_A": {...},
"CAM_B": {...},
"CAM_C": {...}
}
Fonte principal:
meta["frame_controls"]
Também aceita:
meta["stream_meta"]["frame_controls"]
"""
if not isinstance(meta, dict):
return {}, {}
stream_meta = meta.get("stream_meta") if isinstance(meta.get("stream_meta"), dict) else None
frame_controls = meta.get("frame_controls")
if not isinstance(frame_controls, dict) and stream_meta is not None:
frame_controls = stream_meta.get("frame_controls")
if not isinstance(frame_controls, dict) or not frame_controls:
return {}, {}
camera_info = meta.get("camera_info")
if not isinstance(camera_info, dict) and stream_meta is not None:
camera_info = stream_meta.get("camera_info")
camera_info = camera_info if isinstance(camera_info, dict) else {}
controls_by_cam = {}
controls_by_role = {}
for cam_id, ctrl in frame_controls.items():
if not isinstance(ctrl, dict):
continue
cam_id = str(cam_id)
controls_by_cam[cam_id] = dict(ctrl)
role = str(
(camera_info.get(cam_id, {}) or {}).get("role", "")
).lower()
if not role:
# fallback defensivo caso algum meta antigo venha sem camera_info
role = self._role_from_cam_id_fallback(cam_id)
if role:
controls_by_role[role] = dict(ctrl)
return controls_by_role, controls_by_cam
def _role_from_cam_id_fallback(self, cam_id: str) -> str:
"""
Fallback fraco. usado se o meta não tiver camera_info.
No fluxo novo, camera_info sempre deve existir.
"""
cam_id = str(cam_id).upper()
role_map = {
"CAM_A": "rgb",
"CAM_B": "re",
"CAM_C": "nir",
}
return role_map.get(cam_id, "")
def _radiometric_factor_from_controls(self, ctrl: dict, cfg: dict) -> float:
"""
Modelo físico simples:
fator = exposure_time_us * (sensitivity_iso / iso_base)
Esse fator representa a amplificação aproximada do sinal causada pela câmera.
"""
if not isinstance(ctrl, dict):
return 0.0
iso_base = float(cfg.get("iso_base", 100.0) or 100.0)
exp = ctrl.get("exposure_time_us", None)
iso = ctrl.get("sensitivity_iso", None)
# Compatibilidade com contrato antigo
gain = ctrl.get("analogue_gain", None)
gain_est = ctrl.get("analogue_gain_est", None)
try:
exp = float(exp)
except Exception:
exp = 0.0
if exp <= 0:
return 0.0
try:
if iso is not None:
gain_factor = float(iso) / iso_base
elif gain_est is not None:
gain_factor = float(gain_est)
elif gain is not None:
gain_factor = float(gain)
else:
gain_factor = 1.0
except Exception:
gain_factor = 1.0
if gain_factor <= 0:
gain_factor = 1.0
return float(exp * gain_factor)
def _clip_radiometric_scale(self, scale: float, role: str, cfg: dict) -> float:
limits = cfg.get("scale_limits", {}) or {}
role_limits = limits.get(role)
if not isinstance(role_limits, dict):
role_limits = limits.get("default", {}) or {}
scale_min = float(role_limits.get("min", 0.15))
scale_max = float(role_limits.get("max", 6.0))
if scale_min <= 0:
scale_min = 0.001
if scale_max < scale_min:
scale_max = scale_min
return float(np.clip(float(scale), scale_min, scale_max))
def _extract_actual_controls_from_meta(self, meta: dict | None) -> dict:
if not meta:
return {}
@ -2201,3 +2467,8 @@ class RawProcessorCore:
gain = 1.0
return float(exp * gain)
def get_last_radiometric_normalization_result(self):
return self.last_radiometric_normalization_result

View File

@ -8,7 +8,7 @@ import cv2
import numpy as np
from core.raw_processor_core import RawProcessorCore
from core.raw_processor_preview import RawProcessorPreview
from core.oak_fcc3_client import OakFcc3Client
def load_json(path: Path) -> dict:
@ -278,7 +278,12 @@ def build_multispec_from_raw_native_multi(group: dict, meta: dict):
return tensor, f"MULTISPEC gerado offline do RAW_BRUTO | shape={list(tensor.shape)}", processing_info
def build_visual_from_saved_payload(payload_path: Path, meta: dict, cam_id: str | None = None) -> tuple[np.ndarray, str]:
def build_visual_from_saved_payload(
payload_path: Path,
meta: dict,
cam_id: str | None = None,
client: OakFcc3Client | None = None,
) -> tuple[np.ndarray, str]:
"""
Retorna:
preview_bgr_reconstructed
@ -293,66 +298,14 @@ def build_visual_from_saved_payload(payload_path: Path, meta: dict, cam_id: str
if cam_id is None:
raise RuntimeError("cam_id é obrigatório para saved_payload_type='raw_native_multi'")
saved_dtypes = meta.get("saved_payload_dtypes", {}) or {}
saved_shapes = meta.get("saved_payload_shapes", {}) or {}
saved_dtype = saved_dtypes.get(cam_id)
saved_shape = saved_shapes.get(cam_id)
if saved_dtype is None or saved_shape is None:
raise RuntimeError(
f"JSON não contém saved_payload_dtypes/saved_payload_shapes para {cam_id}"
if client is not None:
return build_camera_preview_with_client(
client=client,
payload_path=payload_path,
meta=meta,
cam_id=cam_id,
)
np_dtype = np.dtype(saved_dtype)
raw = np.fromfile(str(payload_path), dtype=np_dtype)
arr = raw.reshape(tuple(saved_shape))
# Busca metadados da câmera no stream_meta
stream_meta = meta.get("stream_meta", {}) or {}
cam_frames = stream_meta.get("camera_info", {}) or {}
cam_meta = cam_frames.get(cam_id, {}) or {}
role = cam_meta.get("role", cam_id)
interface = cam_meta.get("interface", "")
bit_depth = int(cam_meta.get("bit_depth", 10))
bayer = cam_meta.get("bayer_pattern", meta.get("bayer_pattern", "BGGR"))
# USB RGB nativo
if interface.upper() == "USB" or (arr.ndim == 3 and arr.shape[2] == 3 and arr.dtype == np.uint8):
preview_bgr = arr.copy()
desc = f"{cam_id} | role={role} | USB/RGB nativo | dtype={arr.dtype} | shape={arr.shape}"
return preview_bgr, desc
# CSI RAW packed mono
sensor_width, sensor_height = resolve_sensor_dims_for_raw10_packed(arr, cam_meta, meta)
core = RawProcessorCore(
sensor_width=sensor_width,
sensor_height=sensor_height,
bayer_pattern=bayer,
)
preview = RawProcessorPreview(
sensor_width=sensor_width,
sensor_height=sensor_height,
bayer_pattern=bayer,
)
packed = arr
if packed.ndim == 3 and packed.shape[2] == 1:
packed = packed[:, :, 0]
raw16 = core.unpack_raw10_packed(packed)
preview_bgr = preview.raw16_to_preview_bgr(raw16, bit_depth=bit_depth)
desc = (
f"{cam_id} | role={role} | RAW packed mono | "
f"dtype={arr.dtype} | shape={arr.shape} | "
f"sensor={sensor_width}x{sensor_height} | "
f"bayer={bayer} | bit_depth={bit_depth}"
)
return preview_bgr, desc
# =========================================================
# Caso payload único
# =========================================================
@ -477,7 +430,7 @@ def build_visual_from_saved_payload(payload_path: Path, meta: dict, cam_id: str
raise RuntimeError(f"saved_payload_type não suportado neste script: {saved_type}")
def build_panels_from_group(group):
def build_panels_from_group(group, client: OakFcc3Client | None = None):
panels = []
meta = load_json(group["json"])
@ -539,7 +492,12 @@ def build_panels_from_group(group):
# Continua mostrando CAM_A/CAM_B/CAM_C reconstruídas individualmente.
# =========================================================
for cam_id, path in group["cameras"].items():
img, desc = build_visual_from_saved_payload(path, meta, cam_id=cam_id)
img, desc = build_visual_from_saved_payload(
path,
meta,
cam_id=cam_id,
client=client,
)
panels.append((f"{cam_id} reconstruido", img, desc))
return panels
@ -1063,11 +1021,11 @@ def resolve_navigation_inputs(input_path: Path) -> tuple[list[Path], int]:
return entries, idx
def render_group_to_canvas(json_path: Path, max_width: int):
def render_group_to_canvas(json_path: Path, max_width: int, client: OakFcc3Client | None = None):
group = resolve_capture_group(json_path)
meta = load_json(group["json"])
panels = build_panels_from_group(group)
panels = build_panels_from_group(group, client=client)
panels = sort_panels(panels)
canvas = compose_panels(panels, max_width=max_width)
@ -1084,6 +1042,85 @@ def render_group_to_canvas(json_path: Path, max_width: int):
return canvas, info
def load_camera_payload_from_saved(meta: dict, payload_path: Path, cam_id: str) -> np.ndarray:
saved_dtypes = meta.get("saved_payload_dtypes", {}) or {}
saved_shapes = meta.get("saved_payload_shapes", {}) or {}
saved_dtype = saved_dtypes.get(cam_id)
saved_shape = saved_shapes.get(cam_id)
if saved_dtype is None or saved_shape is None:
raise RuntimeError(
f"JSON não contém saved_payload_dtypes/saved_payload_shapes para {cam_id}"
)
raw = np.fromfile(str(payload_path), dtype=np.dtype(saved_dtype))
return raw.reshape(tuple(saved_shape))
def build_camera_preview_with_client(
client: OakFcc3Client,
payload_path: Path,
meta: dict,
cam_id: str,
) -> tuple[np.ndarray, str]:
stream_meta = meta.get("stream_meta", {}) or {}
camera_info = stream_meta.get("camera_info", {}) or {}
cam_meta = camera_info.get(cam_id, {}) or {}
role = cam_meta.get("role", cam_id)
interface = str(cam_meta.get("interface", "")).upper()
arr = load_camera_payload_from_saved(meta, payload_path, cam_id)
# Caso RGB/USB nativo, mantém comportamento direto.
if interface == "USB" or (arr.ndim == 3 and arr.shape[2] == 3 and arr.dtype == np.uint8):
desc = f"{cam_id} | role={role} | USB/RGB nativo | dtype={arr.dtype} | shape={arr.shape}"
return arr.copy(), desc
if cam_id == "CAM_A":
preview_bgr = client.build_save_preview_from_cam_a(
packed_raw_by_camera={"CAM_A": arr},
meta_stream=stream_meta,
sensor_width=int(meta.get("sensor_width", 1280)),
sensor_height=int(meta.get("sensor_height", 800)),
bayer_pattern=meta.get("bayer_pattern", "RGGB"),
)
if preview_bgr is None:
raise RuntimeError("client.build_save_preview_from_cam_a retornou None para CAM_A.")
bayer = (
cam_meta.get("bayer_pattern")
or cam_meta.get("bayer")
or meta.get("bayer_pattern")
or "RGGB"
)
desc = (
f"{cam_id} | role={role} | preview centralizado no OakFcc3Client | "
f"dtype={arr.dtype} | shape={arr.shape} | bayer={bayer}"
)
return preview_bgr, desc
# Para CAM_B/C por enquanto mantém mono visual simples via caminho antigo?
# Melhor: usar build_visual_preview_from_raw do client, que também está centralizado.
previews = client.build_visual_preview_from_raw(
frame={cam_id: arr},
meta={"camera_info": {cam_id: cam_meta}},
)
if cam_id not in previews:
raise RuntimeError(f"client.build_visual_preview_from_raw não retornou {cam_id}. Chaves={list(previews.keys())}")
desc = (
f"{cam_id} | role={role} | preview via OakFcc3Client.build_visual_preview_from_raw | "
f"dtype={arr.dtype} | shape={arr.shape}"
)
return previews[cam_id], desc
def main():
parser = argparse.ArgumentParser(
description="Valida visualmente payload salvo (.bin/.raw/.json/.png) comparando com o preview .png"
@ -1095,12 +1132,25 @@ def main():
input_path = Path(args.input_path)
entries, current_idx = resolve_navigation_inputs(input_path)
client = OakFcc3Client(
width=1280,
height=800,
bayer="RGGB",
frame_type="RAW_BRUTO",
capture_mode="SINGLE",
raw_policy="allow_single",
)
window_name = "Validacao payload | A=anterior | D=proximo | T=salva tensor offline | Q/Esc=sair"
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
while True:
current_json = entries[current_idx]
canvas, info = render_group_to_canvas(current_json, max_width=args.max_width)
canvas, info = render_group_to_canvas(
current_json,
max_width=args.max_width,
client=client,
)
# Cabeçalho adicional na imagem
overlay = canvas.copy()