ajustes na calibragem e visualizacao

This commit is contained in:
Diego Freitas 2026-05-06 13:27:38 -03:00
parent 1f871dc700
commit 6e166748cf
5 changed files with 490 additions and 93 deletions

View File

@ -175,18 +175,14 @@ class OakFcc3Client:
return self.svc.capture_frame(timeout=timeout) return self.svc.capture_frame(timeout=timeout)
def get_next_frame(self, timeout=2.0): def get_next_frame(self, timeout=2.0):
frame, meta, _ = self.get_next_decoded( frame, meta, _ = self.get_next_decoded(timeout=timeout)
timeout=timeout,
update_radiometry=False,
)
return frame, meta return frame, meta
def get_next_decoded(self, timeout=2.0, update_radiometry=True): def get_next_decoded(self, timeout=2.0):
raw_frame, raw_meta = self.get_next_raw_frame(timeout=timeout) raw_frame, raw_meta = self.get_next_raw_frame(timeout=timeout)
decoded = self.decode_stream_cameras(raw_frame, raw_meta) decoded = self.decode_stream_cameras(raw_frame, raw_meta)
if update_radiometry:
self.update_radiometry(decoded, raw_meta) self.update_radiometry(decoded, raw_meta)
frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper() frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper()

View File

@ -11,6 +11,7 @@ class RawProcessorCore:
self.sensor_width = sensor_width self.sensor_width = sensor_width
self.sensor_height = sensor_height self.sensor_height = sensor_height
self.bayer_pattern = bayer_pattern.upper() self.bayer_pattern = bayer_pattern.upper()
self.fusion_config = { self.fusion_config = {
"alignment_mode": "manual_affine", "alignment_mode": "manual_affine",
"baseline_mm": 75.0, "baseline_mm": 75.0,
@ -26,6 +27,7 @@ class RawProcessorCore:
"resize_after_crop": True, "resize_after_crop": True,
"target_size": None, "target_size": None,
} }
self.rgb_calibration = { self.rgb_calibration = {
"enabled": False, "enabled": False,
"gains": { "gains": {
@ -53,6 +55,14 @@ class RawProcessorCore:
self.flatfield_maps = {} self.flatfield_maps = {}
self.flatfield_loaded = False self.flatfield_loaded = False
self.radiometric_normalization_config = {
"enabled": False,
"method": "exposure_gain_reference",
"reference_controls": {},
"clip_output": False,
}
self.camera_settings = {}
if calibration_json_path: if calibration_json_path:
self.load_fusion_config_json(calibration_json_path) self.load_fusion_config_json(calibration_json_path)
@ -469,10 +479,67 @@ class RawProcessorCore:
return decoded return decoded
def _decode_spectral_frame_to_float01(self, data, cam_meta):
arr = data
if arr.ndim == 3 and arr.shape[2] == 1:
arr = arr[:, :, 0]
bit_depth = int(cam_meta.get("bit_depth", 8))
raw_format = str(cam_meta.get("raw_format", "")).upper()
packed = bool(cam_meta.get("packed", False))
channels = int(cam_meta.get("channels", 1)) if cam_meta.get("channels") is not None else 1
sensor_width = int(cam_meta.get("width", self.sensor_width))
sensor_height = int(cam_meta.get("height", arr.shape[0]))
packed_width = int(cam_meta.get("packed_width", 0) or 0)
looks_like_raw10_packed = (
arr.ndim == 2
and arr.dtype == np.uint8
and arr.shape[0] == sensor_height
and (
raw_format == "RAW10_PACKED"
or packed
or bit_depth == 10
or (packed_width > 0 and arr.shape[1] == packed_width and packed_width != sensor_width)
or arr.shape[1] == int(sensor_width * 10 / 8)
)
)
if looks_like_raw10_packed:
raw16 = self.unpack_raw10_packed(
arr,
sensor_width=sensor_width,
sensor_height=sensor_height,
)
max_val = float((1 << bit_depth) - 1)
return np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0)
# Caso preview/processado: mono já vem uint8 normal.
if arr.ndim == 2 and arr.dtype == np.uint8:
return np.clip(arr.astype(np.float32) / 255.0, 0.0, 1.0)
if arr.ndim == 2 and arr.dtype == np.uint16:
max_val = float((1 << bit_depth) - 1) if bit_depth > 0 and bit_depth <= 16 else 65535.0
return np.clip(arr.astype(np.float32) / max_val, 0.0, 1.0)
arr01 = arr.astype(np.float32)
if arr01.max() > 1.5:
arr01 /= 255.0
return np.clip(arr01, 0.0, 1.0)
def fuse_multispec_cameras(self, decoded, meta, channels_expected): def fuse_multispec_cameras(self, decoded, meta, channels_expected):
# Flat-field pertence ao espaço nativo de cada câmera. # 1) Coloca todos os frames na mesma escala de exposição/ganho de referência
# Por isso é aplicado antes de warp/homografia/crop comum. decoded = self.normalize_decoded_by_capture_controls(decoded, meta)
decoded = self.apply_flatfield_to_decoded(decoded)
# 2) Subtrai dark/offset no espaço individual de cada câmera
decoded = self.apply_dark_to_decoded(decoded)
# 3) Aplica o ganho espacial do flat field no espaço individual de cada câmera
decoded = self.apply_flat_gain_to_decoded(decoded)
rgb_cam_id = self._find_cam_by_role(decoded, "rgb") rgb_cam_id = self._find_cam_by_role(decoded, "rgb")
if rgb_cam_id is None: if rgb_cam_id is None:
@ -801,7 +868,6 @@ class RawProcessorCore:
return arr.reshape(shape) return arr.reshape(shape)
def save_rgb_u8_file(self, path: str, arr: np.ndarray): def save_rgb_u8_file(self, path: str, arr: np.ndarray):
arr.astype(np.uint8).tofile(path) arr.astype(np.uint8).tofile(path)
@ -862,23 +928,47 @@ class RawProcessorCore:
fusion = data.get("fusion_config") fusion = data.get("fusion_config")
if isinstance(fusion, dict): if isinstance(fusion, dict):
self.fusion_config = self._merge_fusion_config(self.fusion_config, fusion) self.fusion_config = self._merge_config(self.fusion_config, fusion)
else: else:
print("[WARN] JSON sem fusion_config. Mantendo config padrão.") print("[WARN] JSON sem fusion_config. Mantendo config padrão.")
rgb_cal = data.get("rgb_calibration") rgb_cal = data.get("rgb_calibration")
if isinstance(rgb_cal, dict): if isinstance(rgb_cal, dict):
self.rgb_calibration = self._merge_fusion_config(self.rgb_calibration, rgb_cal) self.rgb_calibration = self._merge_config(self.rgb_calibration, rgb_cal)
flatfield = data.get("flatfield_config") flatfield = data.get("flatfield_config")
if isinstance(flatfield, dict): if isinstance(flatfield, dict):
self.flatfield_config = self._merge_fusion_config(self.flatfield_config, flatfield) self.flatfield_config = self._merge_config(self.flatfield_config, flatfield)
self.load_flatfield_maps() self.load_flatfield_maps()
else: else:
self.flatfield_config["enabled"] = False self.flatfield_config["enabled"] = False
self.flatfield_maps = {} self.flatfield_maps = {}
self.flatfield_loaded = False self.flatfield_loaded = False
rad_norm_config = data.get("radiometric_normalization")
if isinstance(rad_norm_config, dict):
self.radiometric_normalization_config = self._merge_config(self.radiometric_normalization_config, rad_norm_config)
cam_set = data.get("camera_settings")
if isinstance(cam_set, dict):
self.camera_settings = self._merge_config(self.camera_settings, cam_set)
def _merge_config(self, default_cfg: dict, loaded_cfg: dict) -> dict:
cfg = json.loads(json.dumps(default_cfg))
def merge(dst: dict, src: dict):
for key, value in src.items():
if isinstance(value, dict) and isinstance(dst.get(key), dict):
merge(dst[key], value)
else:
dst[key] = value
if isinstance(loaded_cfg, dict):
merge(cfg, loaded_cfg)
return cfg
def _resolve_calibration_path(self, path: str) -> str: def _resolve_calibration_path(self, path: str) -> str:
if not path: if not path:
return "" return ""
@ -966,12 +1056,16 @@ class RawProcessorCore:
return self.flatfield_loaded return self.flatfield_loaded
def apply_flatfield_to_decoded(self, decoded: dict) -> dict: def apply_dark_to_decoded(self, decoded: dict) -> dict:
cfg = self.flatfield_config or {} cfg = self.flatfield_config or {}
if not cfg.get("enabled", False): if not cfg.get("enabled", False):
return decoded return decoded
subtract_dark = bool(cfg.get("subtract_dark", True))
if not subtract_dark:
return decoded
if not self.flatfield_loaded: if not self.flatfield_loaded:
self.load_flatfield_maps() self.load_flatfield_maps()
@ -990,8 +1084,6 @@ class RawProcessorCore:
new_item = dict(item) new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {}) new_meta = dict(item.get("meta", {}) or {})
subtract_dark = bool(cfg.get("subtract_dark", False))
clip_output = bool(cfg.get("clip_output", True))
if role == "rgb": if role == "rgb":
if img.ndim != 3 or img.shape[2] < 3: if img.ndim != 3 or img.shape[2] < 3:
@ -999,11 +1091,70 @@ class RawProcessorCore:
continue continue
out = img.astype(np.float32).copy() out = img.astype(np.float32).copy()
for idx, ch in enumerate(("R", "G", "B")): for idx, ch in enumerate(("R", "G", "B")):
out[:, :, idx] = self._apply_flatfield_single_channel( out[:, :, idx] = self._subtract_dark_single_channel(
out[:, :, idx],
ch,
)
new_item["image"] = out
elif role in ("re", "nir"):
ch = "RE" if role == "re" else "NIR"
new_item["image"] = self._subtract_dark_single_channel(
img.astype(np.float32),
ch,
)
else:
corrected[cam_id] = item
continue
new_meta["dark_applied"] = True
new_item["meta"] = new_meta
corrected[cam_id] = new_item
return corrected
def apply_flat_gain_to_decoded(self, decoded: dict) -> dict:
cfg = self.flatfield_config or {}
if not cfg.get("enabled", False):
return decoded
if not self.flatfield_loaded:
self.load_flatfield_maps()
if not self.flatfield_loaded:
return decoded
clip_output = bool(cfg.get("clip_output", True))
corrected = {}
for cam_id, item in decoded.items():
role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower()
img = item.get("image")
if img is None:
corrected[cam_id] = item
continue
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
if role == "rgb":
if img.ndim != 3 or img.shape[2] < 3:
corrected[cam_id] = item
continue
out = img.astype(np.float32).copy()
for idx, ch in enumerate(("R", "G", "B")):
out[:, :, idx] = self._apply_flat_gain_single_channel(
out[:, :, idx], out[:, :, idx],
ch, ch,
subtract_dark=subtract_dark,
clip_output=clip_output, clip_output=clip_output,
) )
@ -1011,10 +1162,10 @@ class RawProcessorCore:
elif role in ("re", "nir"): elif role in ("re", "nir"):
ch = "RE" if role == "re" else "NIR" ch = "RE" if role == "re" else "NIR"
new_item["image"] = self._apply_flatfield_single_channel(
new_item["image"] = self._apply_flat_gain_single_channel(
img.astype(np.float32), img.astype(np.float32),
ch, ch,
subtract_dark=subtract_dark,
clip_output=clip_output, clip_output=clip_output,
) )
@ -1029,11 +1180,38 @@ class RawProcessorCore:
return corrected return corrected
def _apply_flatfield_single_channel( def _subtract_dark_single_channel(
self,
img: np.ndarray,
channel_name: str,
) -> np.ndarray:
ch = str(channel_name).upper()
entry = self.flatfield_maps.get(ch)
if not entry:
return img.astype(np.float32, copy=False)
dark = entry.get("dark")
if dark is None:
return img.astype(np.float32, copy=False)
base = img.astype(np.float32)
dark = dark.astype(np.float32)
if dark.shape[:2] != base.shape[:2]:
dark = cv2.resize(
dark,
(base.shape[1], base.shape[0]),
interpolation=cv2.INTER_LINEAR,
)
out = np.maximum(base - dark, 0.0)
return out.astype(np.float32, copy=False)
def _apply_flat_gain_single_channel(
self, self,
img: np.ndarray, img: np.ndarray,
channel_name: str, channel_name: str,
subtract_dark: bool = False,
clip_output: bool = True, clip_output: bool = True,
) -> np.ndarray: ) -> np.ndarray:
ch = str(channel_name).upper() ch = str(channel_name).upper()
@ -1046,71 +1224,133 @@ class RawProcessorCore:
if gain is None: if gain is None:
return img.astype(np.float32, copy=False) return img.astype(np.float32, copy=False)
if gain.shape[:2] != img.shape[:2]: base = img.astype(np.float32)
gain = gain.astype(np.float32)
if gain.shape[:2] != base.shape[:2]:
gain = cv2.resize( gain = cv2.resize(
gain.astype(np.float32), gain,
(img.shape[1], img.shape[0]), (base.shape[1], base.shape[0]),
interpolation=cv2.INTER_LINEAR, interpolation=cv2.INTER_LINEAR,
) )
base = img.astype(np.float32) out = base * gain
if subtract_dark and "dark" in entry:
dark = entry["dark"].astype(np.float32)
if dark.shape[:2] != img.shape[:2]:
dark = cv2.resize(dark, (img.shape[1], img.shape[0]), interpolation=cv2.INTER_LINEAR)
base = np.maximum(base - dark, 0.0)
out = base * gain.astype(np.float32)
if clip_output: if clip_output:
out = np.clip(out, 0.0, 1.0) out = np.clip(out, 0.0, 1.0)
return out.astype(np.float32, copy=False) return out.astype(np.float32, copy=False)
def _merge_fusion_config(self, default_cfg: dict, loaded_cfg: dict) -> dict:
cfg = json.loads(json.dumps(default_cfg))
for key, value in loaded_cfg.items(): def normalize_decoded_by_capture_controls(self, decoded: dict, meta: dict | None = None) -> dict:
if isinstance(value, dict) and isinstance(cfg.get(key), dict): cfg = self.radiometric_normalization_config or {}
cfg[key].update(value)
else:
cfg[key] = value
return cfg if not cfg.get("enabled", False):
return decoded
def _decode_spectral_frame_to_float01(self, data, cam_meta): method = str(cfg.get("method", "exposure_gain_reference")).lower()
arr = data if method != "exposure_gain_reference":
return decoded
if arr.ndim == 3 and arr.shape[2] == 1: controls = self._extract_actual_controls_from_meta(meta)
arr = arr[:, :, 0] if not controls:
return decoded
bit_depth = int(cam_meta.get("bit_depth", 8)) reference_controls = cfg.get("reference_controls", {}) or {}
channels = int(cam_meta.get("channels", 1)) if cam_meta.get("channels") is not None else 1 clip_output = bool(cfg.get("clip_output", False))
# Caso preview/processado: mono já vem uint8/uint16 normal. normalized = {}
if arr.ndim == 2 and arr.dtype == np.uint8:
return np.clip(arr.astype(np.float32) / 255.0, 0.0, 1.0)
if arr.ndim == 2 and arr.dtype == np.uint16 and bit_depth != 10: for cam_id, item in decoded.items():
return np.clip(arr.astype(np.float32) / 65535.0, 0.0, 1.0) role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower()
img = item.get("image")
if bit_depth == 10: if img is None or not role:
sensor_width = int(cam_meta.get("width", self.sensor_width)) normalized[cam_id] = item
sensor_height = int(cam_meta.get("height", arr.shape[0])) continue
raw16 = self.unpack_raw10_packed( actual_ctrl = controls.get(role, {}) or {}
arr,
sensor_width=sensor_width, ref_ctrl = (
sensor_height=sensor_height, reference_controls.get(role)
or self.camera_settings.get(role)
or actual_ctrl
or {}
) )
max_val = float((1 << bit_depth) - 1) actual_factor = self._exposure_gain_factor(actual_ctrl)
return np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0) ref_factor = self._exposure_gain_factor(ref_ctrl)
# fallback if actual_factor <= 0 or ref_factor <= 0:
arr01 = arr.astype(np.float32) normalized[cam_id] = item
if arr01.max() > 1.5: continue
arr01 /= 255.0
return np.clip(arr01, 0.0, 1.0) scale = ref_factor / actual_factor
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
out = img.astype(np.float32) * float(scale)
if clip_output:
out = np.clip(out, 0.0, 1.0)
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
return normalized
def _extract_actual_controls_from_meta(self, meta: dict | None) -> dict:
if not meta:
return {}
# Preferência: controles reais daquele frame.
controls = meta.get("actual_camera_controls")
if isinstance(controls, dict) and controls:
return controls
# Possíveis nomes alternativos.
controls = meta.get("camera_controls")
if isinstance(controls, dict) and controls:
return controls
controls = meta.get("startup_camera_controls")
if isinstance(controls, dict) and controls:
return controls
# Em alguns casos o JSON da captura pode ter stream_meta separado,
# mas se o meta recebido aqui for só stream_meta, talvez não tenha controles.
return {}
def _exposure_gain_factor(self, ctrl: dict) -> float:
if not isinstance(ctrl, dict):
return 0.0
exp = ctrl.get("exposure_time_us", None)
gain = ctrl.get("analogue_gain", None)
try:
exp = float(exp)
except Exception:
exp = 0.0
try:
gain = float(gain)
except Exception:
gain = 1.0
if exp <= 0:
return 0.0
if gain <= 0:
gain = 1.0
return float(exp * gain)

View File

@ -29,6 +29,112 @@ def chw_to_hwc(arr: np.ndarray) -> np.ndarray:
return np.transpose(arr, (1, 2, 0)) return np.transpose(arr, (1, 2, 0))
def tensor_to_preview_panels(tensor: np.ndarray):
"""
Recebe tensor CHW [R,G,B,RE,NIR] float32 e devolve painéis visuais.
"""
if tensor.ndim != 3 or tensor.shape[0] < 5:
raise RuntimeError(f"Tensor MULTISPEC inválido: shape={tensor.shape}")
rgb_hwc = np.transpose(tensor[:3].astype(np.float32), (1, 2, 0))
rgb_bgr = normalize_float01_to_bgr(rgb_hwc)
re01 = tensor[3].astype(np.float32)
nir01 = tensor[4].astype(np.float32)
re_bgr = cv2.cvtColor(
np.clip(re01 * 255.0, 0, 255).astype(np.uint8),
cv2.COLOR_GRAY2BGR
)
nir_bgr = cv2.cvtColor(
np.clip(nir01 * 255.0, 0, 255).astype(np.uint8),
cv2.COLOR_GRAY2BGR
)
return [
("MULTISPEC RGB final", rgb_bgr, f"tensor {list(tensor.shape)} | canais 0,1,2"),
("MULTISPEC RE final", re_bgr, "tensor canal 3"),
("MULTISPEC NIR final", nir_bgr, "tensor canal 4"),
]
def build_multispec_from_raw_native_multi(group: dict, meta: dict):
"""
Reconstrói o tensor MULTISPEC final a partir dos .bin RAW_BRUTO salvos.
Usa:
- saved_payload_paths
- saved_payload_shapes
- saved_payload_dtypes
- stream_meta.camera_info
- camera_params_json/module_params.json
"""
if meta.get("saved_payload_type") != "raw_native_multi":
return None, "captura não é raw_native_multi"
stream_meta = meta.get("stream_meta", {}) or {}
camera_info = stream_meta.get("camera_info", {}) or {}
saved_dtypes = meta.get("saved_payload_dtypes", {}) or {}
saved_shapes = meta.get("saved_payload_shapes", {}) or {}
frame = {}
for cam_id, path in group["cameras"].items():
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"Faltam dtype/shape para {cam_id}")
arr = np.fromfile(str(path), dtype=np.dtype(saved_dtype)).reshape(tuple(saved_shape))
frame[cam_id] = arr
if not frame:
raise RuntimeError("Nenhum payload de câmera encontrado para reconstruir MULTISPEC.")
sensor_width = int(meta.get("sensor_width", 1280))
sensor_height = int(meta.get("sensor_height", 800))
bayer = meta.get("bayer_pattern", "RGGB")
# Tenta usar o mesmo module_params que foi usado na captura.
calib_path = meta.get("camera_params_json") or "calibration/module_params.json"
# Se vier relativo, tenta resolver relativo ao diretório atual.
# Normalmente seu script roda da raiz do projeto, então calibration/module_params.json funciona.
if calib_path and not os.path.isfile(calib_path):
# fallback: tenta relativo à pasta do JSON
json_dir = Path(group["json"]).parent
alt = json_dir / calib_path
if alt.exists():
calib_path = str(alt)
else:
print(f"[WARN] module_params não encontrado: {calib_path}. Tentando sem calibração.")
calib_path = None
core = RawProcessorCore(
sensor_width=sensor_width,
sensor_height=sensor_height,
bayer_pattern=bayer,
calibration_json_path=calib_path,
)
# O decode precisa do stream_meta com camera_info.
processing_meta = dict(stream_meta)
# A normalização radiométrica precisa dos controles reais salvos no JSON da captura.
if meta.get("actual_camera_controls") is not None:
processing_meta["actual_camera_controls"] = meta.get("actual_camera_controls")
if meta.get("startup_camera_controls") is not None:
processing_meta["startup_camera_controls"] = meta.get("startup_camera_controls")
tensor = core.build_infer_tensor_from_stream(frame, processing_meta, 5)
return tensor, f"MULTISPEC gerado offline do RAW_BRUTO | shape={list(tensor.shape)}"
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) -> tuple[np.ndarray, str]:
""" """
Retorna: Retorna:
@ -236,12 +342,51 @@ def build_panels_from_group(group):
panels = [] panels = []
meta = load_json(group["json"]) meta = load_json(group["json"])
saved_type = meta.get("saved_payload_type")
# =========================================================
# Para RAW_BRUTO multi, o primeiro painel vira o tensor final
# gerado offline a partir dos .bin salvos.
# =========================================================
if saved_type == "raw_native_multi":
try:
tensor, desc = build_multispec_from_raw_native_multi(group, meta)
tensor_panels = tensor_to_preview_panels(tensor)
# Aqui colocamos só o RGB final como painel principal,
# para substituir o antigo PNG salvo.
title, img, subtitle = tensor_panels[0]
panels.append((title, img, desc))
# Opcional: se quiser também ver RE/NIR finais do tensor,
# descomente estas duas linhas:
# panels.append(tensor_panels[1])
# panels.append(tensor_panels[2])
except Exception as e:
# Fallback para o PNG salvo caso a reconstrução falhe.
preview_saved = cv2.imread(str(group["png"]), cv2.IMREAD_COLOR) preview_saved = cv2.imread(str(group["png"]), cv2.IMREAD_COLOR)
if preview_saved is None: if preview_saved is None:
raise RuntimeError(f"Falha ao ler preview PNG: {group['png']}") raise RuntimeError(f"Falha ao ler preview PNG: {group['png']}")
panels.append((
"Preview salvo fallback",
preview_saved,
f"Falha ao gerar MULTISPEC offline: {e}"
))
else:
# Para RGB/MULTISPEC salvos direto, mantém comportamento antigo.
preview_saved = cv2.imread(str(group["png"]), cv2.IMREAD_COLOR)
if preview_saved is None:
raise RuntimeError(f"Falha ao ler preview PNG: {group['png']}")
panels.append(("Preview salvo", preview_saved, f"{preview_saved.shape[1]}x{preview_saved.shape[0]}")) panels.append(("Preview salvo", preview_saved, f"{preview_saved.shape[1]}x{preview_saved.shape[0]}"))
# =========================================================
# Se houver payload final único, reconstrói normalmente.
# Ex: saved_payload_type == multispec
# =========================================================
if group["final_raw"] is not None: if group["final_raw"] is not None:
result, desc = build_visual_from_saved_payload(group["final_raw"], meta) result, desc = build_visual_from_saved_payload(group["final_raw"], meta)
@ -251,6 +396,9 @@ def build_panels_from_group(group):
else: else:
panels.append(("Reconstruido (final)", result, desc)) panels.append(("Reconstruido (final)", result, desc))
# =========================================================
# Continua mostrando CAM_A/CAM_B/CAM_C reconstruídas individualmente.
# =========================================================
for cam_id, path in group["cameras"].items(): 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)
panels.append((f"{cam_id} reconstruido", img, desc)) panels.append((f"{cam_id} reconstruido", img, desc))

View File

@ -96,7 +96,7 @@ def get_image_by_role(decoded: dict, role: str):
def validate_module_ready(status: dict, raw_policy: str): def validate_module_ready(status: dict, raw_policy: str):
if not status.get("ok", True): if not status.get("ok", True):
raise RuntimeError(f"Status inválido retornado pelo módulo: {status}") raise RuntimeError(f"Status invalido retornado pelo modulo: {status}")
active_roles = status.get("active_roles", {}) or {} active_roles = status.get("active_roles", {}) or {}
active_count = int(status.get("camera_count_active", 0)) active_count = int(status.get("camera_count_active", 0))
@ -254,6 +254,19 @@ def extract_channels_from_decoded(decoded: dict) -> dict:
_, re01 = get_image_by_role(decoded, "re") _, re01 = get_image_by_role(decoded, "re")
_, nir01 = get_image_by_role(decoded, "nir") _, nir01 = get_image_by_role(decoded, "nir")
def assert_not_raw10_packed_image(role, img, expected_w=1280):
if img is None:
return
if img.ndim == 2 and img.shape[1] == int(expected_w * 10 / 8):
raise RuntimeError(
f"{role.upper()} parece RAW10_PACKED interpretado como imagem: "
f"shape={img.shape}. Esperado decodificado com largura {expected_w}."
)
assert_not_raw10_packed_image("re", re01, expected_w=1280)
assert_not_raw10_packed_image("nir", nir01, expected_w=1280)
out = {} out = {}
if rgb01 is not None: if rgb01 is not None:
@ -354,7 +367,7 @@ def build_board(decoded, controls, state_lines, progress_lines, preview_scale=1.
"S = pular etapa dark/preto", "S = pular etapa dark/preto",
"Q / ESC = sair sem salvar", "Q / ESC = sair sem salvar",
"", "",
"Dica: branco/preto devem preencher todo o campo de visão.", "Dica: branco/preto devem preencher todo o campo de visao.",
"Para dark-frame perfeito, tampe as lentes em vez de usar fundo preto.", "Para dark-frame perfeito, tampe as lentes em vez de usar fundo preto.",
]) ])
@ -420,12 +433,12 @@ def capture_stage(
progress_lines = [ progress_lines = [
f"Etapa: {stage_name}", f"Etapa: {stage_name}",
f"Descartando frames iniciais: {len(seen_frame_ids)}/{discard_frames}", f"Descartando frames iniciais: {len(seen_frame_ids)}/{discard_frames}",
"Aguardando estabilização de exposição/stream...", "Aguardando estabilizacao de exposicao/stream...",
] ]
board = build_board( board = build_board(
decoded=last_decoded, decoded=last_decoded,
controls=last_controls, controls=last_controls,
state_lines=[f"CALIBRAÇÃO FLAT-FIELD - {stage_name.upper()}"], state_lines=[f"CALIBRACAO FLAT-FIELD - {stage_name.upper()}"],
progress_lines=progress_lines, progress_lines=progress_lines,
preview_scale=preview_scale, preview_scale=preview_scale,
) )
@ -458,7 +471,7 @@ def capture_stage(
board = build_board( board = build_board(
decoded=last_decoded, decoded=last_decoded,
controls=last_controls, controls=last_controls,
state_lines=[f"CALIBRAÇÃO FLAT-FIELD - {stage_name.upper()}"], state_lines=[f"CALIBRACAO FLAT-FIELD - {stage_name.upper()}"],
progress_lines=progress_lines, progress_lines=progress_lines,
preview_scale=preview_scale, preview_scale=preview_scale,
) )
@ -473,7 +486,7 @@ def capture_stage(
k = cv2.waitKey(1) & 0xFF k = cv2.waitKey(1) & 0xFF
if k in (ord("q"), ord("Q"), 27): if k in (ord("q"), ord("Q"), 27):
raise KeyboardInterrupt("Captura cancelada pelo usuário.") raise KeyboardInterrupt("Captura cancelada pelo usuario.")
return channel_stack, controls_log, meta_log return channel_stack, controls_log, meta_log
@ -579,10 +592,10 @@ def show_final_preview(window_name: str, gain_maps: dict, preview_scale: float):
"Flat-field salvo com sucesso.", "Flat-field salvo com sucesso.",
"ENTER/qualquer tecla = fechar", "ENTER/qualquer tecla = fechar",
"", "",
"Use estes mapas antes da fusão geométrica.", "Use estes mapas antes da fusao geometrica.",
"", "",
"Obs: RGB e mono podem ter shapes diferentes;", "Obs: RGB e mono podem ter shapes diferentes;",
"isso é normal se o decode gerar resoluções distintas.", "isso e normal se o decode gerar resolucoes distintas.",
], x=18, y=36) ], x=18, y=36)
top = np.hstack([panels[0], panels[1], panels[2]]) top = np.hstack([panels[0], panels[1], panels[2]])
@ -635,7 +648,7 @@ def wait_for_enter_or_skip(
if allow_skip and k in (ord("s"), ord("S")): if allow_skip and k in (ord("s"), ord("S")):
return "skip" return "skip"
if k in (ord("q"), ord("Q"), 27): if k in (ord("q"), ord("Q"), 27):
raise KeyboardInterrupt("Cancelado pelo usuário.") raise KeyboardInterrupt("Cancelado pelo usuario.")
# ============================================================ # ============================================================
@ -644,7 +657,7 @@ def wait_for_enter_or_skip(
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Calibrador automático de flat-field/dark-frame para o módulo RGB/RE/NIR OAK-FCC-3.", description="Calibrador automatico de flat-field/dark-frame para o módulo RGB/RE/NIR OAK-FCC-3.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter, formatter_class=argparse.ArgumentDefaultsHelpFormatter,
) )
@ -721,7 +734,7 @@ def main():
output_dtype="uint8", output_dtype="uint8",
capture_mode=args.capture_mode, capture_mode=args.capture_mode,
raw_policy=args.raw_policy, raw_policy=args.raw_policy,
module_calibration_json=args.module_calibration_json module_calibration_json=args.module_calibration_json or None
) as cam: ) as cam:
validate_module_ready(cam.get_status(), args.raw_policy) validate_module_ready(cam.get_status(), args.raw_policy)
@ -731,11 +744,11 @@ def main():
window_name=window_name, window_name=window_name,
title="ETAPA 1/2 - WHITE / FLAT FIELD", title="ETAPA 1/2 - WHITE / FLAT FIELD",
instruction_lines=[ instruction_lines=[
"Posicione o módulo na altura real de operação.", "Posicione o modulo na altura real de operacao.",
"Aponte para uma superfície branca/cinza fosca, uniforme e sem textura.", "Aponte para uma superficie branca/cinza fosca, uniforme e sem textura.",
"Evite reflexos, sombras laterais e saturação.", "Evite reflexos, sombras laterais e saturacao.",
"A superfície deve preencher todo o campo de visão.", "A superficie deve preencher todo o campo de visao.",
"Pressione ENTER para começar a captura WHITE.", "Pressione ENTER para comecar a captura WHITE.",
], ],
preview_scale=args.preview_scale, preview_scale=args.preview_scale,
allow_skip=False, allow_skip=False,
@ -767,10 +780,10 @@ def main():
window_name=window_name, window_name=window_name,
title="ETAPA 2/2 - DARK / PRETO", title="ETAPA 2/2 - DARK / PRETO",
instruction_lines=[ instruction_lines=[
"Agora faça a captura dark/preto.", "Agora faca a captura dark/preto.",
"Melhor opção: tampe as lentes completamente.", "Melhor opcao: tampe as lentes completamente.",
"Alternativa: use fundo preto fosco preenchendo todo o frame.", "Alternativa: use fundo preto fosco preenchendo todo o frame.",
"Mantenha exposição/ganho iguais aos da etapa anterior, se possível.", "Mantenha exposicao/ganho iguais aos da etapa anterior, se possivel.",
"Pressione ENTER para capturar DARK/PRETO.", "Pressione ENTER para capturar DARK/PRETO.",
], ],
preview_scale=args.preview_scale, preview_scale=args.preview_scale,
@ -801,7 +814,7 @@ def main():
# Processamento robusto. # Processamento robusto.
processing_panel = np.zeros((720, 1280, 3), dtype=np.uint8) processing_panel = np.zeros((720, 1280, 3), dtype=np.uint8)
overlay_hud(processing_panel, [ overlay_hud(processing_panel, [
"Processando calibração flat-field...", "Processando calibracao flat-field...",
"Calculando medianas robustas por canal.", "Calculando medianas robustas por canal.",
"Gerando mapas de ganho e previews.", "Gerando mapas de ganho e previews.",
], x=40, y=80, font_scale=0.8, line_step=34) ], x=40, y=80, font_scale=0.8, line_step=34)

View File

@ -703,7 +703,7 @@ def main():
validate_module_ready(cam.get_status(), args.raw_policy) validate_module_ready(cam.get_status(), args.raw_policy)
while True: while True:
raw_frame, raw_meta, decoded = cam.get_next_decoded(timeout=2.0, update_radiometry=False) raw_frame, raw_meta, decoded = cam.get_next_decoded(timeout=2.0)
if raw_meta is not None and raw_meta.get("frame_id") != last_frame_id: if raw_meta is not None and raw_meta.get("frame_id") != last_frame_id:
last_frame_id = raw_meta.get("frame_id") last_frame_id = raw_meta.get("frame_id")