From 8debec9195fb9722f532d9a254c652a8f7abd1a3 Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Mon, 20 Apr 2026 15:56:31 -0300 Subject: [PATCH] ajustes de hierarquia para arquivos pi --- .gitignore | 7 + Python/raspi/Criar Serviço.txt | 44 + Python/raspi/{ => cam_2}/capture_dataset.py | 463 ++++++---- Python/raspi/cam_2/multispectral_service.py | 332 +++++++ Python/raspi/cam_2/pi/camera_manager.py | 256 ++++++ Python/raspi/cam_2/pi/frame_service.py | 405 +++++++++ Python/raspi/{ => cam_2}/pi/main.py | 0 Python/raspi/{ => cam_2}/pi/protocol.py | 0 .../{ => cam_2}/pi/raw_processor_core.py | 0 .../{ => cam_2}/pi/raw_processor_preview.py | 0 Python/raspi/{ => cam_2}/pi/server.py | 287 +++--- Python/raspi/cam_2/pi/state.py | 374 ++++++++ Python/raspi/{ => cam_2}/pi/stream_sender.py | 65 +- .../raspi/{ => cam_2}/pi/trigger_manager.py | 0 Python/raspi/{ => cam_2}/stream_receiver.py | 105 ++- Python/raspi/cam_3/capture_dataset.py | 824 ++++++++++++++++++ Python/raspi/cam_3/multispectral_service.py | 405 +++++++++ Python/raspi/cam_3/pi/camera_manager.py | 560 ++++++++++++ Python/raspi/cam_3/pi/frame_service.py | 513 +++++++++++ Python/raspi/cam_3/pi/main.py | 5 + Python/raspi/cam_3/pi/protocol.py | 35 + Python/raspi/cam_3/pi/raw_processor_core.py | 123 +++ .../raspi/cam_3/pi/raw_processor_preview.py | 125 +++ Python/raspi/cam_3/pi/server.py | 654 ++++++++++++++ Python/raspi/cam_3/pi/state.py | 621 +++++++++++++ Python/raspi/cam_3/pi/stream_sender.py | 469 ++++++++++ Python/raspi/cam_3/pi/trigger_manager.py | 74 ++ Python/raspi/cam_3/stream_receiver.py | 324 +++++++ Python/raspi/check_saved_files.py | 541 ++++++++++++ Python/raspi/multispectral_service.py | 221 ----- Python/raspi/pi/camera_manager.py | 278 ------ Python/raspi/pi/frame_service.py | 239 ----- Python/raspi/pi/state.py | 155 ---- Python/raspi/test_service.py | 2 +- 34 files changed, 7277 insertions(+), 1229 deletions(-) create mode 100644 Python/raspi/Criar Serviço.txt rename Python/raspi/{ => cam_2}/capture_dataset.py (56%) create mode 100644 Python/raspi/cam_2/multispectral_service.py create mode 100644 Python/raspi/cam_2/pi/camera_manager.py create mode 100644 Python/raspi/cam_2/pi/frame_service.py rename Python/raspi/{ => cam_2}/pi/main.py (100%) rename Python/raspi/{ => cam_2}/pi/protocol.py (100%) rename Python/raspi/{ => cam_2}/pi/raw_processor_core.py (100%) rename Python/raspi/{ => cam_2}/pi/raw_processor_preview.py (100%) rename Python/raspi/{ => cam_2}/pi/server.py (68%) create mode 100644 Python/raspi/cam_2/pi/state.py rename Python/raspi/{ => cam_2}/pi/stream_sender.py (83%) rename Python/raspi/{ => cam_2}/pi/trigger_manager.py (100%) rename Python/raspi/{ => cam_2}/stream_receiver.py (67%) create mode 100644 Python/raspi/cam_3/capture_dataset.py create mode 100644 Python/raspi/cam_3/multispectral_service.py create mode 100644 Python/raspi/cam_3/pi/camera_manager.py create mode 100644 Python/raspi/cam_3/pi/frame_service.py create mode 100644 Python/raspi/cam_3/pi/main.py create mode 100644 Python/raspi/cam_3/pi/protocol.py create mode 100644 Python/raspi/cam_3/pi/raw_processor_core.py create mode 100644 Python/raspi/cam_3/pi/raw_processor_preview.py create mode 100644 Python/raspi/cam_3/pi/server.py create mode 100644 Python/raspi/cam_3/pi/state.py create mode 100644 Python/raspi/cam_3/pi/stream_sender.py create mode 100644 Python/raspi/cam_3/pi/trigger_manager.py create mode 100644 Python/raspi/cam_3/stream_receiver.py create mode 100644 Python/raspi/check_saved_files.py delete mode 100644 Python/raspi/multispectral_service.py delete mode 100644 Python/raspi/pi/camera_manager.py delete mode 100644 Python/raspi/pi/frame_service.py delete mode 100644 Python/raspi/pi/state.py diff --git a/.gitignore b/.gitignore index a2a770b96..96ef48364 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,10 @@ AgroBase/AgroBase/bin/x64/Debug/Python/venv/ !AgroBase/AgroBase/bin/x64/Debug/OperacoesSalvas/ !AgroBase/AgroBase/bin/x64/Debug/Parametros/ +/Python/raspi/cam_2/imx296_pi/dataset/brutas/cana_media/cedo/20260420/20260420_134924_309.json +/Python/raspi/cam_2/imx296_pi/dataset/brutas/cana_media/cedo/20260420/20260420_134924_309.png +/Python/raspi/cam_2/imx296_pi/dataset/brutas/cana_media/cedo/20260420/20260420_134924_309.raw +/Python/raspi/cam_2/imx296_pi/dataset/brutas/cana_media/cedo/20260420/20260420_134947_929.bin +/Python/raspi/cam_2/imx296_pi/dataset/brutas/cana_media/cedo/20260420/20260420_134947_929.json +/Python/raspi/cam_2/imx296_pi/dataset/brutas/cana_media/cedo/20260420/20260420_134947_929.png +/Python/raspi/cam_3/imx296_pi/dataset/brutas/cana_media/cedo/20260420 diff --git a/Python/raspi/Criar Serviço.txt b/Python/raspi/Criar Serviço.txt new file mode 100644 index 000000000..2b2280679 --- /dev/null +++ b/Python/raspi/Criar Serviço.txt @@ -0,0 +1,44 @@ +Criar serviço de auto inicialização no boot: +sudo nano /etc/systemd/system/multispec.service + +Preencher o arquivo com: +[Unit] +Description=Modulo Multiespectral +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=diego +WorkingDirectory=/home/diego/multispec_module +ExecStart=/usr/bin/python3 /home/diego/multispec_module/main.py +Restart=always +RestartSec=3 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target + +Recarregar o systemctl: +sudo systemctl daemon-reload + +Ver status: +sudo systemctl status multispec.service + +Acompanhar logs em tempo real: +journalctl -u multispec.service -f + +Habilitar o serviço no boot: +sudo systemctl enable multispec.service + +Desabilitar o serviço no boot: +sudo systemctl disable multispec.service + +Iniciar o serviço manualmente: +sudo systemctl start multispec.service + +Parar o serviço: +sudo systemctl stop multispec.service + +Reiniciar o serviço: +sudo systemctl restart multispec.service \ No newline at end of file diff --git a/Python/raspi/capture_dataset.py b/Python/raspi/cam_2/capture_dataset.py similarity index 56% rename from Python/raspi/capture_dataset.py rename to Python/raspi/cam_2/capture_dataset.py index 4d96765ad..3acec618b 100644 --- a/Python/raspi/capture_dataset.py +++ b/Python/raspi/cam_2/capture_dataset.py @@ -58,51 +58,57 @@ def save_sample( frame_type: str, preview_bgr: np.ndarray, meta: dict, - raw3: np.ndarray | None = None, + raw_payload: np.ndarray | None = None, packed_raw: np.ndarray | None = None, + packed_raw_by_camera: dict | None = None, ): - """ - Salva conforme o tipo de frame: - - - RGB: - * payload em .raw float32 (3,H,W) - * preview em .png - * metadados em .json - - - RAW_BRUTO: - * payload packed RAW10 em .bin - * preview em .png - * metadados em .json - """ os.makedirs(base_dir, exist_ok=True) name = ts_name() png_path = os.path.join(base_dir, f"{name}.png") json_path = os.path.join(base_dir, f"{name}.json") - if frame_type == "RGB": - if raw3 is None: - raise ValueError("raw3 não pode ser None quando frame_type='RGB'") + if frame_type in ("RGB", "RGBNIR"): + if raw_payload is None: + raise ValueError(f"raw_payload não pode ser None quando frame_type='{frame_type}'") payload_path = os.path.join(base_dir, f"{name}.raw") - raw3.astype(np.float32).tofile(payload_path) + raw_payload.astype(np.float32).tofile(payload_path) - meta["saved_payload_type"] = "raw3" + meta["saved_payload_type"] = frame_type.lower() meta["saved_payload_path"] = os.path.basename(payload_path) meta["saved_payload_dtype"] = "float32" - meta["saved_payload_shape"] = list(raw3.shape) + meta["saved_payload_shape"] = list(raw_payload.shape) elif frame_type == "RAW_BRUTO": - if packed_raw is None: - raise ValueError("packed_raw não pode ser None quando frame_type='RAW_BRUTO'") + if packed_raw_by_camera is not None: + payload_files = {} + payload_shapes = {} + payload_dtypes = {} - payload_path = os.path.join(base_dir, f"{name}.bin") - packed_raw.tofile(payload_path) + for cam_id, arr in packed_raw_by_camera.items(): + path = os.path.join(base_dir, f"{name}_{cam_id}.bin") + arr.tofile(path) + payload_files[cam_id] = os.path.basename(path) + payload_shapes[cam_id] = list(arr.shape) + payload_dtypes[cam_id] = str(arr.dtype) - meta["saved_payload_type"] = "raw10_packed" - meta["saved_payload_path"] = os.path.basename(payload_path) - meta["saved_payload_dtype"] = str(packed_raw.dtype) - meta["saved_payload_shape"] = list(packed_raw.shape) + meta["saved_payload_type"] = "raw10_packed_multi" + meta["saved_payload_paths"] = payload_files + meta["saved_payload_shapes"] = payload_shapes + meta["saved_payload_dtypes"] = payload_dtypes + + else: + if packed_raw is None: + raise ValueError("packed_raw não pode ser None quando frame_type='RAW_BRUTO'") + + payload_path = os.path.join(base_dir, f"{name}.bin") + packed_raw.tofile(payload_path) + + meta["saved_payload_type"] = "raw10_packed" + meta["saved_payload_path"] = os.path.basename(payload_path) + meta["saved_payload_dtype"] = str(packed_raw.dtype) + meta["saved_payload_shape"] = list(packed_raw.shape) else: raise ValueError(f"frame_type não suportado para save: {frame_type}") @@ -112,16 +118,111 @@ def save_sample( with open(json_path, "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) - return payload_path, png_path, json_path + return png_path, json_path +def get_camera_map_from_status(status: dict) -> dict: + result = {} + for cam in status.get("cameras", []): + result[cam.get("id")] = cam + return result + + +def validate_module_ready(status: dict, frame_type: str, raw_policy: str): + if not status.get("ok", True): + raise RuntimeError(f"Status inválido retornado pelo módulo: {status}") + + active_ids = list(status.get("active_camera_ids", [])) + active_count = int(status.get("camera_count_active", 0)) + cam_map = get_camera_map_from_status(status) + + if frame_type == "RGB": + if "cam0" not in active_ids: + raise RuntimeError( + "Modo RGB requer cam0 ativa, mas o módulo não reportou cam0 como ativa." + ) + return + + if frame_type == "RGBNIR": + missing = [cid for cid in ("cam0", "cam1") if cid not in active_ids] + if missing: + raise RuntimeError( + f"Modo RGBNIR requer cam0 e cam1 ativas. Faltando: {missing}. " + f"Ativas atuais: {active_ids}" + ) + return + + if frame_type == "RAW_BRUTO": + if raw_policy == "require_dual": + missing = [cid for cid in ("cam0", "cam1") if cid not in active_ids] + if missing: + raise RuntimeError( + f"RAW_BRUTO com política require_dual exige duas câmeras ativas. " + f"Faltando: {missing}. Ativas atuais: {active_ids}" + ) + else: + if active_count < 1: + raise RuntimeError("RAW_BRUTO requer ao menos uma câmera ativa, mas nenhuma foi detectada.") + return + + raise RuntimeError(f"frame_type desconhecido para validação: {frame_type}") + + +def build_preview_from_raw_cam0( + packed_cam0: np.ndarray, + processor_core: RawProcessorCore, + processor_preview: RawProcessorPreview, + bit_depth: int = 10, +): + packed = packed_cam0 + if packed.ndim == 3 and packed.shape[2] == 1: + packed = packed[:, :, 0] + + raw16 = processor_core.unpack_raw10_packed(packed) + preview_bgr = processor_preview.raw16_to_preview_bgr( + raw16, + bit_depth=bit_depth, + ) + + raw3 = processor_core.build_training_rgb( + raw16, + output_dtype="float32", + bit_depth=bit_depth, + ) + + return preview_bgr, raw3, packed + + +def resolve_effective_capture_mode(frame_type: str, raw_policy: str, requested_mode: str) -> str: + """ + Decide o modo real que será pedido ao módulo, priorizando segurança e economia. + + Regras: + - RGB -> SINGLE + - RGBNIR -> DUAL + - RAW_BRUTO + allow_single -> SINGLE + - RAW_BRUTO + require_dual -> DUAL + """ + if frame_type == "RGB": + return "SINGLE" + + if frame_type == "RGBNIR": + return "DUAL" + + if frame_type == "RAW_BRUTO": + if raw_policy == "require_dual": + return "DUAL" + return "SINGLE" + + return requested_mode + # ========================= # MAIN # ========================= def main(): parser = argparse.ArgumentParser( - description="Captura de dataset RAW3 usando módulo multispectral Pi + StreamReceiver.", + description="Captura de dataset usando módulo multispectral Pi + StreamReceiver.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) @@ -138,18 +239,23 @@ def main(): parser.add_argument("--height", type=int, default=480, help="Altura óptica da câmera.") parser.add_argument("--interval", type=float, default=1.0, help="Intervalo em segundos para auto-save quando ligado.") parser.add_argument("--preview_upscale", type=int, default=2, help="Fator de upscale visual do preview.") - parser.add_argument("--bayer", default="GBRG", choices=["GBRG", "GRBG", "RGGB", "BGGR"], help="Padrão Bayer da câmera.") - parser.add_argument("--codec_family", default="numcodecs", help="Apenas informativo no metadata local.") - parser.add_argument("--codec_name", default="blosc", help="Apenas informativo no metadata local.") - parser.add_argument("--frame_type", default="RAW_BRUTO", choices=["RAW_BRUTO", "RGB"], help="Tipo de payload pedido ao Pi.") - parser.add_argument("--output_dtype", default="uint8", choices=["uint8", "float32"], help="Dtype do payload processado no Pi.") + parser.add_argument("--bayer", default="GBRG", choices=["GBRG", "GRBG", "RGGB", "BGGR"], help="Padrão Bayer das câmeras.") + parser.add_argument("--frame_type", default="RAW_BRUTO", choices=["RAW_BRUTO", "RGB", "RGBNIR"], help="Tipo de payload pedido ao Pi.") + parser.add_argument("--output_dtype", default="float32", choices=["uint8", "uint16", "float32"], help="Dtype do payload processado no Pi.") + parser.add_argument("--capture_mode", default="AUTO", choices=["AUTO", "SINGLE", "DUAL"], help="Modo de captura desejado no módulo.") + parser.add_argument("--raw_policy", default="allow_single", choices=["allow_single", "require_dual"], help="Quando frame_type=RAW_BRUTO, define se o script aceita 1 câmera ou exige 2.") args = parser.parse_args() + effective_capture_mode = resolve_effective_capture_mode( + frame_type=args.frame_type, + raw_policy=args.raw_policy, + requested_mode=args.capture_mode, + ) + raw_w = args.width raw_h = args.height - # dataset//brutas/cana_/// session_dir = os.path.join( args.modelo, args.out_root, @@ -161,16 +267,16 @@ def main(): os.makedirs(session_dir, exist_ok=True) print("============================================") - print("Coleta de dataset RAW3 - Módulo Multiespectral") - print(f"Cana : {args.cana}") - print(f"Horário : {args.horario}") - print(f"Saída : {session_dir}") - print(f"Sensor : {raw_w}x{raw_h} | Bayer={args.bayer}") + print("Coleta de dataset - Módulo Multiespectral") + print(f"Cana : {args.cana}") + print(f"Horário : {args.horario}") + print(f"Saída : {session_dir}") + print(f"Sensor : {raw_w}x{raw_h} | Bayer={args.bayer}") + print(f"FrameType : {args.frame_type}") + print(f"CaptureMode : {args.capture_mode} -> efetivo={effective_capture_mode}") + print(f"RAW policy : {args.raw_policy}") print("============================================") - window_name = "Dataset Capture - RAW3 (C/SPACE=save | A=auto-save | M=preview scale | Q=quit)" - cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) - auto_save = False last_auto_t = 0.0 preview_upscale = args.preview_upscale @@ -190,20 +296,33 @@ def main(): receiver = StreamReceiver(host="0.0.0.0", port=args.stream_port) svc = MultiSpectralService(host=args.pi_host, port=args.server_port, timeout=10) - processor_core = RawProcessorCore( + print(f"[INFO] Verificando conexão com o módulo em {args.pi_host}:{args.server_port}...") + + svc.ensure_alive() + + if not svc.is_alive(): + raise RuntimeError("Módulo não encontrado ou não respondeu ao ping.") + + print("[OK] Módulo conectado e respondendo.") + + window_name = "Dataset Capture (C/SPACE=save | A=auto-save | M=preview scale | Q=quit)" + cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) + + processor_core_cam0 = RawProcessorCore( sensor_width=raw_w, sensor_height=raw_h, bayer_pattern=args.bayer, ) - processor_preview = RawProcessorPreview( + processor_preview_cam0 = RawProcessorPreview( sensor_width=raw_w, sensor_height=raw_h, bayer_pattern=args.bayer, ) last_frame_id = -1 - last_raw3 = None + last_payload_float = None last_packed_raw = None + last_packed_raw_by_camera = None last_preview_bgr = None last_meta_stream = None @@ -226,10 +345,32 @@ def main(): f"fps={mode['fps']}" ) - print("SET RES:", svc.set_resolution(raw_w, raw_h)) + print("SET CAM0 RES:", svc.set_camera_resolution(0, raw_w, raw_h)) + print("SET CAM1 RES:", svc.set_camera_resolution(1, raw_w, raw_h)) + print("SET CAM0 BAYER:", svc.set_camera_bayer(0, args.bayer)) + print("SET CAM1 BAYER:", svc.set_camera_bayer(1, args.bayer)) print("SET FPS:", svc.set_fps(args.fps)) - print("SET BAYER:", svc.set_bayer(args.bayer)) - print("BEGIN:", svc.begin(frame_type=args.frame_type, output_dtype=args.output_dtype)) + print("SET CAPTURE MODE:", svc.set_capture_mode(effective_capture_mode)) + print("SET FRAME TYPE:", svc.set_frame_type(args.frame_type)) + print("SET OUTPUT DTYPE:", svc.set_output_dtype(args.output_dtype)) + + begin_resp = svc.begin( + frame_type=args.frame_type, + output_dtype=args.output_dtype, + capture_mode=effective_capture_mode, + ) + print("BEGIN:", begin_resp) + + status = svc.get_status() + print("STATUS:", json.dumps({ + "status": status.get("status"), + "detected_mode": status.get("detected_mode"), + "camera_count_active": status.get("camera_count_active"), + "active_camera_ids": status.get("active_camera_ids"), + }, ensure_ascii=False)) + + validate_module_ready(status, args.frame_type, args.raw_policy) + print("START STREAM:", svc.start_stream(args.pc_host, args.stream_port, fps=args.fps)) camera_ctrl = svc.get_camera_controls() @@ -240,9 +381,6 @@ def main(): manual_gain = camera_ctrl.get("analogue_gain", None) manual_colour_gains = camera_ctrl.get("colour_gains", None) - expected_packed_w = (args.width * 10) // 8 - expected_packed_h = args.height - while True: t0 = time.time() @@ -251,92 +389,99 @@ def main(): if meta is not None and frame is not None and meta.get("frame_id") != last_frame_id: last_frame_id = meta["frame_id"] - #print(meta) - - if False and meta["packed_width"] != expected_packed_w or meta["packed_height"] != expected_packed_h: - def infer_sensor_size_from_packed(packed_width: int, packed_height: int): - width = int((packed_width * 8) / 10) - height = packed_height - return width, height - actual_packed_w = meta["packed_width"] - actual_packed_h = meta["packed_height"] - - padding = actual_packed_w - expected_packed_w - if actual_packed_h != args.height: - erro = True - elif actual_packed_w < expected_packed_w: - erro = True - elif padding > 64: - # margem conservadora, se quiser - erro = True - else: - erro = False - - if erro: - inferred_w = int((actual_packed_w * 8) / 10) - inferred_h = actual_packed_h - - modes_txt = [] - if modes_resp.get("ok"): - for m in modes_resp["sensor_modes"]: - size = m.get("size") - fmt = m.get("format") - fps = m.get("fps") - modes_txt.append(f"- {size[0]}x{size[1]} | {fmt} | fps={fps}") - modes_str = "\n".join(modes_txt) if modes_txt else "(não disponível)" - - RuntimeError( - f"Modo RAW inesperado.\n" - f"Solicitado: {args.width}x{args.height} (packed útil esperado {expected_packed_w})\n" - f"Recebido: packed {actual_packed_h}x{actual_packed_w}\n" - f"Obs: packed_width pode incluir padding/stride.\n" - f"Se a altura confere e o packed recebido é maior que o esperado, " - f"o modo pode estar correto com alinhamento de memória." - ) try: frame_type = meta.get("frame_type", "RAW_BRUTO") dtype_str = meta.get("dtype") or meta.get("output_dtype", "uint8") if frame_type == "RAW_BRUTO": - packed = frame - if packed.ndim == 3 and packed.shape[2] == 1: - packed = packed[:, :, 0] + if isinstance(frame, dict): + packed_by_camera = frame + cam0 = packed_by_camera.get("cam0") + if cam0 is None: + raise RuntimeError("RAW_BRUTO multi recebido sem cam0 para preview") - raw16 = processor_core.unpack_raw10_packed(packed) - preview_bgr = processor_preview.raw16_to_preview_bgr( - raw16, - bit_depth=meta.get("source_bit_depth", 10), - ) + source_cam0 = None + for src in (meta.get("raw_sources") or []): + if src.get("id") == "cam0": + source_cam0 = src + break - # continua gerando raw3 apenas para visualização/depuração local, se quiser manter - raw3 = processor_core.build_training_rgb( - raw16, - output_dtype="float32", - bit_depth=meta.get("source_bit_depth", 10), - ) + bit_depth_cam0 = 10 if source_cam0 is None else int(source_cam0.get("bit_depth", 10)) - last_packed_raw = packed.copy() + preview_bgr, raw3_preview, packed_cam0 = build_preview_from_raw_cam0( + cam0, + processor_core_cam0, + processor_preview_cam0, + bit_depth=bit_depth_cam0, + ) + + last_packed_raw = None + last_packed_raw_by_camera = {cam_id: arr.copy() for cam_id, arr in packed_by_camera.items()} + last_payload_float = raw3_preview.copy() + + else: + source_camera = meta.get("source_camera") or {} + bit_depth = int(source_camera.get("bit_depth", meta.get("source_bit_depth", 10))) + + preview_bgr, raw3_preview, packed_single = build_preview_from_raw_cam0( + frame, + processor_core_cam0, + processor_preview_cam0, + bit_depth=bit_depth, + ) + + last_packed_raw = packed_single.copy() + last_packed_raw_by_camera = None + last_payload_float = raw3_preview.copy() elif frame_type == "RGB": rgb_chw = frame - if rgb_chw.ndim != 3: - raise RuntimeError(f"Frame RGB inválido: shape={rgb_chw.shape}") + if not isinstance(rgb_chw, np.ndarray) or rgb_chw.ndim != 3: + raise RuntimeError(f"Frame RGB inválido: type={type(rgb_chw)}") if dtype_str == "uint8": - raw3 = rgb_chw.astype(np.float32) / 255.0 + payload_float = rgb_chw.astype(np.float32) / 255.0 elif dtype_str == "float32": - raw3 = rgb_chw.astype(np.float32) + payload_float = rgb_chw.astype(np.float32) + elif dtype_str == "uint16": + payload_float = rgb_chw.astype(np.float32) / 65535.0 else: raise RuntimeError(f"dtype RGB não suportado: {dtype_str}") - preview_rgb = np.transpose(raw3, (1, 2, 0)) + preview_rgb = np.transpose(payload_float, (1, 2, 0)) preview_bgr = cv2.cvtColor( np.clip(preview_rgb * 255.0, 0, 255).astype(np.uint8), cv2.COLOR_RGB2BGR ) + last_payload_float = payload_float.copy() last_packed_raw = None + last_packed_raw_by_camera = None + + elif frame_type == "RGBNIR": + rgbnir_chw = frame + if not isinstance(rgbnir_chw, np.ndarray) or rgbnir_chw.ndim != 3 or rgbnir_chw.shape[0] != 5: + raise RuntimeError(f"Frame RGBNIR inválido: shape={getattr(rgbnir_chw, 'shape', None)}") + + if dtype_str == "uint8": + payload_float = rgbnir_chw.astype(np.float32) / 255.0 + elif dtype_str == "float32": + payload_float = rgbnir_chw.astype(np.float32) + elif dtype_str == "uint16": + payload_float = rgbnir_chw.astype(np.float32) / 65535.0 + else: + raise RuntimeError(f"dtype RGBNIR não suportado: {dtype_str}") + + preview_rgb = np.transpose(payload_float[:3], (1, 2, 0)) + preview_bgr = cv2.cvtColor( + np.clip(preview_rgb * 255.0, 0, 255).astype(np.uint8), + cv2.COLOR_RGB2BGR + ) + + last_payload_float = payload_float.copy() + last_packed_raw = None + last_packed_raw_by_camera = None else: raise RuntimeError(f"frame_type não suportado neste script: {frame_type}") @@ -350,9 +495,6 @@ def main(): else: preview_show = preview_bgr.copy() - # ========================= - # FPS do stream (frames recebidos) - # ========================= curr_frame_id = meta.get("frame_id") if last_stream_frame_id is not None and curr_frame_id is not None: @@ -368,9 +510,6 @@ def main(): stream_frames_accum = 0 t_stream_fps = time.time() - # ========================= - # FPS de visualização/processamento no PC - # ========================= view_frames += 1 dt_view = time.time() - t_view_fps if dt_view >= 1.0: @@ -378,13 +517,13 @@ def main(): view_frames = 0 t_view_fps = time.time() + active_sources = meta.get("payload_sources") lines = [ f"CANA: {args.cana} | HORA: {args.horario} | Pasta: {os.path.basename(session_dir)}", - f"AutoSave: {'ON' if auto_save else 'OFF'} | Intervalo: {args.interval:.1f}s | PreviewScale: {preview_upscale}", - f"frame_id={meta.get('frame_id')} | FPS_STREAM={fps_stream:.1f} | FPS_VIEW={fps_view:.1f}", + 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"packed={meta.get('width')}x{meta.get('height')} | raw3_shape={list(raw3.shape)}", - f"type={meta.get('frame_type')} | layout={meta.get('output_layout')} | dtype={meta.get('dtype') or meta.get('output_dtype')}", f"AE={'ON' if ae_enabled else 'OFF'} | AWB={'ON' if awb_enabled else 'OFF'} | EXP={manual_exposure_us} | GAIN={manual_gain}", "Keys: C/SPACE=save | A=auto-save | E=AE | W=AWB | I/K=exp | O/L=gain | R=reset | M=preview | Q/Esc=quit", ] @@ -396,8 +535,7 @@ def main(): cv2.imshow(window_name, preview_show) - last_raw3 = raw3.copy() if raw3 is not None else None - last_preview_bgr = preview_bgr.copy() if preview_bgr is not None else None + last_preview_bgr = preview_bgr.copy() last_meta_stream = dict(meta) except Exception as e: @@ -409,11 +547,11 @@ def main(): now = time.time() can_save = ( - last_meta_stream is not None - and last_preview_bgr is not None - and ( - (last_meta_stream.get("frame_type") == "RGB" and last_raw3 is not None) or - (last_meta_stream.get("frame_type") == "RAW_BRUTO" and last_packed_raw is not None) + last_meta_stream is not None and + last_preview_bgr is not None and + ( + (last_meta_stream.get("frame_type") in ("RGB", "RGBNIR") and last_payload_float is not None) or + (last_meta_stream.get("frame_type") == "RAW_BRUTO" and (last_packed_raw is not None or last_packed_raw_by_camera is not None)) ) ) @@ -426,35 +564,27 @@ def main(): "horario": args.horario, "sensor_width": raw_w, "sensor_height": raw_h, - "bayer_pattern": last_meta_stream.get("source_bayer_pattern"), + "bayer_pattern": args.bayer, "fps_target": args.fps, "frame_type": frame_type_save, - "codec_family": last_meta_stream.get("codec_family"), - "codec_name": last_meta_stream.get("codec_name"), - "codec_params": last_meta_stream.get("codec_params"), + "capture_mode_requested": args.capture_mode, + "capture_mode_effective": effective_capture_mode, + "raw_policy": args.raw_policy, "stream_meta": last_meta_stream, "note": "autosave", } - if frame_type_save == "RGB": - meta_save["raw3_shape"] = list(last_raw3.shape) - meta_save["raw3_dtype"] = str(last_raw3.dtype) - elif frame_type_save == "RAW_BRUTO": - meta_save["packed_shape"] = list(last_packed_raw.shape) - meta_save["packed_dtype"] = str(last_packed_raw.dtype) - meta_save["packed_height"] = int(last_packed_raw.shape[0]) - meta_save["packed_width"] = int(last_packed_raw.shape[1]) - - payload_path, _, _ = save_sample( + save_sample( session_dir, frame_type=frame_type_save, preview_bgr=last_preview_bgr, meta=meta_save, - raw3=last_raw3, + raw_payload=last_payload_float, packed_raw=last_packed_raw, + packed_raw_by_camera=last_packed_raw_by_camera, ) - last_msg = f"SALVO (auto): {os.path.basename(payload_path)}" + last_msg = "SALVO (auto)" last_msg_t = now last_auto_t = now @@ -487,7 +617,6 @@ def main(): last_msg_t = time.time() elif k in (ord("i"), ord("I")): - # aumenta exposição manual if manual_exposure_us is None: manual_exposure_us = 15000 else: @@ -503,7 +632,6 @@ def main(): last_msg_t = time.time() elif k in (ord("k"), ord("K")): - # diminui exposição manual if manual_exposure_us is None: manual_exposure_us = 15000 else: @@ -519,7 +647,6 @@ def main(): last_msg_t = time.time() elif k in (ord("o"), ord("O")): - # aumenta ganho manual if manual_gain is None: manual_gain = 1.0 else: @@ -535,7 +662,6 @@ def main(): last_msg_t = time.time() elif k in (ord("l"), ord("L")): - # diminui ganho manual if manual_gain is None: manual_gain = 1.0 else: @@ -551,7 +677,6 @@ def main(): last_msg_t = time.time() elif k in (ord("r"), ord("R")): - # reset manuais svc.clear_exposure_time() svc.clear_analogue_gain() svc.clear_colour_gains() @@ -564,15 +689,6 @@ def main(): last_msg_t = time.time() elif k in (ord("c"), ord("C"), 32): - can_save = ( - last_meta_stream is not None - and last_preview_bgr is not None - and ( - (last_meta_stream.get("frame_type") == "RGB" and last_raw3 is not None) or - (last_meta_stream.get("frame_type") == "RAW_BRUTO" and last_packed_raw is not None) - ) - ) - if can_save: frame_type_save = last_meta_stream.get("frame_type") @@ -582,12 +698,11 @@ def main(): "horario": args.horario, "sensor_width": raw_w, "sensor_height": raw_h, - "bayer_pattern": last_meta_stream.get("source_bayer_pattern"), + "bayer_pattern": args.bayer, "fps_target": args.fps, "frame_type": frame_type_save, - "codec_family": last_meta_stream.get("codec_family"), - "codec_name": last_meta_stream.get("codec_name"), - "codec_params": last_meta_stream.get("codec_params"), + "capture_mode": args.capture_mode, + "raw_policy": args.raw_policy, "stream_meta": last_meta_stream, "camera_controls": { "ae_enable": ae_enabled, @@ -599,25 +714,17 @@ def main(): "note": "manual", } - if frame_type_save == "RGB": - meta_save["raw3_shape"] = list(last_raw3.shape) - meta_save["raw3_dtype"] = str(last_raw3.dtype) - elif frame_type_save == "RAW_BRUTO": - meta_save["packed_shape"] = list(last_packed_raw.shape) - meta_save["packed_dtype"] = str(last_packed_raw.dtype) - meta_save["packed_height"] = int(last_packed_raw.shape[0]) - meta_save["packed_width"] = int(last_packed_raw.shape[1]) - - payload_path, _, _ = save_sample( + save_sample( session_dir, frame_type=frame_type_save, preview_bgr=last_preview_bgr, meta=meta_save, - raw3=last_raw3, + raw_payload=last_payload_float, packed_raw=last_packed_raw, + packed_raw_by_camera=last_packed_raw_by_camera, ) - last_msg = f"SALVO (manual): {os.path.basename(payload_path)}" + last_msg = "SALVO (manual)" last_msg_t = time.time() dt_loop = time.time() - t0 diff --git a/Python/raspi/cam_2/multispectral_service.py b/Python/raspi/cam_2/multispectral_service.py new file mode 100644 index 000000000..807915823 --- /dev/null +++ b/Python/raspi/cam_2/multispectral_service.py @@ -0,0 +1,332 @@ +import socket +import json +import numpy as np +import base64 +import time +from typing import Optional + + +class MultiSpectralService: + def __init__(self, host="192.168.105.6", port=5000, timeout=5): + self.host = host + self.port = port + self.timeout = timeout + self.sock = None + self.file = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc, tb): + self.disconnect() + + def connect(self): + if self.sock is not None: + return + + self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + self.sock.settimeout(self.timeout) + self.file = self.sock.makefile("r", encoding="utf-8") + + def disconnect(self): + try: + if self.file: + self.file.close() + except Exception: + pass + + try: + if self.sock: + self.sock.close() + except Exception: + pass + + self.file = None + self.sock = None + + def check_connection(self, timeout: float = None) -> bool: + try: + self.connect() + + resp = self._send_command({"cmd": "ping"}) + return resp.get("ok") and resp.get("reply") == "pong" + + except Exception: + return False + + def is_alive(self) -> bool: + try: + self.connect() + resp = self._send_command({"cmd": "ping"}) + return resp.get("ok") and resp.get("reply") == "pong" + except Exception: + return False + + def ensure_alive(self): + try: + self.connect() + except Exception as e: + raise RuntimeError( + f"Não foi possível conectar ao módulo em {self.host}:{self.port}. " + f"Verifique rede, IP e se o Pi está ligado. Erro: {e}" + ) from e + + try: + resp = self._send_command({"cmd": "ping"}) + except Exception as e: + raise RuntimeError( + f"Conectou ao endereço {self.host}:{self.port}, mas o módulo não respondeu ao ping. " + f"Verifique se o serviço está rodando no Pi. Erro: {e}" + ) from e + + if not resp.get("ok") or resp.get("reply") != "pong": + raise RuntimeError( + f"Resposta inválida do módulo ao ping: {resp}" + ) + + def _send_command(self, payload: dict) -> dict: + if self.sock is None: + self.connect() + + data = (json.dumps(payload) + "\n").encode("utf-8") + self.sock.sendall(data) + + line = self.file.readline() + if not line: + self.disconnect() + raise RuntimeError("Conexão encerrada pelo servidor") + + return json.loads(line.strip()) + + def _numpy_dtype_from_string(self, dtype_str: str): + mapping = { + "uint8": np.uint8, + "float32": np.float32, + "uint16": np.uint16, + } + if dtype_str not in mapping: + raise RuntimeError(f"dtype não suportado recebido do Pi: {dtype_str}") + return mapping[dtype_str] + + def _reshape_array(self, raw_bytes: bytes, dtype_str: str, layout: str, width: int, height: int, channels: int): + np_dtype = self._numpy_dtype_from_string(dtype_str) + arr = np.frombuffer(raw_bytes, dtype=np_dtype) + + if layout == "HW": + return arr.reshape(height, width) + + if layout == "CHW": + return arr.reshape(channels, height, width) + + if layout == "HWC": + return arr.reshape(height, width, channels) + + raise RuntimeError(f"Layout não suportado recebido do Pi: {layout}") + + def ping(self): + return self._send_command({"cmd": "ping"}) + + def get_status(self): + return self._send_command({"cmd": "get_status"}) + + def get_config(self): + return self._send_command({"cmd": "get_config"}) + + def begin(self, frame_type: str = "RAW_BRUTO", output_dtype: str = "uint8", capture_mode: str = "AUTO"): + return self._send_command({ + "cmd": "begin", + "frame_type": frame_type, + "output_dtype": output_dtype, + "capture_mode": capture_mode, + }) + + def stop(self): + return self._send_command({"cmd": "stop"}) + + def set_fps(self, fps: int): + return self._send_command({"cmd": "set_fps", "value": fps}) + + def set_jpeg_quality(self, quality: int): + return self._send_command({"cmd": "set_jpeg_quality", "value": quality}) + + def set_frame_type(self, frame_type: str): + return self._send_command({"cmd": "set_frame_type", "value": frame_type}) + + def set_output_dtype(self, output_dtype: str): + return self._send_command({"cmd": "set_output_dtype", "value": output_dtype}) + + def set_capture_mode(self, capture_mode: str): + return self._send_command({"cmd": "set_capture_mode", "value": capture_mode}) + + def set_camera_enabled(self, index: int, enabled: bool): + return self._send_command({ + "cmd": "set_camera_enabled", + "index": index, + "enabled": bool(enabled) + }) + + def set_camera_bayer(self, index: int, bayer_pattern: str): + return self._send_command({ + "cmd": "set_camera_bayer", + "index": index, + "pattern": bayer_pattern + }) + + def set_camera_resolution(self, index: int, width: int, height: int): + return self._send_command({ + "cmd": "set_camera_resolution", + "index": index, + "width": width, + "height": height + }) + + def capture_frame(self): + t0 = time.perf_counter() + + resp = self._send_command({"cmd": "capture_frame"}) + if not resp.get("ok"): + raise RuntimeError(resp.get("error", "Falha ao capturar frame")) + + encoding = resp.get("encoding", "base64") + output_layout = resp.get("output_layout", "HW") + dtype_str = resp.get("dtype") or resp.get("output_dtype") or "uint8" + + meta = { + "frame_type": resp.get("frame_type"), + "payload_format_version": resp.get("payload_format_version"), + "output_dtype": resp.get("output_dtype"), + "dtype": dtype_str, + "output_layout": output_layout, + "output_channel_names": resp.get("output_channel_names"), + "payload_sources": resp.get("payload_sources"), + "source_camera": resp.get("source_camera"), + "source_cameras": resp.get("source_cameras"), + "camera_frames": resp.get("camera_frames"), + "multi_payload": resp.get("multi_payload", False), + "payload_parts": resp.get("payload_parts"), + "packed_width": resp.get("packed_width"), + "packed_height": resp.get("packed_height"), + "source_width": resp.get("source_width"), + "source_height": resp.get("source_height"), + "source_bayer_pattern": resp.get("source_bayer_pattern"), + "source_bit_depth": resp.get("source_bit_depth"), + "size": resp.get("size"), + "ts_pi": resp.get("ts_pi"), + "ts_pi_monotonic": resp.get("ts_pi_monotonic"), + "dt_trigger": resp.get("dt_trigger"), + "dt_settle": resp.get("dt_settle"), + "dt_capture": resp.get("dt_capture"), + "dt_process": resp.get("dt_process"), + "dt_total_pi": resp.get("dt_total_pi"), + } + + if encoding == "base64": + raw = base64.b64decode(resp["data"]) + + width = int(resp.get("output_width", resp.get("width"))) + height = int(resp.get("output_height", resp.get("height"))) + channels = int(resp.get("output_channels", resp.get("channels", 1))) + + arr = self._reshape_array( + raw_bytes=raw, + dtype_str=dtype_str, + layout=output_layout, + width=width, + height=height, + channels=channels, + ) + + meta.update({ + "width": width, + "height": height, + "channels": channels, + "dt_total_pc": time.perf_counter() - t0, + }) + return arr, meta + + if encoding == "base64-multi": + frames_resp = resp.get("frames", {}) + frames = {} + + payload_parts = resp.get("payload_parts", []) + parts_by_cam = {p.get("camera_id"): p for p in payload_parts if p.get("camera_id")} + + camera_frames = resp.get("camera_frames", {}) + + for cam_id, item in frames_resp.items(): + raw = base64.b64decode(item["data"]) + cam_meta = camera_frames.get(cam_id, {}) + part_meta = parts_by_cam.get(cam_id, {}) + + width = int(cam_meta.get("width")) + height = int(cam_meta.get("height")) + channels = int(cam_meta.get("channels", 1)) + + # Para RAW bruto multi, cada parte tende a ser HW + arr = self._reshape_array( + raw_bytes=raw, + dtype_str=dtype_str if dtype_str != "multi" else "uint16", + layout="HW", + width=width, + height=height, + channels=channels, + ) + + frames[cam_id] = arr + + meta.update({ + "frames_meta": camera_frames, + "dt_total_pc": time.perf_counter() - t0, + }) + return frames, meta + + raise RuntimeError(f"encoding não suportado recebido do Pi: {encoding}") + + def capture_frame_array(self): + return self.capture_frame() + + def start_stream(self, host: str, port: int, fps: float): + return self._send_command({ + "cmd": "start_stream", + "host": host, + "port": port, + "fps": fps + }) + + def stop_stream(self): + return self._send_command({"cmd": "stop_stream"}) + + def get_camera_controls(self): + return self._send_command({"cmd": "get_camera_controls"}) + + def set_ae_enable(self, value: bool): + return self._send_command({"cmd": "set_ae_enable", "value": bool(value)}) + + def set_awb_enable(self, value: bool): + return self._send_command({"cmd": "set_awb_enable", "value": bool(value)}) + + def set_exposure_time(self, exposure_time_us: Optional[int] = None): + return self._send_command({"cmd": "set_exposure_time", "value": exposure_time_us}) + + def clear_exposure_time(self): + return self._send_command({"cmd": "clear_exposure_time"}) + + def set_analogue_gain(self, gain: float | None): + return self._send_command({"cmd": "set_analogue_gain", "value": gain}) + + def clear_analogue_gain(self): + return self._send_command({"cmd": "clear_analogue_gain"}) + + def set_colour_gains(self, r_gain: float, b_gain: float): + return self._send_command({ + "cmd": "set_colour_gains", + "r_gain": r_gain, + "b_gain": b_gain + }) + + def clear_colour_gains(self): + return self._send_command({"cmd": "clear_colour_gains"}) + + def get_sensor_modes(self): + return self._send_command({"cmd": "get_sensor_modes"}) \ No newline at end of file diff --git a/Python/raspi/cam_2/pi/camera_manager.py b/Python/raspi/cam_2/pi/camera_manager.py new file mode 100644 index 000000000..64c0eaba7 --- /dev/null +++ b/Python/raspi/cam_2/pi/camera_manager.py @@ -0,0 +1,256 @@ +from picamera2 import Picamera2 +from threading import Lock, RLock +import threading +import time +import numpy as np + + +class CameraManager: + def __init__(self, state): + self.state = state + self.initialized = False + + self.camera_lock = RLock() + self.frame_lock = Lock() + + self.cameras_runtime = {} + self._reconfigure_needed = False + self._sensor_modes_cache = None + + def mark_reconfigure_needed(self): + with self.camera_lock: + self._reconfigure_needed = True + + def _init_camera_runtime(self, cam_spec): + return { + "picam2": None, + "last_frame": None, + "frame_id": 0, + "frame_ts": None, + "buffer": None, + "stop_event": threading.Event(), + "thread": None + } + + def _get_required_camera_ids(self): + frame_type = self.state.frame_type + resolved_mode = self.state.resolve_capture_mode() + + if frame_type == "RGB": + return ["cam0"] + + if frame_type == "RGBNIR": + return ["cam0", "cam1"] + + if frame_type == "RAW_BRUTO": + if resolved_mode == "DUAL": + return ["cam0", "cam1"] + return ["cam0"] + + return ["cam0"] + + def begin(self): + with self.camera_lock: + self.stop() + + required_ids = set(self._get_required_camera_ids()) + + for cam in self.state.cameras: + if cam.id not in required_ids: + self.state.set_camera_connected(cam.index, False) + continue + + try: + picam2 = Picamera2(camera_num=cam.index) + + config = picam2.create_video_configuration( + main={"size": (640, 480), "format": "RGB888"}, + raw={"size": (cam.width, cam.height)}, + buffer_count=6 + ) + + picam2.configure(config) + picam2.start() + + runtime = self._init_camera_runtime(cam) + runtime["picam2"] = picam2 + self.cameras_runtime[cam.id] = runtime + + self.state.set_camera_connected( + cam.index, + True, + width=cam.width, + height=cam.height, + bayer_pattern=cam.bayer_pattern, + bit_depth=cam.bit_depth + ) + + except Exception as e: + print(f"[WARN] Falha ao abrir {cam.id} (index={cam.index}): {e}") + self.state.set_camera_connected(cam.index, False) + + for cam_id in self.state.active_camera_ids: + self._start_thread(cam_id) + + self.initialized = len(self.cameras_runtime) > 0 + self._reconfigure_needed = False + + return self.initialized + + def _start_thread(self, cam_id): + runtime = self.cameras_runtime[cam_id] + runtime["stop_event"].clear() + + t = threading.Thread( + target=self._update_loop, + args=(cam_id,), + daemon=True + ) + runtime["thread"] = t + t.start() + + def _update_loop(self, cam_id): + runtime = self.cameras_runtime[cam_id] + + while not runtime["stop_event"].is_set(): + request = None + try: + picam2 = runtime["picam2"] + request = picam2.capture_request() + + raw = request.make_array("raw") + + with self.frame_lock: + if ( + runtime["buffer"] is None or + runtime["buffer"].shape != raw.shape or + runtime["buffer"].dtype != raw.dtype + ): + runtime["buffer"] = raw.copy() + else: + np.copyto(runtime["buffer"], raw) + + runtime["last_frame"] = runtime["buffer"] + runtime["frame_id"] += 1 + runtime["frame_ts"] = time.perf_counter() + + except Exception as e: + print(f"[ERRO LOOP {cam_id}] {e}") + time.sleep(0.05) + + finally: + if request is not None: + try: + request.release() + except Exception: + pass + + def capture_raw_frames(self): + result = {} + + with self.frame_lock: + for cam_id, runtime in self.cameras_runtime.items(): + if runtime["last_frame"] is None: + continue + + frame = runtime["last_frame"] + h, w = frame.shape[:2] + + result[cam_id] = ( + frame, + w, + h, + 1, + runtime["frame_id"], + runtime["frame_ts"] + ) + + return result + + def apply_controls(self): + with self.camera_lock: + for runtime in self.cameras_runtime.values(): + picam2 = runtime.get("picam2") + if picam2 is None: + continue + + controls = {} + frame_us = int(1_000_000 / max(1, self.state.fps or 10)) + controls["FrameDurationLimits"] = (frame_us, frame_us) + controls["AeEnable"] = bool(self.state.ae_enable) + controls["AwbEnable"] = bool(self.state.awb_enable) + + if not self.state.ae_enable: + if self.state.exposure_time_us is not None: + controls["ExposureTime"] = int(self.state.exposure_time_us) + if self.state.analogue_gain is not None: + controls["AnalogueGain"] = float(self.state.analogue_gain) + + if not self.state.awb_enable and self.state.colour_gains is not None: + r_gain, b_gain = self.state.colour_gains + controls["ColourGains"] = (float(r_gain), float(b_gain)) + + try: + picam2.set_controls(controls) + except Exception as e: + print(f"[ERRO CONTROLS] {e} | controls={controls}") + + return True + + def get_sensor_modes(self): + if self._sensor_modes_cache is not None: + return self._sensor_modes_cache + + try: + temp = Picamera2(camera_num=0) + modes = temp.sensor_modes + result = [] + + for i, m in enumerate(modes): + result.append({ + "index": i, + "format": str(m.get("format")) if m.get("format") is not None else None, + "size": list(m.get("size")) if m.get("size") is not None else None, + "bit_depth": m.get("bit_depth"), + "fps": m.get("fps"), + "crop_limits": list(m.get("crop_limits")) if m.get("crop_limits") is not None else None, + "exposure_limits": list(m.get("exposure_limits")) if m.get("exposure_limits") is not None else None, + }) + + self._sensor_modes_cache = result + return result + + finally: + try: + temp.close() + except Exception: + pass + + def stop(self): + with self.camera_lock: + for runtime in self.cameras_runtime.values(): + runtime["stop_event"].set() + + for runtime in self.cameras_runtime.values(): + t = runtime.get("thread") + if t: + t.join(timeout=1) + + for runtime in self.cameras_runtime.values(): + cam = runtime.get("picam2") + if cam: + try: + cam.stop() + except Exception: + pass + try: + cam.close() + except Exception: + pass + + self.cameras_runtime.clear() + self.initialized = False + self._reconfigure_needed = False + + for cam in self.state.cameras: + self.state.set_camera_connected(cam.index, False) \ No newline at end of file diff --git a/Python/raspi/cam_2/pi/frame_service.py b/Python/raspi/cam_2/pi/frame_service.py new file mode 100644 index 000000000..e1be73642 --- /dev/null +++ b/Python/raspi/cam_2/pi/frame_service.py @@ -0,0 +1,405 @@ +import time +import threading +import base64 +import numpy as np + + +class FrameService: + def __init__(self, state, trigger_manager, camera_manager): + self.state = state + self.trigger = trigger_manager + self.camera = camera_manager + self._capture_lock = threading.RLock() + self.raw_processors = {} + + def _get_camera_spec(self, cam_id): + cam = self.state.get_camera(cam_id) + if cam is None: + raise RuntimeError(f"Câmera '{cam_id}' não encontrada no state") + return cam + + def _ensure_raw_processor_for_camera(self, cam_id): + cam = self._get_camera_spec(cam_id) + rp = self.raw_processors.get(cam_id) + + if ( + rp is None or + rp.sensor_width != cam.width or + rp.sensor_height != cam.height or + rp.bayer_pattern.upper() != cam.bayer_pattern.upper() + ): + from raw_processor_core import RawProcessorCore + rp = RawProcessorCore( + sensor_width=cam.width, + sensor_height=cam.height, + bayer_pattern=cam.bayer_pattern, + ) + self.raw_processors[cam_id] = rp + + return rp + + def _capture_with_retry(self, required_sources, max_attempts=10, retry_delay_s=0.02): + last_frames = None + + for _ in range(max_attempts): + frames = self.camera.capture_raw_frames() + + ok = True + for cam_id in required_sources: + info = frames.get(cam_id) + if info is None: + ok = False + break + + frame, width, height, channels, frame_id, frame_ts = info + if frame is None or width <= 0 or height <= 0 or channels <= 0: + ok = False + break + + if ok: + return frames + + last_frames = frames + time.sleep(retry_delay_s) + + raise RuntimeError( + f"Capture retornou frames insuficientes após {max_attempts} tentativas. " + f"required_sources={required_sources}, received_sources={list((last_frames or {}).keys())}" + ) + + def capture_frame_raw(self): + with self._capture_lock: + if not self.state.initialized: + raise RuntimeError("Módulo não inicializado") + + t0_perf = time.perf_counter() + t0_unix = time.time() + + dt_trigger = 0.0 + dt_settle = 0.0 + dt_capture = 0.0 + dt_process = 0.0 + + required_sources = list(self.state.payload.sources) + + try: + if self.state.trigger_enabled: + trig_start = time.perf_counter() + self.trigger.pulse() + trig_end = time.perf_counter() + dt_trigger = trig_end - trig_start + + settle_ms = float(getattr(self.state, "trigger_settle_delay_ms", 0.0) or 0.0) + if settle_ms > 0: + settle_start = time.perf_counter() + time.sleep(settle_ms / 1000.0) + settle_end = time.perf_counter() + dt_settle = settle_end - settle_start + + cap_start = time.perf_counter() + raw_frames = self._capture_with_retry(required_sources) + cap_end = time.perf_counter() + dt_capture = cap_end - cap_start + + proc_start = time.perf_counter() + frame_out, meta_extra = self._build_output_frame(raw_frames) + proc_end = time.perf_counter() + dt_process = proc_end - proc_start + + except Exception as e: + raise RuntimeError(f"Falha durante captura/processamento de frame: {e}") from e + + if frame_out is None: + raise RuntimeError("Frame processado retornou nulo") + + total_end = time.perf_counter() + + self.state.stream_frame_id += 1 + frame_id = self.state.stream_frame_id + + camera_frames_meta = {} + for cam_id, info in raw_frames.items(): + frame, width, height, channels, cam_frame_id, cam_frame_ts = info + cam = self._get_camera_spec(cam_id) + + camera_frames_meta[cam_id] = { + "camera_frame_id": int(cam_frame_id), + "camera_frame_ts": cam_frame_ts, + "width": int(width), + "height": int(height), + "channels": int(channels), + "bayer_pattern": cam.bayer_pattern, + "bit_depth": int(cam.bit_depth), + } + + meta = { + "frame_id": frame_id, + "ts_pi": t0_unix, + "ts_pi_monotonic": t0_perf, + + "dt_trigger": dt_trigger, + "dt_settle": dt_settle, + "dt_capture": dt_capture, + "dt_process": dt_process, + "dt_total_pi": total_end - t0_perf, + + "camera_frames": camera_frames_meta, + "capture_mode_resolved": self.state.resolve_capture_mode(), + "payload_format_version": self.state.payload_format_version, + } + + meta.update(meta_extra) + return frame_out, meta + + def capture_frame_base64(self): + frame, meta = self.capture_frame_raw() + + if isinstance(frame, dict): + encoded = {} + total_size = 0 + + for cam_id, arr in frame.items(): + frame_bytes = arr.tobytes() + total_size += len(frame_bytes) + encoded[cam_id] = { + "encoding": "base64", + "size": len(frame_bytes), + "data": base64.b64encode(frame_bytes).decode("ascii"), + } + + meta["encoding"] = "base64-multi" + meta["size"] = total_size + meta["frames"] = encoded + return meta + + frame_bytes = frame.tobytes() + meta["encoding"] = "base64" + meta["size"] = len(frame_bytes) + meta["data"] = base64.b64encode(frame_bytes).decode("ascii") + return meta + + def _build_output_frame(self, raw_frames): + if self.state.frame_type == "RAW_BRUTO": + return self._process_raw_bruto(raw_frames) + + if self.state.frame_type == "RGB": + return self._process_rgb(raw_frames) + + if self.state.frame_type == "RGBNIR": + return self._process_rgbnir(raw_frames) + + raise RuntimeError(f"frame_type inválido: {self.state.frame_type}") + + def _convert_output_dtype(self, arr, bit_depth): + max_sensor_value = (1 << int(bit_depth)) - 1 + + if self.state.output_dtype == "uint8": + if arr.dtype == "uint8": + return arr + if arr.dtype == "float32": + return (arr * 255.0).clip(0, 255).astype("uint8") + if arr.dtype.kind in ("u", "i"): + max_val = arr.max() if arr.size > 0 else 0 + if max_val <= 255: + return arr.astype("uint8") + return ((arr.astype("float32") / max_sensor_value) * 255.0).clip(0, 255).astype("uint8") + raise RuntimeError(f"dtype não suportado para uint8: {arr.dtype}") + + if self.state.output_dtype == "uint16": + if arr.dtype == "uint16": + return arr + if arr.dtype == "uint8": + return (arr.astype("uint16") << 8) + if arr.dtype == "float32": + return (arr * 65535.0).clip(0, 65535).astype("uint16") + if arr.dtype.kind in ("u", "i"): + return arr.astype("uint16") + raise RuntimeError(f"dtype não suportado para uint16: {arr.dtype}") + + if self.state.output_dtype == "float32": + if arr.dtype == "float32": + return arr + if arr.dtype == "uint8": + return arr.astype("float32") / 255.0 + if arr.dtype.kind in ("u", "i"): + max_val = arr.max() if arr.size > 0 else 0 + if max_val <= 255: + return arr.astype("float32") / 255.0 + return arr.astype("float32") / max_sensor_value + raise RuntimeError(f"dtype não suportado para float32: {arr.dtype}") + + raise RuntimeError(f"output_dtype inválido: {self.state.output_dtype}") + + def _process_raw_bruto(self, raw_frames): + sources = list(self.state.payload.sources) + + if len(sources) == 1: + cam_id = sources[0] + frame, width, height, channels, cam_frame_id, cam_frame_ts = raw_frames[cam_id] + cam = self._get_camera_spec(cam_id) + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": str(frame.dtype), + "dtype": str(frame.dtype), + "output_layout": "HW", + "output_channels": 1, + "output_channel_names": ["RAW10_PACKED"], + "output_width": int(width), + "output_height": int(height), + "width": int(width), + "height": int(height), + "channels": 1, + "payload_sources": [cam_id], + "source_camera": { + "id": cam_id, + "bayer_pattern": cam.bayer_pattern, + "bit_depth": int(cam.bit_depth), + }, + } + return frame, meta_extra + + frames_out = {} + sources_meta = [] + + for cam_id in sources: + frame, width, height, channels, cam_frame_id, cam_frame_ts = raw_frames[cam_id] + cam = self._get_camera_spec(cam_id) + frames_out[cam_id] = frame + sources_meta.append({ + "id": cam_id, + "width": int(width), + "height": int(height), + "bayer_pattern": cam.bayer_pattern, + "bit_depth": int(cam.bit_depth), + }) + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": "multi", + "dtype": "multi", + "output_layout": "MULTI_HW", + "output_channels": len(frames_out), + "output_channel_names": [f"RAW_{cam_id.upper()}" for cam_id in sources], + "output_width": None, + "output_height": None, + "width": None, + "height": None, + "channels": len(frames_out), + "payload_sources": sources, + "raw_sources": sources_meta, + } + + return frames_out, meta_extra + + def _process_rgb(self, raw_frames): + cam_id = self.state.payload.sources[0] + packed, packed_width, packed_height, _, _, _ = raw_frames[cam_id] + cam = self._get_camera_spec(cam_id) + rp = self._ensure_raw_processor_for_camera(cam_id) + + if packed.ndim == 3 and packed.shape[2] == 1: + packed = packed[:, :, 0] + + raw16 = rp.unpack_raw10_packed(packed) + rgb_chw = rp.build_training_rgb(raw16, bit_depth=cam.bit_depth) + rgb_chw = self._convert_output_dtype(rgb_chw, cam.bit_depth) + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": self.state.output_dtype, + "dtype": str(rgb_chw.dtype), + "output_layout": "CHW", + "output_channels": 3, + "output_channel_names": ["R", "G", "B"], + "output_width": int(rgb_chw.shape[2]), + "output_height": int(rgb_chw.shape[1]), + "width": int(rgb_chw.shape[2]), + "height": int(rgb_chw.shape[1]), + "channels": 3, + "payload_sources": [cam_id], + "packed_width": int(packed_width), + "packed_height": int(packed_height), + "source_camera": { + "id": cam_id, + "bayer_pattern": cam.bayer_pattern, + "bit_depth": int(cam.bit_depth), + "source_width": int(cam.width), + "source_height": int(cam.height), + }, + } + + return rgb_chw, meta_extra + + def _process_rgbnir(self, raw_frames): + sources = list(self.state.payload.sources) + if len(sources) < 2: + raise RuntimeError("RGBNIR requer duas câmeras ativas") + + cam_rgb_id = sources[0] + cam_nir_id = sources[1] + + packed_rgb, _, _, _, _, _ = raw_frames[cam_rgb_id] + packed_nir, _, _, _, _, _ = raw_frames[cam_nir_id] + + cam_rgb = self._get_camera_spec(cam_rgb_id) + cam_nir = self._get_camera_spec(cam_nir_id) + + rp_rgb = self._ensure_raw_processor_for_camera(cam_rgb_id) + rp_nir = self._ensure_raw_processor_for_camera(cam_nir_id) + + if packed_rgb.ndim == 3 and packed_rgb.shape[2] == 1: + packed_rgb = packed_rgb[:, :, 0] + if packed_nir.ndim == 3 and packed_nir.shape[2] == 1: + packed_nir = packed_nir[:, :, 0] + + raw16_rgb = rp_rgb.unpack_raw10_packed(packed_rgb) + raw16_nir = rp_nir.unpack_raw10_packed(packed_nir) + + rgb_chw = rp_rgb.build_training_rgb(raw16_rgb, bit_depth=cam_rgb.bit_depth).astype("float32") + cam2_rgb = rp_nir.build_training_rgb(raw16_nir, bit_depth=cam_nir.bit_depth).astype("float32") + + min_h = min(rgb_chw.shape[1], cam2_rgb.shape[1]) + min_w = min(rgb_chw.shape[2], cam2_rgb.shape[2]) + + rgb_chw = rgb_chw[:, :min_h, :min_w] + cam2_rgb = cam2_rgb[:, :min_h, :min_w] + + # assumindo ordem [R, G, B] + re_single = cam2_rgb[0:1, :, :] + nir_single = cam2_rgb[2:3, :, :] + + rgbnir = np.concatenate([rgb_chw, nir_single, re_single], axis=0) + rgbnir = self._convert_output_dtype(rgbnir, max(cam_rgb.bit_depth, cam_nir.bit_depth)) + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": self.state.output_dtype, + "dtype": str(rgbnir.dtype), + "output_layout": "CHW", + "output_channels": 5, + "output_channel_names": ["R", "G", "B", "NIR", "RE"], + "output_width": int(rgbnir.shape[2]), + "output_height": int(rgbnir.shape[1]), + "width": int(rgbnir.shape[2]), + "height": int(rgbnir.shape[1]), + "channels": 5, + "payload_sources": [cam_rgb_id, cam_nir_id], + "source_cameras": [ + { + "id": cam_rgb_id, + "role": cam_rgb.role, + "bayer_pattern": cam_rgb.bayer_pattern, + "bit_depth": int(cam_rgb.bit_depth), + }, + { + "id": cam_nir_id, + "role": cam_nir.role, + "bayer_pattern": cam_nir.bayer_pattern, + "bit_depth": int(cam_nir.bit_depth), + }, + ], + "rgbnir_note": "RGB da cam0; NIR extraído do canal B da cam1; RE extraído do canal R da cam1", + } + + return rgbnir, meta_extra \ No newline at end of file diff --git a/Python/raspi/pi/main.py b/Python/raspi/cam_2/pi/main.py similarity index 100% rename from Python/raspi/pi/main.py rename to Python/raspi/cam_2/pi/main.py diff --git a/Python/raspi/pi/protocol.py b/Python/raspi/cam_2/pi/protocol.py similarity index 100% rename from Python/raspi/pi/protocol.py rename to Python/raspi/cam_2/pi/protocol.py diff --git a/Python/raspi/pi/raw_processor_core.py b/Python/raspi/cam_2/pi/raw_processor_core.py similarity index 100% rename from Python/raspi/pi/raw_processor_core.py rename to Python/raspi/cam_2/pi/raw_processor_core.py diff --git a/Python/raspi/pi/raw_processor_preview.py b/Python/raspi/cam_2/pi/raw_processor_preview.py similarity index 100% rename from Python/raspi/pi/raw_processor_preview.py rename to Python/raspi/cam_2/pi/raw_processor_preview.py diff --git a/Python/raspi/pi/server.py b/Python/raspi/cam_2/pi/server.py similarity index 68% rename from Python/raspi/pi/server.py rename to Python/raspi/cam_2/pi/server.py index 1df81dcaf..85b45fc23 100644 --- a/Python/raspi/pi/server.py +++ b/Python/raspi/cam_2/pi/server.py @@ -22,24 +22,35 @@ def parse_bool(value): raise ValueError("Valor booleano inválido") + def parse_frame_type(value): valid = {"RAW_BRUTO", "RGB", "RGBNIR"} if value not in valid: raise ValueError(f"frame_type inválido: {value}") return value + def parse_output_dtype(value): - valid = {"uint8", "float32"} + valid = {"uint8", "uint16", "float32"} if value not in valid: raise ValueError(f"output_dtype inválido: {value}") return value + +def parse_capture_mode(value): + valid = {"AUTO", "SINGLE", "DUAL"} + if value not in valid: + raise ValueError(f"capture_mode inválido: {value}") + return value + + def parse_bayer_pattern(value): valid = {"GBRG", "GRBG", "RGGB", "BGGR"} if value not in valid: raise ValueError(f"bayer_pattern inválido: {value}") return value + class ModuleServer: def __init__(self, host="0.0.0.0", port=5000): self.host = host @@ -66,6 +77,72 @@ class ModuleServer: if hasattr(self.camera, "mark_reconfigure_needed"): self.camera.mark_reconfigure_needed() + def _get_active_cameras(self): + return [cam for cam in self.state.cameras if cam.connected and cam.enabled] + + def _get_primary_camera(self): + active = self._get_active_cameras() + if active: + return active[0] + + if self.state.cameras: + return self.state.cameras[0] + + return None + + def _payload_dict(self): + payload = self.state.payload + return { + "payload_format_version": self.state.payload_format_version, + "frame_type": self.state.frame_type, + "output_dtype": self.state.output_dtype, + "output_layout": payload.layout, + "output_channels": payload.channels, + "output_channel_names": payload.channel_names, + "output_width": payload.width, + "output_height": payload.height, + "payload_sources": payload.sources, + } + + def _source_dict(self): + cam = self._get_primary_camera() + if cam is None: + return { + "source_camera": None, + "source_width": 0, + "source_height": 0, + "source_bayer_pattern": None, + "source_bit_depth": None, + } + + return { + "source_camera": cam.id, + "source_width": cam.width, + "source_height": cam.height, + "source_bayer_pattern": cam.bayer_pattern, + "source_bit_depth": cam.bit_depth, + } + + def _build_config_response(self): + return { + "ok": True, + "module": self.state.module_name, + "version": self.state.version, + "status": self.state.status, + "initialized": self.state.initialized, + "streaming": self.state.streaming, + "fps": self.state.fps, + "jpeg_quality": self.state.jpeg_quality, + "capture_mode": self.state.capture_mode, + "detected_mode": self.state.detected_mode, + "camera_count_detected": self.state.camera_count_detected, + "camera_count_active": self.state.camera_count_active, + "active_camera_ids": self.state.active_camera_ids, + "cameras": [cam.to_dict() for cam in self.state.cameras], + **self._payload_dict(), + **self._source_dict(), + } + def handle_command(self, msg: dict) -> dict: with self.lock: cmd = msg.get("cmd") @@ -79,55 +156,25 @@ class ModuleServer: return {"ok": True, **self.state.to_dict()} if cmd == "get_config": - return { - "ok": True, - "fps": self.state.fps, - "jpeg_quality": self.state.jpeg_quality, - "width": self.state.width, - "height": self.state.height, - "camera_id": self.state.camera_id, - - "payload_format_version": self.state.payload_format_version, - - "source_bayer_pattern": self.state.source_bayer_pattern, - "source_bit_depth": self.state.source_bit_depth, - - "frame_type": self.state.frame_type, - "output_dtype": self.state.output_dtype, - "output_layout": self.state.output_layout, - "output_channels": self.state.output_channels, - "output_channel_names": self.state.output_channel_names, - "output_width": self.state.output_width, - "output_height": self.state.output_height, - } + return self._build_config_response() if cmd == "begin": frame_type = parse_frame_type(msg.get("frame_type", self.state.frame_type)) output_dtype = parse_output_dtype(msg.get("output_dtype", self.state.output_dtype)) + capture_mode = parse_capture_mode(msg.get("capture_mode", self.state.capture_mode)) - # Atualiza a especificação de saída ANTES de inicializar self.state.frame_type = frame_type self.state.output_dtype = output_dtype - self.state.update_output_spec() + self.state.capture_mode = capture_mode + self.state.update_payload_spec() if self.state.initialized: return { "ok": True, "status": self.state.status, "initialized": True, - - "payload_format_version": self.state.payload_format_version, - - "frame_type": self.state.frame_type, - "output_dtype": self.state.output_dtype, - "output_layout": self.state.output_layout, - "output_channels": self.state.output_channels, - "output_channel_names": self.state.output_channel_names, - "output_width": self.state.output_width, - "output_height": self.state.output_height, - - "source_bayer_pattern": self.state.source_bayer_pattern, - "source_bit_depth": self.state.source_bit_depth, + **self._payload_dict(), + **self._source_dict(), } self.state.status = "initializing" @@ -137,7 +184,6 @@ class ModuleServer: self.camera.begin() self.state.initialized = True - self.state.camera_connected = True self.state.status = "ready" self.state.last_error = None @@ -145,19 +191,8 @@ class ModuleServer: "ok": True, "status": self.state.status, "initialized": True, - - "payload_format_version": self.state.payload_format_version, - - "frame_type": self.state.frame_type, - "output_dtype": self.state.output_dtype, - "output_layout": self.state.output_layout, - "output_channels": self.state.output_channels, - "output_channel_names": self.state.output_channel_names, - "output_width": self.state.output_width, - "output_height": self.state.output_height, - - "source_bayer_pattern": self.state.source_bayer_pattern, - "source_bit_depth": self.state.source_bit_depth, + **self._payload_dict(), + **self._source_dict(), } if cmd == "stop": @@ -171,7 +206,6 @@ class ModuleServer: self.state.initialized = False self.state.streaming = False - self.state.camera_connected = False self.state.status = "idle" self.state.status_detail = None return {"ok": True, "status": self.state.status} @@ -187,7 +221,6 @@ class ModuleServer: self.state.fps = value self._mark_reconfigure_needed() - return {"ok": True, "fps": self.state.fps} if cmd == "set_jpeg_quality": @@ -198,37 +231,95 @@ class ModuleServer: self.state.jpeg_quality = value return {"ok": True, "jpeg_quality": self.state.jpeg_quality} - if cmd == "set_bayer": - pattern = parse_bayer_pattern(msg.get("pattern", self.state.source_bayer_pattern)) - self.state.source_bayer_pattern = pattern - + if cmd == "set_frame_type": + frame_type = parse_frame_type(msg.get("value")) + self.state.frame_type = frame_type + self.state.update_payload_spec() + self._mark_reconfigure_needed() return { "ok": True, - "bayer_pattern": self.state.source_bayer_pattern + "frame_type": self.state.frame_type, + **self._payload_dict(), } - if cmd == "set_resolution": + if cmd == "set_output_dtype": + output_dtype = parse_output_dtype(msg.get("value")) + self.state.output_dtype = output_dtype + self.state.update_payload_spec() + self._mark_reconfigure_needed() + return { + "ok": True, + "output_dtype": self.state.output_dtype, + **self._payload_dict(), + } + + if cmd == "set_capture_mode": + capture_mode = parse_capture_mode(msg.get("value")) + self.state.capture_mode = capture_mode + self.state.update_payload_spec() + self._mark_reconfigure_needed() + return { + "ok": True, + "capture_mode": self.state.capture_mode, + "detected_mode": self.state.detected_mode, + **self._payload_dict(), + } + + if cmd == "set_camera_enabled": + index = int(msg.get("index")) + enabled = parse_bool(msg.get("enabled")) + self.state.set_camera_enabled(index, enabled) + self._mark_reconfigure_needed() + + cam = self.state.get_camera_by_index(index) + return { + "ok": True, + "camera": cam.to_dict(), + "camera_count_active": self.state.camera_count_active, + "active_camera_ids": self.state.active_camera_ids, + **self._payload_dict(), + } + + if cmd == "set_camera_bayer": + index = int(msg.get("index")) + pattern = parse_bayer_pattern(msg.get("pattern")) + + cam = self.state.get_camera_by_index(index) + if cam is None: + return {"ok": False, "error": f"Câmera de índice {index} não existe"} + + cam.bayer_pattern = pattern + self.state.update_payload_spec() + self._mark_reconfigure_needed() + + return { + "ok": True, + "camera": cam.to_dict(), + **self._source_dict(), + } + + if cmd == "set_camera_resolution": + index = int(msg.get("index")) width = int(msg.get("width")) height = int(msg.get("height")) if width <= 0 or height <= 0: return {"ok": False, "error": "resolução inválida"} - self.state.width = width - self.state.height = height - self.state.update_output_spec() + cam = self.state.get_camera_by_index(index) + if cam is None: + return {"ok": False, "error": f"Câmera de índice {index} não existe"} + + cam.width = width + cam.height = height + self.state.update_payload_spec() self._mark_reconfigure_needed() return { "ok": True, - "width": self.state.width, - "height": self.state.height, - "output_width": self.state.output_width, - "output_height": self.state.output_height, - "frame_type": self.state.frame_type, - "output_layout": self.state.output_layout, - "output_channels": self.state.output_channels, - "output_channel_names": self.state.output_channel_names, + "camera": cam.to_dict(), + **self._payload_dict(), + **self._source_dict(), } if cmd == "start_stream": @@ -238,10 +329,8 @@ class ModuleServer: if not host: return {"ok": False, "error": "host obrigatório"} - if port <= 0 or port > 65535: return {"ok": False, "error": "porta inválida"} - if fps <= 0: return {"ok": False, "error": "fps inválido"} @@ -252,19 +341,8 @@ class ModuleServer: "host": self.state.stream_host, "port": self.state.stream_port, "fps": self.state.stream_fps, - - "payload_format_version": self.state.payload_format_version, - - "frame_type": self.state.frame_type, - "output_dtype": self.state.output_dtype, - "output_layout": self.state.output_layout, - "output_channels": self.state.output_channels, - "output_channel_names": self.state.output_channel_names, - "output_width": self.state.output_width, - "output_height": self.state.output_height, - - "source_bayer_pattern": self.state.source_bayer_pattern, - "source_bit_depth": self.state.source_bit_depth, + **self._payload_dict(), + **self._source_dict(), } self.stream_sender.start(host, port, fps) @@ -280,19 +358,8 @@ class ModuleServer: "host": host, "port": port, "fps": fps, - - "payload_format_version": self.state.payload_format_version, - - "frame_type": self.state.frame_type, - "output_dtype": self.state.output_dtype, - "output_layout": self.state.output_layout, - "output_channels": self.state.output_channels, - "output_channel_names": self.state.output_channel_names, - "output_width": self.state.output_width, - "output_height": self.state.output_height, - - "source_bayer_pattern": self.state.source_bayer_pattern, - "source_bit_depth": self.state.source_bit_depth, + **self._payload_dict(), + **self._source_dict(), } if cmd == "stop_stream": @@ -303,11 +370,7 @@ class ModuleServer: self.state.stream_host = None self.state.stream_port = None self.state.stream_fps = None - - if self.state.initialized: - self.state.status = "ready" - else: - self.state.status = "idle" + self.state.status = "ready" if self.state.initialized else "idle" return { "ok": True, @@ -317,18 +380,14 @@ class ModuleServer: if cmd == "set_ae_enable": self.state.ae_enable = parse_bool(msg.get("value")) - if self.camera.initialized: self.camera.apply_controls() - return {"ok": True, "ae_enable": self.state.ae_enable} if cmd == "set_awb_enable": self.state.awb_enable = parse_bool(msg.get("value")) - if self.camera.initialized: self.camera.apply_controls() - return {"ok": True, "awb_enable": self.state.awb_enable} if cmd == "set_exposure_time": @@ -339,10 +398,8 @@ class ModuleServer: return {"ok": False, "error": "ExposureTime inválido"} self.state.exposure_time_us = value - if self.camera.initialized: self.camera.apply_controls() - return {"ok": True, "exposure_time_us": self.state.exposure_time_us} if cmd == "set_analogue_gain": @@ -353,10 +410,8 @@ class ModuleServer: return {"ok": False, "error": "AnalogueGain inválido"} self.state.analogue_gain = value - if self.camera.initialized: self.camera.apply_controls() - return {"ok": True, "analogue_gain": self.state.analogue_gain} if cmd == "set_colour_gains": @@ -373,10 +428,8 @@ class ModuleServer: return {"ok": False, "error": "ColourGains inválidos"} self.state.colour_gains = [r_gain, b_gain] - if self.camera.initialized: self.camera.apply_controls() - return {"ok": True, "colour_gains": self.state.colour_gains} if cmd == "clear_exposure_time": @@ -410,10 +463,7 @@ class ModuleServer: if cmd == "get_sensor_modes": modes = self.camera.get_sensor_modes() - return { - "ok": True, - "sensor_modes": modes - } + return {"ok": True, "sensor_modes": modes} return {"ok": False, "error": f"Comando desconhecido: {cmd}"} @@ -423,7 +473,6 @@ class ModuleServer: def client_thread(self, conn, addr): print(f"[INFO] Cliente conectado: {addr}") - buffer = b"" try: @@ -485,4 +534,4 @@ class ModuleServer: args=(conn, addr), daemon=True ) - t.start() + t.start() \ No newline at end of file diff --git a/Python/raspi/cam_2/pi/state.py b/Python/raspi/cam_2/pi/state.py new file mode 100644 index 000000000..fe0c35230 --- /dev/null +++ b/Python/raspi/cam_2/pi/state.py @@ -0,0 +1,374 @@ +from dataclasses import dataclass, field, asdict +from typing import List, Optional, Dict, Any + + +@dataclass +class CameraSpec: + id: str + index: int + role: str = "generic" # rgb | nir | re | generic + connected: bool = False + enabled: bool = True + available: bool = False # detectada e pronta para uso + + width: int = 640 + height: int = 480 + bayer_pattern: str = "GBRG" + bit_depth: int = 10 + + model: Optional[str] = None + serial: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class PayloadSpec: + frame_type: str = "RAW_BRUTO" # RAW_BRUTO | RGB | RGBNIR + dtype: str = "uint8" # uint8 | uint16 | float32 + layout: str = "HW" # HW | CHW + channels: int = 1 + channel_names: List[str] = field(default_factory=lambda: ["BAYER"]) + width: int = 640 + height: int = 480 + sources: List[str] = field(default_factory=lambda: ["cam0"]) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +class ModuleState: + def __init__(self): + self.module_name = "multispectral" + self.version = "0.2.0" + self.payload_format_version = 2 + + self.status = "idle" + self.status_detail = None + self.last_error = None + self.last_command = None + + self.initialized = False + self.streaming = False + + # Estratégia do módulo + self.capture_mode = "AUTO" # AUTO | SINGLE | DUAL + self.detected_mode = "NONE" # NONE | SINGLE | DUAL + self.multi_camera_enabled = True + self.max_cameras = 2 + + # Configuração global base + self.fps = 10 + self.jpeg_quality = 90 + + # Frame desejado de saída + self.frame_type = "RAW_BRUTO" # RAW_BRUTO | RGB | RGBNIR + self.output_dtype = "uint8" # uint8 | uint16 | float32 + + # Lista de câmeras conhecidas pelo módulo + self.cameras: List[CameraSpec] = [ + CameraSpec(id="cam0", index=0, role="rgb"), + CameraSpec(id="cam1", index=1, role="nir"), + ] + + # Resumo dinâmico da topologia + self.camera_count_detected = 0 + self.camera_count_active = 0 + self.active_camera_ids: List[str] = [] + + # Spec do payload atual + self.payload = PayloadSpec() + + # Trigger + self.trigger_enabled = True + self.trigger_pin = 18 + self.trigger_active_high = True + self.trigger_pulse_ms = 5.0 + self.trigger_settle_delay_ms = 0.0 + + # Streaming + self.stream_host = None + self.stream_port = None + self.stream_fps = None + self.stream_frame_id = 0 + + # Codec + self.codec_family = "none" + self.codec_name = "blosc" + self.codec_params = { + "cname": "zstd", + "clevel": 3, + "shuffle": "BITSHUFFLE", + } + + # Controle da câmera + self.ae_enable = True + self.awb_enable = True + self.exposure_time_us = 15000 + self.analogue_gain = 1.0 + self.colour_gains = [1.0, 1.0] + self.frame_duration_limits = None + + # Inicializa o payload conforme a configuração padrão + self.refresh_topology() + self.update_payload_spec() + + # ========================================================= + # Topologia / câmeras + # ========================================================= + + def get_camera(self, cam_id: str) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.id == cam_id: + return cam + return None + + def get_camera_by_index(self, index: int) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.index == index: + return cam + return None + + def set_camera_connected( + self, + index: int, + connected: bool, + *, + width: Optional[int] = None, + height: Optional[int] = None, + bayer_pattern: Optional[str] = None, + bit_depth: Optional[int] = None, + model: Optional[str] = None, + serial: Optional[str] = None, + ) -> None: + cam = self.get_camera_by_index(index) + if cam is None: + raise ValueError(f"Câmera de índice {index} não existe.") + + cam.connected = connected + cam.available = connected and cam.enabled + + if width is not None: + cam.width = width + if height is not None: + cam.height = height + if bayer_pattern is not None: + cam.bayer_pattern = bayer_pattern + if bit_depth is not None: + cam.bit_depth = bit_depth + if model is not None: + cam.model = model + if serial is not None: + cam.serial = serial + + self.refresh_topology() + self.update_payload_spec() + + def set_camera_enabled(self, index: int, enabled: bool) -> None: + cam = self.get_camera_by_index(index) + if cam is None: + raise ValueError(f"Câmera de índice {index} não existe.") + + cam.enabled = enabled + cam.available = cam.connected and cam.enabled + + self.refresh_topology() + self.update_payload_spec() + + def refresh_topology(self) -> None: + detected = [cam for cam in self.cameras if cam.connected] + active = [cam for cam in self.cameras if cam.connected and cam.enabled] + + self.camera_count_detected = len(detected) + self.camera_count_active = len(active) + self.active_camera_ids = [cam.id for cam in active] + + if self.camera_count_active >= 2 and self.multi_camera_enabled: + self.detected_mode = "DUAL" + elif self.camera_count_active == 1: + self.detected_mode = "SINGLE" + else: + self.detected_mode = "NONE" + + def resolve_capture_mode(self) -> str: + if self.capture_mode == "AUTO": + return self.detected_mode + + if self.capture_mode == "DUAL": + if self.camera_count_active >= 2 and self.multi_camera_enabled: + return "DUAL" + if self.camera_count_active == 1: + return "SINGLE" + return "NONE" + + if self.capture_mode == "SINGLE": + if self.camera_count_active >= 1: + return "SINGLE" + return "NONE" + + return "NONE" + + # ========================================================= + # Payload + # ========================================================= + + def update_payload_spec(self) -> None: + resolved_mode = self.resolve_capture_mode() + active_cams = [cam for cam in self.cameras if cam.connected and cam.enabled] + + if resolved_mode == "NONE" or not active_cams: + self.payload = PayloadSpec( + frame_type=self.frame_type, + dtype=self.output_dtype, + layout="HW", + channels=0, + channel_names=[], + width=0, + height=0, + sources=[], + ) + return + + # Para SINGLE usamos a primeira ativa + cam0 = active_cams[0] + + if self.frame_type == "RAW_BRUTO": + if resolved_mode == "SINGLE": + self.payload = PayloadSpec( + frame_type="RAW_BRUTO", + dtype=self.output_dtype, + layout="HW", + channels=1, + channel_names=["BAYER"], + width=cam0.width, + height=cam0.height, + sources=[cam0.id], + ) + elif resolved_mode == "DUAL": + # Aqui estamos dizendo que o payload contém duas origens brutas. + # O frame_service / stream_sender depois decide como empacotar isso. + self.payload = PayloadSpec( + frame_type="RAW_BRUTO", + dtype=self.output_dtype, + layout="HW", + channels=2, + channel_names=["BAYER_CAM0", "BAYER_CAM1"], + width=cam0.width, + height=cam0.height, + sources=[cam.id for cam in active_cams[:2]], + ) + + elif self.frame_type == "RGB": + self.payload = PayloadSpec( + frame_type="RGB", + dtype=self.output_dtype, + layout="CHW", + channels=3, + channel_names=["R", "G", "B"], + width=cam0.width // 2, + height=cam0.height // 2, + sources=[cam0.id], + ) + + elif self.frame_type == "RGBNIR": + if resolved_mode == "DUAL": + cam1 = active_cams[1] + self.payload = PayloadSpec( + frame_type="RGBNIR", + dtype=self.output_dtype, + layout="CHW", + channels=5, + channel_names=["R", "G", "B", "NIR", "RE"], + width=min(cam0.width, cam1.width) // 2, + height=min(cam0.height, cam1.height) // 2, + sources=[cam0.id, cam1.id], + ) + else: + # Ainda não dá para formar RGBNIR real com uma câmera só. + self.payload = PayloadSpec( + frame_type="RGBNIR", + dtype=self.output_dtype, + layout="CHW", + channels=0, + channel_names=[], + width=0, + height=0, + sources=[], + ) + + else: + raise ValueError(f"frame_type inválido: {self.frame_type}") + + # ========================================================= + # Estado / erro + # ========================================================= + + def clear_error(self) -> None: + self.last_error = None + if self.status == "error": + self.status = "idle" + self.status_detail = None + + def set_error(self, error: str) -> None: + self.last_error = str(error) + self.status = "error" + self.status_detail = str(error) + + # ========================================================= + # Serialização + # ========================================================= + + def to_dict(self) -> Dict[str, Any]: + return { + "module": self.module_name, + "version": self.version, + + "status": self.status, + "status_detail": self.status_detail, + "last_error": self.last_error, + "last_command": self.last_command, + + "initialized": self.initialized, + "streaming": self.streaming, + + "capture_mode": self.capture_mode, + "detected_mode": self.detected_mode, + "multi_camera_enabled": self.multi_camera_enabled, + "max_cameras": self.max_cameras, + + "camera_count_detected": self.camera_count_detected, + "camera_count_active": self.camera_count_active, + "active_camera_ids": self.active_camera_ids, + "cameras": [cam.to_dict() for cam in self.cameras], + + "fps": self.fps, + "jpeg_quality": self.jpeg_quality, + + "trigger_enabled": self.trigger_enabled, + "trigger_pin": self.trigger_pin, + "trigger_active_high": self.trigger_active_high, + "trigger_pulse_ms": self.trigger_pulse_ms, + "trigger_settle_delay_ms": self.trigger_settle_delay_ms, + + "stream_host": self.stream_host, + "stream_port": self.stream_port, + "stream_fps": self.stream_fps, + "stream_frame_id": self.stream_frame_id, + + "codec_family": self.codec_family, + "codec_name": self.codec_name, + "codec_params": self.codec_params, + + "ae_enable": self.ae_enable, + "awb_enable": self.awb_enable, + "exposure_time_us": self.exposure_time_us, + "analogue_gain": self.analogue_gain, + "colour_gains": self.colour_gains, + "frame_duration_limits": self.frame_duration_limits, + + "payload_format_version": self.payload_format_version, + "frame_type": self.frame_type, + "output_dtype": self.output_dtype, + "payload": self.payload.to_dict(), + } \ No newline at end of file diff --git a/Python/raspi/pi/stream_sender.py b/Python/raspi/cam_2/pi/stream_sender.py similarity index 83% rename from Python/raspi/pi/stream_sender.py rename to Python/raspi/cam_2/pi/stream_sender.py index 0c7d876bd..b52cd4a70 100644 --- a/Python/raspi/pi/stream_sender.py +++ b/Python/raspi/cam_2/pi/stream_sender.py @@ -32,7 +32,7 @@ class StreamSender: self._frames_dropped = 0 self._capture_errors = 0 self._send_errors = 0 - self._last_sent_camera_frame_id = 0 + self._last_sent_capture_signature = None @property def is_running(self): @@ -62,7 +62,7 @@ class StreamSender: self.state.stream_host = host self.state.stream_port = port self.state.stream_fps = fps - self._last_sent_camera_frame_id = 0 + self._last_sent_capture_signature = None self._thread_capture = threading.Thread( target=self._worker_capture, @@ -200,14 +200,56 @@ class StreamSender: self._t_send_header = t2 - t1 self._t_send_payload = t3 - t2 + def _build_capture_signature(self, meta): + camera_frames = meta.get("camera_frames", {}) + if not camera_frames: + return None + + items = [] + for cam_id in sorted(camera_frames.keys()): + items.append((cam_id, camera_frames[cam_id].get("camera_frame_id"))) + return tuple(items) + + def _serialize_frame_payload(self, frame, meta): + if not isinstance(frame, dict): + frame_bytes = frame.tobytes() + payload_meta = { + "multi_payload": False, + "payload_parts": [ + { + "kind": "single_array", + "size_raw": len(frame_bytes), + } + ], + } + return frame_bytes, payload_meta + + payload = bytearray() + payload_parts = [] + + for cam_id in meta.get("payload_sources", list(frame.keys())): + arr = frame[cam_id] + part_bytes = arr.tobytes() + payload.extend(len(part_bytes).to_bytes(4, "big")) + payload.extend(part_bytes) + + payload_parts.append({ + "camera_id": cam_id, + "size_raw": len(part_bytes), + }) + + payload_meta = { + "multi_payload": True, + "payload_parts": payload_parts, + } + return bytes(payload), payload_meta + def _worker_capture(self, fps: float): frame_interval = 1.0 / fps if fps > 0 else 0.0 next_deadline = time.perf_counter() last_frame_ts = None while not self._stop_event.is_set(): - t_loop0 = time.perf_counter() - try: frame, meta = self.frame_service.capture_frame_raw() except Exception as e: @@ -220,8 +262,8 @@ class StreamSender: time.sleep(0.01) continue - camera_frame_id = meta.get("camera_frame_id", 0) - if camera_frame_id == self._last_sent_camera_frame_id: + capture_signature = self._build_capture_signature(meta) + if capture_signature is not None and capture_signature == self._last_sent_capture_signature: time.sleep(0.001) continue @@ -230,11 +272,11 @@ class StreamSender: last_frame_ts = t_frame_ready t_bytes0 = time.perf_counter() - frame_bytes = frame.tobytes() + raw_payload, payload_meta = self._serialize_frame_payload(frame, meta) t_bytes1 = time.perf_counter() t_comp0 = time.perf_counter() - comp_bytes = self._compress(frame_bytes) + comp_bytes = self._compress(raw_payload) t_comp1 = time.perf_counter() codec_name = None if self.state.codec_family == "none" else self.state.codec_name @@ -246,11 +288,12 @@ class StreamSender: "codec_name": codec_name, "codec_params": codec_params, "dt_frame_period": dt_frame_period, - "payload_size_raw": len(frame_bytes), + "payload_size_raw": len(raw_payload), "payload_size_comp": len(comp_bytes), "dt_bytes": t_bytes1 - t_bytes0, "dt_comp": t_comp1 - t_comp0, - **meta + **payload_meta, + **meta, } queued = False @@ -270,7 +313,7 @@ class StreamSender: self._frames_dropped += 1 if queued: - self._last_sent_camera_frame_id = camera_frame_id + self._last_sent_capture_signature = capture_signature if frame_interval > 0: next_deadline += frame_interval diff --git a/Python/raspi/pi/trigger_manager.py b/Python/raspi/cam_2/pi/trigger_manager.py similarity index 100% rename from Python/raspi/pi/trigger_manager.py rename to Python/raspi/cam_2/pi/trigger_manager.py diff --git a/Python/raspi/stream_receiver.py b/Python/raspi/cam_2/stream_receiver.py similarity index 67% rename from Python/raspi/stream_receiver.py rename to Python/raspi/cam_2/stream_receiver.py index 560e672b5..913dd9f51 100644 --- a/Python/raspi/stream_receiver.py +++ b/Python/raspi/cam_2/stream_receiver.py @@ -41,13 +41,13 @@ class StreamReceiver: try: if self._client_sock: self._client_sock.close() - except: + except Exception: pass try: if self._server_sock: self._server_sock.close() - except: + except Exception: pass self._client_sock = None @@ -94,39 +94,23 @@ class StreamReceiver: payload_len = int.from_bytes(self._recv_exact(client, 4), "big") payload_comp = self._recv_exact(client, payload_len) - + self._ensure_codec(header) if header.get("codec_family") == "none": payload = payload_comp else: payload = self._codec.decode(payload_comp) - - expected = header["payload_size_raw"] + + expected = int(header["payload_size_raw"]) if len(payload) != expected: raise ValueError( f"Tamanho descomprimido inválido: {len(payload)} != {expected}" ) - - height = int(header.get("output_height", header.get("height"))) - width = int(header.get("output_width", header.get("width"))) - channels = int(header.get("output_channels", header.get("channels", 1))) - layout = header.get("output_layout", "HWC") - - dtype = self._numpy_dtype_from_header(header) - - arr = np.frombuffer(payload, dtype=dtype) - - if layout == "HW": - frame = arr.reshape(height, width) - - elif layout == "CHW": - frame = arr.reshape(channels, height, width) - - elif layout == "HWC": - frame = arr.reshape(height, width, channels) + if header.get("multi_payload", False): + frame = self._decode_multi_payload(payload, header) else: - raise RuntimeError(f"Layout não suportado: {layout}") + frame = self._decode_single_payload(payload, header) self.last_frame = frame self.last_meta = header @@ -143,6 +127,71 @@ class StreamReceiver: self._client_sock = None self._server_sock = None + def _decode_single_payload(self, payload: bytes, header: dict): + height = int(header.get("output_height", header.get("height"))) + width = int(header.get("output_width", header.get("width"))) + channels = int(header.get("output_channels", header.get("channels", 1))) + layout = header.get("output_layout", "HWC") + dtype = self._numpy_dtype_from_header(header) + + arr = np.frombuffer(payload, dtype=dtype) + + if layout == "HW": + return arr.reshape(height, width) + + if layout == "CHW": + return arr.reshape(channels, height, width) + + if layout == "HWC": + return arr.reshape(height, width, channels) + + raise RuntimeError(f"Layout não suportado: {layout}") + + def _decode_multi_payload(self, payload: bytes, header: dict): + payload_parts = header.get("payload_parts", []) + camera_frames = header.get("camera_frames", {}) + dtype = self._numpy_dtype_from_header(header) + + frames = {} + offset = 0 + + for part in payload_parts: + cam_id = part.get("camera_id") + if not cam_id: + raise RuntimeError("payload_parts sem camera_id") + + if offset + 4 > len(payload): + raise RuntimeError("Payload multi truncado ao ler tamanho da parte") + + part_size = int.from_bytes(payload[offset:offset + 4], "big") + offset += 4 + + if offset + part_size > len(payload): + raise RuntimeError(f"Payload multi truncado ao ler dados de {cam_id}") + + part_bytes = payload[offset:offset + part_size] + offset += part_size + + cam_meta = camera_frames.get(cam_id, {}) + width = int(cam_meta.get("width")) + height = int(cam_meta.get("height")) + channels = int(cam_meta.get("channels", 1)) + + # Para RAW dual atual, cada parte é HW. + arr = np.frombuffer(part_bytes, dtype=dtype) + if channels == 1: + frame = arr.reshape(height, width) + else: + frame = arr.reshape(channels, height, width) + + frames[cam_id] = frame + + if offset != len(payload): + raise RuntimeError( + f"Payload multi com bytes sobrando: consumidos={offset}, total={len(payload)}" + ) + + return frames def _normalize_shuffle(self, shuffle_value): if isinstance(shuffle_value, int): @@ -194,7 +243,6 @@ class StreamReceiver: self._codec = self._build_codec_from_header(header) self._codec_signature = sig - def _numpy_dtype_from_header(self, header: dict): dtype_str = header.get("dtype") or header.get("output_dtype") or "uint8" @@ -204,8 +252,11 @@ class StreamReceiver: "uint16": np.uint16, } + if dtype_str == "multi": + # fallback atual para RAW bruto multi + return np.uint16 + if dtype_str not in mapping: raise RuntimeError(f"dtype não suportado: {dtype_str}") - return mapping[dtype_str] - + return mapping[dtype_str] \ No newline at end of file diff --git a/Python/raspi/cam_3/capture_dataset.py b/Python/raspi/cam_3/capture_dataset.py new file mode 100644 index 000000000..e092bdd50 --- /dev/null +++ b/Python/raspi/cam_3/capture_dataset.py @@ -0,0 +1,824 @@ +import os +import time +import json +import argparse +from datetime import datetime + +import cv2 +import numpy as np + +from multispectral_service import MultiSpectralService +from stream_receiver import StreamReceiver +from pi.raw_processor_core import RawProcessorCore +from pi.raw_processor_preview import RawProcessorPreview + + +STREAM_PORT = 6001 +PI_HOST = "192.168.105.6" +PC_HOST = "192.168.105.5" + + +# ========================= +# Helpers gerais +# ========================= + +def ts_name() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + + +def overlay_hud( + img_bgr: np.ndarray, + lines: list[str], + base_h: int = 720, + base_font_scale: float = 0.75, + base_line_step: int = 28, +): + h, w = img_bgr.shape[:2] + + scale = h / float(base_h) + scale = max(scale, 0.4) + + font_scale = base_font_scale * scale + line_step = int(base_line_step * scale) + + thick_outline = max(1, int(3 * scale)) + thick_text = max(1, int(2 * scale)) + + y = int(24 * scale) + x = int(12 * scale) + + for s in lines: + cv2.putText(img_bgr, s, (x, y), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), thick_outline, cv2.LINE_AA) + cv2.putText(img_bgr, s, (x, y), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (255, 255, 255), thick_text, cv2.LINE_AA) + y += line_step + + +def save_sample( + base_dir: str, + frame_type: str, + preview_bgr: np.ndarray, + meta: dict, + raw_payload: np.ndarray | None = None, + packed_raw: np.ndarray | None = None, + packed_raw_by_camera: dict | None = None, +): + os.makedirs(base_dir, exist_ok=True) + name = ts_name() + + png_path = os.path.join(base_dir, f"{name}.png") + json_path = os.path.join(base_dir, f"{name}.json") + + if frame_type in ("RGB", "MULTISPEC"): + if raw_payload is None: + raise ValueError(f"raw_payload não pode ser None quando frame_type='{frame_type}'") + + payload_path = os.path.join(base_dir, f"{name}.raw") + raw_payload.astype(np.float32).tofile(payload_path) + + meta["saved_payload_type"] = frame_type.lower() + meta["saved_payload_path"] = os.path.basename(payload_path) + meta["saved_payload_dtype"] = "float32" + meta["saved_payload_shape"] = list(raw_payload.shape) + + elif frame_type == "RAW_BRUTO": + if packed_raw_by_camera is not None: + payload_files = {} + payload_shapes = {} + payload_dtypes = {} + + for cam_id, arr in packed_raw_by_camera.items(): + path = os.path.join(base_dir, f"{name}_{cam_id}.bin") + arr.tofile(path) + payload_files[cam_id] = os.path.basename(path) + payload_shapes[cam_id] = list(arr.shape) + payload_dtypes[cam_id] = str(arr.dtype) + + meta["saved_payload_type"] = "raw_native_multi" + meta["saved_payload_paths"] = payload_files + meta["saved_payload_shapes"] = payload_shapes + meta["saved_payload_dtypes"] = payload_dtypes + + else: + if packed_raw is None: + raise ValueError("packed_raw não pode ser None quando frame_type='RAW_BRUTO'") + + payload_path = os.path.join(base_dir, f"{name}.bin") + packed_raw.tofile(payload_path) + + meta["saved_payload_type"] = "raw_native_single" + meta["saved_payload_path"] = os.path.basename(payload_path) + meta["saved_payload_dtype"] = str(packed_raw.dtype) + meta["saved_payload_shape"] = list(packed_raw.shape) + + else: + raise ValueError(f"frame_type não suportado para save: {frame_type}") + + cv2.imwrite(png_path, preview_bgr) + + with open(json_path, "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + + return png_path, json_path + + +def get_camera_map_from_status(status: dict) -> dict: + result = {} + for cam in status.get("cameras", []): + result[cam.get("id")] = cam + return result + + +def validate_module_ready(status: dict, frame_type: str, raw_policy: str, capture_mode: str): + if not status.get("ok", True): + raise RuntimeError(f"Status inválido retornado pelo módulo: {status}") + + active_ids = list(status.get("active_camera_ids", [])) + active_count = int(status.get("camera_count_active", 0)) + + if frame_type == "RGB": + if "cam2" not in active_ids: + raise RuntimeError( + "Modo RGB requer cam2 ativa (USB RGB), mas o módulo não reportou cam2 como ativa." + ) + return + + if frame_type == "MULTISPEC": + if capture_mode == "TRIPLE": + missing = [cid for cid in ("cam0", "cam1", "cam2") if cid not in active_ids] + if missing: + raise RuntimeError( + f"Modo MULTISPEC/TRIPLE requer cam0, cam1 e cam2 ativas. " + f"Faltando: {missing}. Ativas atuais: {active_ids}" + ) + return + + if capture_mode == "DOUBLE": + has_rgb = "cam2" in active_ids + has_spec = ("cam0" in active_ids) or ("cam1" in active_ids) + + if not has_rgb or not has_spec: + raise RuntimeError( + f"Modo MULTISPEC/DOUBLE requer cam2 + (cam0 ou cam1). " + f"Ativas atuais: {active_ids}" + ) + return + + # AUTO ou outros casos + has_rgb = "cam2" in active_ids + has_spec = ("cam0" in active_ids) or ("cam1" in active_ids) + + if not (has_rgb and has_spec): + raise RuntimeError( + f"Modo MULTISPEC requer pelo menos RGB + 1 canal espectral. " + f"Ativas atuais: {active_ids}" + ) + return + + if frame_type == "RAW_BRUTO": + if raw_policy == "require_triple": + missing = [cid for cid in ("cam0", "cam1", "cam2") if cid not in active_ids] + if missing: + raise RuntimeError( + f"RAW_BRUTO com política require_triple exige três câmeras ativas. " + f"Faltando: {missing}. Ativas atuais: {active_ids}" + ) + else: + if active_count < 1: + raise RuntimeError("RAW_BRUTO requer ao menos uma câmera ativa, mas nenhuma foi detectada.") + return + + raise RuntimeError(f"frame_type desconhecido para validação: {frame_type}") + + +def build_preview_from_raw_payload( + frame, + meta: dict, + processor_core: RawProcessorCore, + processor_preview: RawProcessorPreview, +): + """ + Gera preview priorizando a câmera RGB (cam2). + Se cam2 não estiver presente, cai para fallback usando a primeira câmera mono disponível. + Retorna: + preview_bgr + payload_float_preview + preview_source_id + """ + payload_sources = meta.get("payload_sources", []) or [] + + # Caso multi-payload: tenta usar cam2 primeiro + if isinstance(frame, dict): + if "cam2" in frame: + rgb_frame = frame["cam2"] + + if rgb_frame.ndim != 3 or rgb_frame.shape[2] != 3: + raise RuntimeError(f"cam2 recebida mas inválida para preview RGB: shape={rgb_frame.shape}") + + preview_bgr = rgb_frame.copy() + payload_float = rgb_frame[:, :, ::-1].astype(np.float32) / 255.0 + payload_float = np.transpose(payload_float, (2, 0, 1)) + + return preview_bgr, payload_float, "cam2" + + # fallback: usa a primeira câmera mono disponível + fallback_id = None + for cid in ("cam0", "cam1"): + if cid in frame: + fallback_id = cid + break + + if fallback_id is None: + raise RuntimeError("Nenhuma câmera disponível no payload para gerar preview") + + packed = frame[fallback_id] + if packed.ndim == 3 and packed.shape[2] == 1: + packed = packed[:, :, 0] + + cam_frames = meta.get("camera_frames", {}) or {} + cam_meta = cam_frames.get(fallback_id, {}) + bit_depth = int(cam_meta.get("bit_depth", 10)) + + raw16 = processor_core.unpack_raw10_packed(packed) + preview_bgr = processor_preview.raw16_to_preview_bgr(raw16, bit_depth=bit_depth) + + payload_float = processor_core.build_training_rgb( + raw16, + output_dtype="float32", + bit_depth=bit_depth, + ) + + return preview_bgr, payload_float, fallback_id + + # Caso single-payload + if isinstance(frame, np.ndarray): + # Se vier HWC/3ch, tratamos como RGB USB + if frame.ndim == 3 and frame.shape[2] == 3: + preview_bgr = frame.copy() + payload_float = frame[:, :, ::-1].astype(np.float32) / 255.0 + payload_float = np.transpose(payload_float, (2, 0, 1)) + return preview_bgr, payload_float, "cam2" + + # Se vier mono packed, fallback antigo + packed = frame + if packed.ndim == 3 and packed.shape[2] == 1: + packed = packed[:, :, 0] + + source_camera = meta.get("source_camera") or {} + bit_depth = int(source_camera.get("bit_depth", meta.get("source_bit_depth", 10))) + + raw16 = processor_core.unpack_raw10_packed(packed) + preview_bgr = processor_preview.raw16_to_preview_bgr(raw16, bit_depth=bit_depth) + + payload_float = processor_core.build_training_rgb( + raw16, + output_dtype="float32", + bit_depth=bit_depth, + ) + + return preview_bgr, payload_float, source_camera.get("id", "unknown") + + raise RuntimeError(f"Tipo de frame não suportado para preview: {type(frame)}") + + +def resolve_effective_capture_mode(frame_type: str, raw_policy: str, requested_mode: str) -> str: + """ + Decide o modo real que será pedido ao módulo. + + Nova regra: + - Se o usuário pediu explicitamente SINGLE/DOUBLE/TRIPLE, respeitamos. + - Se pediu AUTO, deixamos AUTO ir para o módulo. + - A única exceção opcional é MULTISPEC + require_triple implícito, + mas mesmo assim podemos deixar o módulo resolver se preferirmos. + """ + if requested_mode in ("SINGLE", "DOUBLE", "TRIPLE"): + return requested_mode + + # requested_mode == AUTO + return "AUTO" + +# ========================= +# MAIN +# ========================= + +def main(): + parser = argparse.ArgumentParser( + description="Captura de dataset usando módulo multispectral Pi + StreamReceiver.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument("--cana", required=True, choices=["baixa", "media", "alta"], help="Estado da cana no momento da coleta.") + parser.add_argument("--horario", required=True, choices=["cedo", "meio_dia", "entardecer", "nublado"], help="Janela de iluminação / horário da coleta.") + parser.add_argument("--out_root", default="dataset", help="Pasta raiz do dataset.") + parser.add_argument("--modelo", default="imx296_pi", help="Nome do módulo/câmera para montar a pasta.") + parser.add_argument("--pi_host", default=PI_HOST, help="IP do servidor no Raspberry Pi.") + parser.add_argument("--pc_host", default=PC_HOST, help="IP local do notebook/PC que receberá o stream.") + parser.add_argument("--stream_port", type=int, default=STREAM_PORT, help="Porta TCP do receiver de stream.") + parser.add_argument("--server_port", type=int, default=5000, help="Porta TCP do servidor de comandos no Pi.") + parser.add_argument("--fps", type=int, default=20, help="FPS desejado.") + parser.add_argument("--width", type=int, default=640, help="Largura óptica da câmera.") + parser.add_argument("--height", type=int, default=480, help="Altura óptica da câmera.") + parser.add_argument("--interval", type=float, default=1.0, help="Intervalo em segundos para auto-save quando ligado.") + parser.add_argument("--preview_upscale", type=int, default=2, help="Fator de upscale visual do preview.") + parser.add_argument("--bayer", default="GBRG", choices=["GBRG", "GRBG", "RGGB", "BGGR"], help="Padrão Bayer das câmeras.") + parser.add_argument("--output_dtype", default="float32", choices=["uint8", "uint16", "float32"], help="Dtype do payload processado no Pi.") + parser.add_argument("--frame_type", default="RAW_BRUTO", choices=["RAW_BRUTO", "RGB", "MULTISPEC"], help="Tipo de payload pedido ao Pi.") + parser.add_argument("--capture_mode", default="AUTO", choices=["AUTO", "SINGLE", "DOUBLE", "TRIPLE"], help="Modo de captura desejado no módulo.") + parser.add_argument("--raw_policy", default="allow_single", choices=["allow_single", "require_triple"], help="Quando frame_type=RAW_BRUTO, define se o script aceita 1 câmera ou exige 3.") + + args = parser.parse_args() + + effective_capture_mode = resolve_effective_capture_mode( + frame_type=args.frame_type, + raw_policy=args.raw_policy, + requested_mode=args.capture_mode, + ) + + raw_w = args.width + raw_h = args.height + + session_dir = os.path.join( + args.modelo, + args.out_root, + "brutas", + f"cana_{args.cana}", + args.horario, + datetime.now().strftime("%Y%m%d"), + ) + os.makedirs(session_dir, exist_ok=True) + + print("============================================") + print("Coleta de dataset - Módulo Multiespectral") + print(f"Cana : {args.cana}") + print(f"Horário : {args.horario}") + print(f"Saída : {session_dir}") + print(f"Sensor : {raw_w}x{raw_h} | Bayer={args.bayer}") + print(f"FrameType : {args.frame_type}") + print(f"CaptureMode : {args.capture_mode} -> efetivo={effective_capture_mode}") + print(f"RAW policy : {args.raw_policy}") + print("============================================") + + auto_save = False + last_auto_t = 0.0 + preview_upscale = args.preview_upscale + + t_view_fps = time.time() + view_frames = 0 + fps_view = 0.0 + + t_stream_fps = time.time() + last_stream_frame_id = None + stream_frames_accum = 0 + fps_stream = 0.0 + + last_msg = "" + last_msg_t = 0.0 + + receiver = StreamReceiver(host="0.0.0.0", port=args.stream_port) + svc = MultiSpectralService(host=args.pi_host, port=args.server_port, timeout=10) + + print(f"[INFO] Verificando conexão com o módulo em {args.pi_host}:{args.server_port}...") + + svc.ensure_alive() + + if not svc.is_alive(): + raise RuntimeError("Módulo não encontrado ou não respondeu ao ping.") + + print("[OK] Módulo conectado e respondendo.") + + window_name = "Dataset Capture (C/SPACE=save | A=auto-save | M=preview scale | Q=quit)" + cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) + + processor_core_cam0 = RawProcessorCore( + sensor_width=raw_w, + sensor_height=raw_h, + bayer_pattern=args.bayer, + ) + processor_preview_cam0 = RawProcessorPreview( + sensor_width=raw_w, + sensor_height=raw_h, + bayer_pattern=args.bayer, + ) + + last_frame_id = -1 + last_payload_float = None + last_packed_raw = None + last_packed_raw_by_camera = None + last_preview_bgr = None + last_meta_stream = None + + try: + receiver.start() + time.sleep(0.5) + + svc.connect() + + if args.frame_type in ("RAW_BRUTO", "MULTISPEC"): + modes_resp = svc.get_sensor_modes() + if not modes_resp.get("ok"): + print(f"[WARN] Falha ao obter sensor_modes: {modes_resp}") + else: + for mode in modes_resp.get("sensor_modes", []): + print( + f"[cam={mode.get('camera_id')} mode={mode.get('mode_index')}] " + f"size={mode.get('size')} " + f"format={mode.get('format')} " + f"bit_depth={mode.get('bit_depth')} " + f"fps={mode.get('fps')}" + ) + else: + print("[INFO] get_sensor_modes pulado para frame_type=RGB") + + print("SET CAM0 RES:", svc.set_camera_resolution(0, raw_w, raw_h)) + print("SET CAM1 RES:", svc.set_camera_resolution(1, raw_w, raw_h)) + print("SET CAM2 RES:", svc.set_camera_resolution(2, raw_w, raw_h)) + print("SET CAM0 BAYER:", svc.set_camera_bayer(0, args.bayer)) + print("SET CAM1 BAYER:", svc.set_camera_bayer(1, args.bayer)) + print("SET FPS:", svc.set_fps(args.fps)) + print("SET CAPTURE MODE:", svc.set_capture_mode(effective_capture_mode)) + print("SET FRAME TYPE:", svc.set_frame_type(args.frame_type)) + print("SET OUTPUT DTYPE:", svc.set_output_dtype(args.output_dtype)) + + begin_resp = svc.begin( + frame_type=args.frame_type, + output_dtype=args.output_dtype, + capture_mode=effective_capture_mode, + ) + print("BEGIN:", begin_resp) + + status = svc.get_status() + print("STATUS:", json.dumps({ + "status": status.get("status"), + "detected_mode": status.get("detected_mode"), + "camera_count_active": status.get("camera_count_active"), + "active_camera_ids": status.get("active_camera_ids"), + }, ensure_ascii=False)) + + validate_module_ready(status, args.frame_type, args.raw_policy, effective_capture_mode) + + print("START STREAM:", svc.start_stream(args.pc_host, args.stream_port, fps=args.fps)) + + camera_ctrl = svc.get_camera_controls() + + ae_enabled = bool(camera_ctrl.get("ae_enable", True)) + awb_enabled = bool(camera_ctrl.get("awb_enable", True)) + manual_exposure_us = camera_ctrl.get("exposure_time_us", None) + manual_gain = camera_ctrl.get("analogue_gain", None) + manual_colour_gains = camera_ctrl.get("colour_gains", None) + + while True: + t0 = time.time() + + meta = receiver.last_meta + frame = receiver.last_frame + + if meta is not None and frame is not None and meta.get("frame_id") != last_frame_id: + last_frame_id = meta["frame_id"] + + try: + frame_type = meta.get("frame_type", "RAW_BRUTO") + dtype_str = meta.get("dtype") or meta.get("output_dtype", "uint8") + preview_source_id = "cam2" + + if frame_type == "RAW_BRUTO": + if isinstance(frame, dict): + packed_by_camera = frame + + preview_bgr, raw3_preview, preview_source_id = build_preview_from_raw_payload( + frame=frame, + meta=meta, + processor_core=processor_core_cam0, + processor_preview=processor_preview_cam0, + ) + + last_packed_raw = None + last_packed_raw_by_camera = {cam_id: arr.copy() for cam_id, arr in packed_by_camera.items()} + last_payload_float = raw3_preview.copy() + + else: + preview_bgr, raw3_preview, preview_source_id = build_preview_from_raw_payload( + frame=frame, + meta=meta, + processor_core=processor_core_cam0, + processor_preview=processor_preview_cam0, + ) + + last_packed_raw = frame.copy() + last_packed_raw_by_camera = None + last_payload_float = raw3_preview.copy() + + elif frame_type == "RGB": + rgb_chw = frame + if not isinstance(rgb_chw, np.ndarray) or rgb_chw.ndim != 3: + raise RuntimeError(f"Frame RGB inválido: type={type(rgb_chw)}") + + if dtype_str == "uint8": + payload_float = rgb_chw.astype(np.float32) / 255.0 + elif dtype_str == "float32": + payload_float = rgb_chw.astype(np.float32) + elif dtype_str == "uint16": + payload_float = rgb_chw.astype(np.float32) / 65535.0 + else: + raise RuntimeError(f"dtype RGB não suportado: {dtype_str}") + + preview_rgb = np.transpose(payload_float, (1, 2, 0)) + preview_bgr = cv2.cvtColor( + np.clip(preview_rgb * 255.0, 0, 255).astype(np.uint8), + cv2.COLOR_RGB2BGR + ) + + last_payload_float = payload_float.copy() + last_packed_raw = None + last_packed_raw_by_camera = None + + elif frame_type == "MULTISPEC": + multispec_chw = frame + if not isinstance(multispec_chw, np.ndarray) or multispec_chw.ndim != 3 or multispec_chw.shape[0] not in (4, 5): + raise RuntimeError(f"Frame MULTISPEC inválido: shape={getattr(multispec_chw, 'shape', None)}") + + if dtype_str == "uint8": + payload_float = multispec_chw.astype(np.float32) / 255.0 + elif dtype_str == "float32": + payload_float = multispec_chw.astype(np.float32) + elif dtype_str == "uint16": + payload_float = multispec_chw.astype(np.float32) / 65535.0 + else: + raise RuntimeError(f"dtype MULTISPEC não suportado: {dtype_str}") + + preview_rgb = np.transpose(payload_float[:3], (1, 2, 0)) + preview_bgr = cv2.cvtColor( + np.clip(preview_rgb * 255.0, 0, 255).astype(np.uint8), + cv2.COLOR_RGB2BGR + ) + + last_payload_float = payload_float.copy() + last_packed_raw = None + last_packed_raw_by_camera = None + + else: + raise RuntimeError(f"frame_type não suportado neste script: {frame_type}") + + if preview_upscale and preview_upscale > 1: + preview_show = cv2.resize( + preview_bgr, + (preview_bgr.shape[1] * preview_upscale, preview_bgr.shape[0] * preview_upscale), + interpolation=cv2.INTER_NEAREST, + ) + else: + preview_show = preview_bgr.copy() + + curr_frame_id = meta.get("frame_id") + + if curr_frame_id is not None: + if last_stream_frame_id != curr_frame_id: + stream_frames_accum += 1 + + last_stream_frame_id = curr_frame_id + + dt_stream = time.time() - t_stream_fps + if dt_stream >= 1.0: + fps_stream = stream_frames_accum / dt_stream + stream_frames_accum = 0 + t_stream_fps = time.time() + + view_frames += 1 + dt_view = time.time() - t_view_fps + if dt_view >= 1.0: + fps_view = view_frames / dt_view + view_frames = 0 + t_view_fps = time.time() + + active_sources = meta.get("payload_sources") + 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"AE={'ON' if ae_enabled else 'OFF'} | AWB={'ON' if awb_enabled else 'OFF'} | EXP={manual_exposure_us} | GAIN={manual_gain}", + "Keys: C/SPACE=save | A=auto-save | E=AE | W=AWB | I/K=exp | O/L=gain | R=reset | M=preview | Q/Esc=quit", + ] + overlay_hud(preview_show, lines, base_h=raw_h) + + if last_msg and (time.time() - last_msg_t) < 2.0: + cv2.putText(preview_show, last_msg, (12, preview_show.shape[0] - 18), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, cv2.LINE_AA) + + cv2.imshow(window_name, preview_show) + + last_preview_bgr = preview_bgr.copy() + last_meta_stream = dict(meta) + + except Exception as e: + err = np.zeros((500, 1200, 3), dtype=np.uint8) + cv2.putText(err, f"Erro ao processar frame: {e}", (20, 60), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, cv2.LINE_AA) + cv2.imshow(window_name, err) + print(f"[ERRO FRAME] {e}") + + now = time.time() + can_save = ( + last_meta_stream is not None and + last_preview_bgr is not None and + ( + (last_meta_stream.get("frame_type") in ("RGB", "MULTISPEC") and last_payload_float is not None) or + (last_meta_stream.get("frame_type") == "RAW_BRUTO" and (last_packed_raw is not None or last_packed_raw_by_camera is not None)) + ) + ) + + if auto_save and can_save and (now - last_auto_t) >= args.interval: + frame_type_save = last_meta_stream.get("frame_type") + + meta_save = { + "ts": datetime.now().isoformat(timespec="milliseconds"), + "cana": args.cana, + "horario": args.horario, + "sensor_width": raw_w, + "sensor_height": raw_h, + "bayer_pattern": args.bayer, + "fps_target": args.fps, + "frame_type": frame_type_save, + "capture_mode_requested": args.capture_mode, + "capture_mode_effective": effective_capture_mode, + "raw_policy": args.raw_policy, + "stream_meta": last_meta_stream, + "note": "autosave", + "raw_preview_reference_camera": preview_source_id, + } + + save_sample( + session_dir, + frame_type=frame_type_save, + preview_bgr=last_preview_bgr, + meta=meta_save, + raw_payload=last_payload_float, + packed_raw=last_packed_raw, + packed_raw_by_camera=last_packed_raw_by_camera, + ) + + last_msg = "SALVO (auto)" + last_msg_t = now + last_auto_t = now + + k = cv2.waitKey(1) & 0xFF + if k in (ord("q"), ord("Q"), 27): + break + + elif k in (ord("a"), ord("A")): + auto_save = not auto_save + last_msg = f"AutoSave -> {'ON' if auto_save else 'OFF'}" + last_msg_t = time.time() + + elif k in (ord("m"), ord("M")): + preview_upscale = 0 if preview_upscale else args.preview_upscale + last_msg = f"Preview UPSCALE -> {preview_upscale}" + last_msg_t = time.time() + + elif k in (ord("e"), ord("E")): + ae_enabled = not ae_enabled + resp = svc.set_ae_enable(ae_enabled) + ae_enabled = bool(resp.get("ae_enable", ae_enabled)) + last_msg = f"AE -> {'ON' if ae_enabled else 'OFF'}" + last_msg_t = time.time() + + elif k in (ord("w"), ord("W")): + awb_enabled = not awb_enabled + resp = svc.set_awb_enable(awb_enabled) + awb_enabled = bool(resp.get("awb_enable", awb_enabled)) + last_msg = f"AWB -> {'ON' if awb_enabled else 'OFF'}" + last_msg_t = time.time() + + elif k in (ord("i"), ord("I")): + if manual_exposure_us is None: + manual_exposure_us = 15000 + else: + manual_exposure_us = min(int(manual_exposure_us * 1.15), 200000) + + if ae_enabled: + ae_enabled = False + svc.set_ae_enable(False) + + resp = svc.set_exposure_time(int(manual_exposure_us)) + manual_exposure_us = resp.get("exposure_time_us", manual_exposure_us) + last_msg = f"ExposureTime -> {manual_exposure_us} us" + last_msg_t = time.time() + + elif k in (ord("k"), ord("K")): + if manual_exposure_us is None: + manual_exposure_us = 15000 + else: + manual_exposure_us = max(int(manual_exposure_us / 1.15), 100) + + if ae_enabled: + ae_enabled = False + svc.set_ae_enable(False) + + resp = svc.set_exposure_time(int(manual_exposure_us)) + manual_exposure_us = resp.get("exposure_time_us", manual_exposure_us) + last_msg = f"ExposureTime -> {manual_exposure_us} us" + last_msg_t = time.time() + + elif k in (ord("o"), ord("O")): + if manual_gain is None: + manual_gain = 1.0 + else: + manual_gain = min(float(manual_gain) * 1.10, 32.0) + + if ae_enabled: + ae_enabled = False + svc.set_ae_enable(False) + + resp = svc.set_analogue_gain(float(manual_gain)) + manual_gain = resp.get("analogue_gain", manual_gain) + last_msg = f"AnalogueGain -> {manual_gain:.2f}" + last_msg_t = time.time() + + elif k in (ord("l"), ord("L")): + if manual_gain is None: + manual_gain = 1.0 + else: + manual_gain = max(float(manual_gain) / 1.10, 1.0) + + if ae_enabled: + ae_enabled = False + svc.set_ae_enable(False) + + resp = svc.set_analogue_gain(float(manual_gain)) + manual_gain = resp.get("analogue_gain", manual_gain) + last_msg = f"AnalogueGain -> {manual_gain:.2f}" + last_msg_t = time.time() + + elif k in (ord("r"), ord("R")): + svc.clear_exposure_time() + svc.clear_analogue_gain() + svc.clear_colour_gains() + + manual_exposure_us = None + manual_gain = None + manual_colour_gains = None + + last_msg = "Manual controls resetados" + last_msg_t = time.time() + + elif k in (ord("c"), ord("C"), 32): + if can_save: + frame_type_save = last_meta_stream.get("frame_type") + meta_save = { + "ts": datetime.now().isoformat(timespec="milliseconds"), + "cana": args.cana, + "horario": args.horario, + "sensor_width": raw_w, + "sensor_height": raw_h, + "bayer_pattern": args.bayer, + "fps_target": args.fps, + "frame_type": frame_type_save, + "capture_mode_requested": args.capture_mode, + "capture_mode_effective": effective_capture_mode, + "raw_policy": args.raw_policy, + "stream_meta": last_meta_stream, + "camera_controls": { + "ae_enable": ae_enabled, + "awb_enable": awb_enabled, + "exposure_time_us": manual_exposure_us, + "analogue_gain": manual_gain, + "colour_gains": manual_colour_gains, + }, + "note": "manual", + "raw_preview_reference_camera": preview_source_id, + } + + save_sample( + session_dir, + frame_type=frame_type_save, + preview_bgr=last_preview_bgr, + meta=meta_save, + raw_payload=last_payload_float, + packed_raw=last_packed_raw, + packed_raw_by_camera=last_packed_raw_by_camera, + ) + + last_msg = "SALVO (manual)" + last_msg_t = time.time() + + dt_loop = time.time() - t0 + if dt_loop < 0.001: + time.sleep(0.001) + + finally: + try: + print("STOP STREAM:", svc.stop_stream()) + except Exception: + pass + + try: + print("STOP:", svc.stop()) + except Exception: + pass + + svc.disconnect() + receiver.stop() + cv2.destroyAllWindows() + print("Fim da captura.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Python/raspi/cam_3/multispectral_service.py b/Python/raspi/cam_3/multispectral_service.py new file mode 100644 index 000000000..18578fefc --- /dev/null +++ b/Python/raspi/cam_3/multispectral_service.py @@ -0,0 +1,405 @@ +import socket +import json +import numpy as np +import base64 +import time +from typing import Optional, Any + + +class MultiSpectralService: + def __init__(self, host="192.168.105.6", port=5000, timeout=5): + self.host = host + self.port = port + self.timeout = timeout + self.sock = None + self.file = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc, tb): + self.disconnect() + + # ========================================================= + # Conexão + # ========================================================= + + def connect(self): + if self.sock is not None: + return + + self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + self.sock.settimeout(self.timeout) + self.file = self.sock.makefile("r", encoding="utf-8") + + def disconnect(self): + try: + if self.file: + self.file.close() + except Exception: + pass + + try: + if self.sock: + self.sock.close() + except Exception: + pass + + self.file = None + self.sock = None + + def check_connection(self, timeout: float = None) -> bool: + try: + self.connect() + resp = self._send_command({"cmd": "ping"}) + return resp.get("ok") and resp.get("reply") == "pong" + except Exception: + return False + + def is_alive(self) -> bool: + try: + self.connect() + resp = self._send_command({"cmd": "ping"}) + return resp.get("ok") and resp.get("reply") == "pong" + except Exception: + return False + + def ensure_alive(self): + try: + self.connect() + except Exception as e: + raise RuntimeError( + f"Não foi possível conectar ao módulo em {self.host}:{self.port}. " + f"Verifique rede, IP e se o Pi está ligado. Erro: {e}" + ) from e + + try: + resp = self._send_command({"cmd": "ping"}) + except Exception as e: + raise RuntimeError( + f"Conectou ao endereço {self.host}:{self.port}, mas o módulo não respondeu ao ping. " + f"Verifique se o serviço está rodando no Pi. Erro: {e}" + ) from e + + if not resp.get("ok") or resp.get("reply") != "pong": + raise RuntimeError(f"Resposta inválida do módulo ao ping: {resp}") + + def _send_command(self, payload: dict) -> dict: + if self.sock is None: + self.connect() + + data = (json.dumps(payload) + "\n").encode("utf-8") + self.sock.sendall(data) + + line = self.file.readline() + if not line: + self.disconnect() + raise RuntimeError("Conexão encerrada pelo servidor") + + return json.loads(line.strip()) + + # ========================================================= + # Helpers numpy + # ========================================================= + + def _numpy_dtype_from_string(self, dtype_str: str): + mapping = { + "uint8": np.uint8, + "uint16": np.uint16, + "float32": np.float32, + } + if dtype_str not in mapping: + raise RuntimeError(f"dtype não suportado recebido do Pi: {dtype_str}") + return mapping[dtype_str] + + def _reshape_array_from_shape(self, raw_bytes: bytes, dtype_str: str, shape: list | tuple): + np_dtype = self._numpy_dtype_from_string(dtype_str) + arr = np.frombuffer(raw_bytes, dtype=np_dtype) + return arr.reshape(tuple(shape)) + + def _reshape_array(self, raw_bytes: bytes, dtype_str: str, layout: str, width: int, height: int, channels: int): + np_dtype = self._numpy_dtype_from_string(dtype_str) + arr = np.frombuffer(raw_bytes, dtype=np_dtype) + + if layout == "HW": + return arr.reshape(height, width) + + if layout == "CHW": + return arr.reshape(channels, height, width) + + if layout == "HWC": + return arr.reshape(height, width, channels) + + raise RuntimeError(f"Layout não suportado recebido do Pi: {layout}") + + def _decode_single_array(self, raw: bytes, resp: dict): + dtype_str = resp.get("dtype") or resp.get("output_dtype") or "uint8" + shape = resp.get("shape") + + if shape: + return self._reshape_array_from_shape(raw, dtype_str, shape) + + output_layout = resp.get("output_layout", "HW") + width = int(resp.get("output_width", resp.get("width", 0))) + height = int(resp.get("output_height", resp.get("height", 0))) + channels = int(resp.get("output_channels", resp.get("channels", 1))) + + return self._reshape_array( + raw_bytes=raw, + dtype_str=dtype_str, + layout=output_layout, + width=width, + height=height, + channels=channels, + ) + + def _decode_multi_frames_base64(self, resp: dict): + frames_resp = resp.get("frames", {}) + payload_parts = resp.get("payload_parts", []) or [] + camera_frames = resp.get("camera_frames", {}) or {} + + parts_by_cam = {} + for part in payload_parts: + cam_id = part.get("camera_id") + if cam_id: + parts_by_cam[cam_id] = part + + frames = {} + + for cam_id, item in frames_resp.items(): + raw = base64.b64decode(item["data"]) + part_meta = parts_by_cam.get(cam_id, {}) + cam_meta = camera_frames.get(cam_id, {}) + + dtype_str = part_meta.get("dtype") or resp.get("dtype") or resp.get("output_dtype") + shape = part_meta.get("shape") + + if dtype_str == "multi" or dtype_str is None: + # fallback conservador + dtype_str = "uint8" if int(cam_meta.get("channels", 1)) > 1 else "uint16" + + if shape: + arr = self._reshape_array_from_shape(raw, dtype_str, shape) + else: + width = int(part_meta.get("width", cam_meta.get("width", 0))) + height = int(part_meta.get("height", cam_meta.get("height", 0))) + channels = int(part_meta.get("channels", cam_meta.get("channels", 1))) + + layout = "HWC" if channels > 1 else "HW" + + arr = self._reshape_array( + raw_bytes=raw, + dtype_str=dtype_str, + layout=layout, + width=width, + height=height, + channels=channels, + ) + + frames[cam_id] = arr + + return frames + + def _extract_meta(self, resp: dict, t0: float) -> dict: + dtype_str = resp.get("dtype") or resp.get("output_dtype") or "uint8" + + return { + "frame_id": resp.get("frame_id"), + "frame_type": resp.get("frame_type"), + "payload_format_version": resp.get("payload_format_version"), + + "output_dtype": resp.get("output_dtype"), + "dtype": dtype_str, + "output_layout": resp.get("output_layout"), + "output_channels": resp.get("output_channels"), + "output_channel_names": resp.get("output_channel_names"), + "output_width": resp.get("output_width"), + "output_height": resp.get("output_height"), + + "payload_sources": resp.get("payload_sources"), + "payload_complete": resp.get("payload_complete"), + + "source_camera": resp.get("source_camera"), + "source_cameras": resp.get("source_cameras"), + "camera_frames": resp.get("camera_frames"), + + "multi_payload": resp.get("multi_payload", False), + "payload_kind": resp.get("payload_kind"), + "payload_parts": resp.get("payload_parts"), + + "packed_width": resp.get("packed_width"), + "packed_height": resp.get("packed_height"), + "source_width": resp.get("source_width"), + "source_height": resp.get("source_height"), + "source_bayer_pattern": resp.get("source_bayer_pattern"), + "source_bit_depth": resp.get("source_bit_depth"), + + "size": resp.get("size"), + "ts_pi": resp.get("ts_pi"), + "ts_pi_monotonic": resp.get("ts_pi_monotonic"), + + "dt_trigger": resp.get("dt_trigger"), + "dt_settle": resp.get("dt_settle"), + "dt_capture": resp.get("dt_capture"), + "dt_process": resp.get("dt_process"), + "dt_total_pi": resp.get("dt_total_pi"), + + "dt_total_pc": time.perf_counter() - t0, + } + + # ========================================================= + # Comandos básicos + # ========================================================= + + def ping(self): + return self._send_command({"cmd": "ping"}) + + def get_status(self): + return self._send_command({"cmd": "get_status"}) + + def get_config(self): + return self._send_command({"cmd": "get_config"}) + + def begin(self, frame_type: str = "RAW_BRUTO", output_dtype: str = "uint8", capture_mode: str = "AUTO"): + return self._send_command({ + "cmd": "begin", + "frame_type": frame_type, + "output_dtype": output_dtype, + "capture_mode": capture_mode, + }) + + def stop(self): + return self._send_command({"cmd": "stop"}) + + def set_fps(self, fps: int): + return self._send_command({"cmd": "set_fps", "value": fps}) + + def set_jpeg_quality(self, quality: int): + return self._send_command({"cmd": "set_jpeg_quality", "value": quality}) + + def set_frame_type(self, frame_type: str): + return self._send_command({"cmd": "set_frame_type", "value": frame_type}) + + def set_output_dtype(self, output_dtype: str): + return self._send_command({"cmd": "set_output_dtype", "value": output_dtype}) + + def set_capture_mode(self, capture_mode: str): + return self._send_command({"cmd": "set_capture_mode", "value": capture_mode}) + + def set_camera_enabled(self, index: int, enabled: bool): + return self._send_command({ + "cmd": "set_camera_enabled", + "index": index, + "enabled": bool(enabled) + }) + + def set_camera_bayer(self, index: int, bayer_pattern: str): + return self._send_command({ + "cmd": "set_camera_bayer", + "index": index, + "pattern": bayer_pattern + }) + + def set_camera_resolution(self, index: int, width: int, height: int): + return self._send_command({ + "cmd": "set_camera_resolution", + "index": index, + "width": width, + "height": height + }) + + # ========================================================= + # Captura + # ========================================================= + + def capture_frame(self): + t0 = time.perf_counter() + + resp = self._send_command({"cmd": "capture_frame"}) + if not resp.get("ok"): + raise RuntimeError(resp.get("error", "Falha ao capturar frame")) + + encoding = resp.get("encoding", "base64") + meta = self._extract_meta(resp, t0) + + if encoding == "base64": + raw = base64.b64decode(resp["data"]) + arr = self._decode_single_array(raw, resp) + + meta.update({ + "shape": list(arr.shape), + "channels": int(arr.shape[0]) if arr.ndim == 3 and resp.get("output_layout") == "CHW" + else (int(arr.shape[2]) if arr.ndim == 3 else 1), + "width": int(arr.shape[2]) if arr.ndim == 3 and resp.get("output_layout") == "CHW" + else (int(arr.shape[1]) if arr.ndim == 3 else int(arr.shape[1])), + "height": int(arr.shape[1]) if arr.ndim == 3 and resp.get("output_layout") == "CHW" + else int(arr.shape[0]), + }) + return arr, meta + + if encoding == "base64-multi": + frames = self._decode_multi_frames_base64(resp) + meta.update({ + "frames_meta": resp.get("camera_frames", {}), + "decoded_shapes": {cam_id: list(arr.shape) for cam_id, arr in frames.items()}, + }) + return frames, meta + + raise RuntimeError(f"encoding não suportado recebido do Pi: {encoding}") + + def capture_frame_array(self): + return self.capture_frame() + + # ========================================================= + # Stream + # ========================================================= + + def start_stream(self, host: str, port: int, fps: float): + return self._send_command({ + "cmd": "start_stream", + "host": host, + "port": port, + "fps": fps + }) + + def stop_stream(self): + return self._send_command({"cmd": "stop_stream"}) + + # ========================================================= + # Controles de câmera + # ========================================================= + + def get_camera_controls(self): + return self._send_command({"cmd": "get_camera_controls"}) + + def set_ae_enable(self, value: bool): + return self._send_command({"cmd": "set_ae_enable", "value": bool(value)}) + + def set_awb_enable(self, value: bool): + return self._send_command({"cmd": "set_awb_enable", "value": bool(value)}) + + def set_exposure_time(self, exposure_time_us: Optional[int] = None): + return self._send_command({"cmd": "set_exposure_time", "value": exposure_time_us}) + + def clear_exposure_time(self): + return self._send_command({"cmd": "clear_exposure_time"}) + + def set_analogue_gain(self, gain: float | None): + return self._send_command({"cmd": "set_analogue_gain", "value": gain}) + + def clear_analogue_gain(self): + return self._send_command({"cmd": "clear_analogue_gain"}) + + def set_colour_gains(self, r_gain: float, b_gain: float): + return self._send_command({ + "cmd": "set_colour_gains", + "r_gain": r_gain, + "b_gain": b_gain + }) + + def clear_colour_gains(self): + return self._send_command({"cmd": "clear_colour_gains"}) + + def get_sensor_modes(self): + return self._send_command({"cmd": "get_sensor_modes"}) \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/camera_manager.py b/Python/raspi/cam_3/pi/camera_manager.py new file mode 100644 index 000000000..3f7288efe --- /dev/null +++ b/Python/raspi/cam_3/pi/camera_manager.py @@ -0,0 +1,560 @@ +from picamera2 import Picamera2 +from threading import Lock, RLock +import threading +import time +import numpy as np +import cv2 +from pathlib import Path + + +class CameraManager: + def __init__(self, state): + self.state = state + self.initialized = False + + self.camera_lock = RLock() + self.frame_lock = Lock() + + self.cameras_runtime = {} + self._reconfigure_needed = False + self._sensor_modes_cache = None + + # ========================================================= + # Estado interno + # ========================================================= + + def mark_reconfigure_needed(self): + with self.camera_lock: + self._reconfigure_needed = True + + def _init_camera_runtime(self, cam_spec): + return { + "camera_id": cam_spec.id, + "camera_index": cam_spec.index, + "role": cam_spec.role, + "interface": getattr(cam_spec, "interface", "CSI"), + + "backend": None, # picamera2 | opencv + "picam2": None, + "cap": None, + + "last_frame": None, + "frame_id": 0, + "frame_ts": None, + "buffer": None, + + "stop_event": threading.Event(), + "thread": None, + } + + # ========================================================= + # Seleção de câmeras necessárias + # ========================================================= + + def _get_required_camera_ids(self): + if hasattr(self.state, "get_required_camera_ids_for_frame_type"): + return self.state.get_required_camera_ids_for_frame_type() + + frame_type = self.state.frame_type + resolved_mode = self.state.resolve_capture_mode() + + if frame_type == "RGB": + cam = getattr(self.state, "rgb_camera_id", None) + return [cam] if cam else [] + + if frame_type == "MULTISPEC": + ids = [] + for attr in ("rgb_camera_id", "re_camera_id", "nir_camera_id"): + cam_id = getattr(self.state, attr, None) + if cam_id: + ids.append(cam_id) + return ids + + if frame_type == "RAW_BRUTO": + if resolved_mode == "TRIPLE": + return [cam.id for cam in self.state.cameras if cam.enabled] + if resolved_mode == "SINGLE": + for role in ("rgb", "re", "nir"): + if hasattr(self.state, "get_active_camera_by_role"): + cam = self.state.get_active_camera_by_role(role) + if cam: + return [cam.id] + return [] + + return [] + + # ========================================================= + # Inicialização + # ========================================================= + + def begin(self): + with self.camera_lock: + self.stop() + + required_ids = set(self._get_required_camera_ids()) + if not required_ids: + print("[WARN] Nenhuma câmera requerida para o frame_type/capture_mode atual") + + for cam in self.state.cameras: + if cam.id not in required_ids: + self.state.set_camera_connected(cam.index, False) + continue + + try: + runtime = self._open_camera(cam) + self.cameras_runtime[cam.id] = runtime + + self.state.set_camera_connected( + cam.index, + True, + width=cam.width, + height=cam.height, + bayer_pattern=cam.bayer_pattern, + bit_depth=cam.bit_depth, + ) + + except Exception as e: + print(f"[WARN] Falha ao abrir {cam.id} (index={cam.index}, role={cam.role}): {e}") + self.state.set_camera_connected(cam.index, False) + + # Aplica controles logo após abrir + if self.cameras_runtime: + try: + self.apply_controls() + except Exception as e: + print(f"[WARN] Falha ao aplicar controles iniciais: {e}") + + for cam_id in list(self.cameras_runtime.keys()): + self._start_thread(cam_id) + + self.initialized = len(self.cameras_runtime) > 0 + self._reconfigure_needed = False + + return self.initialized + + def _open_camera(self, cam): + interface = getattr(cam, "interface", "CSI").upper() + + if interface == "CSI": + return self._open_csi_camera(cam) + + if interface == "USB": + return self._open_usb_camera(cam) + + raise RuntimeError(f"Interface de câmera não suportada: {interface}") + + def _open_csi_camera(self, cam): + runtime = self._init_camera_runtime(cam) + + picam2 = Picamera2(camera_num=cam.index) + + config = picam2.create_video_configuration( + main={"size": (640, 480), "format": "RGB888"}, + raw={"size": (cam.width, cam.height)}, + buffer_count=6 + ) + + picam2.configure(config) + picam2.start() + + runtime["backend"] = "picamera2" + runtime["picam2"] = picam2 + return runtime + + def _open_usb_camera(self, cam): + runtime = self._init_camera_runtime(cam) + + backend_name = str(getattr(cam, "usb_backend", "V4L2")).upper() + api_preference = cv2.CAP_V4L2 if backend_name == "V4L2" else cv2.CAP_ANY + + source = self._resolve_usb_video_path(cam) + if source: + cap = cv2.VideoCapture(source, api_preference) + else: + cap = cv2.VideoCapture(cam.index, api_preference) + + if not cap.isOpened(): + # fallback no índice se o path falhar + if source: + cap.release() + cap = cv2.VideoCapture(cam.index, api_preference) + + if not cap.isOpened(): + raise RuntimeError( + f"Falha ao abrir câmera USB. device_path={getattr(cam, 'device_path', None)} index={cam.index}" + ) + + # Configuração desejada + cap.set(cv2.CAP_PROP_FRAME_WIDTH, cam.width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, cam.height) + cap.set(cv2.CAP_PROP_FPS, float(self.state.fps)) + + # Tenta reduzir buffer + try: + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + except Exception: + pass + + # Tenta MJPG, se a câmera suportar + try: + fourcc = cv2.VideoWriter_fourcc(*"MJPG") + cap.set(cv2.CAP_PROP_FOURCC, fourcc) + except Exception: + pass + + # Pequeno warmup + time.sleep(0.25) + + frame = None + ok = False + for i in range(15): + ok, frame = cap.read() + if ok and frame is not None: + break + time.sleep(0.05) + + if not ok or frame is None: + cap.release() + raise RuntimeError( + f"Falha ao capturar frame inicial da câmera USB " + f"(device_path={getattr(cam, 'device_path', None)}, index={cam.index})" + ) + + actual_h, actual_w = frame.shape[:2] + channels = frame.shape[2] if frame.ndim == 3 else 1 + + cam.width = int(actual_w) + cam.height = int(actual_h) + + runtime["backend"] = "opencv" + runtime["cap"] = cap + runtime["buffer"] = frame.copy() + runtime["last_frame"] = runtime["buffer"] + runtime["frame_id"] = 1 + runtime["frame_ts"] = time.perf_counter() + + print( + f"[INFO] USB {cam.id} aberta: src={getattr(cam, 'device_path', None) or cam.index} " + f"{actual_w}x{actual_h}, channels={channels}, backend={backend_name}" + ) + + return runtime + + def _resolve_usb_video_path(self, cam): + # 1. Se já veio um caminho explícito e ele existe, usa + device_path = getattr(cam, "device_path", None) + if device_path and Path(device_path).exists(): + return str(Path(device_path).resolve()) if Path(device_path).is_symlink() else device_path + + # 2. Tenta by-id + by_id_dir = Path("/dev/v4l/by-id") + if by_id_dir.exists(): + candidates = sorted(by_id_dir.glob("*video-index0")) + if candidates: + # Para 1 webcam USB, o primeiro já costuma resolver + return str(candidates[0]) + + # 3. Tenta by-path + by_path_dir = Path("/dev/v4l/by-path") + if by_path_dir.exists(): + candidates = sorted(by_path_dir.glob("*video-index0")) + if candidates: + return str(candidates[0]) + + # 4. Fallback bruto + return None + + # ========================================================= + # Threads de captura + # ========================================================= + + def _start_thread(self, cam_id): + runtime = self.cameras_runtime[cam_id] + runtime["stop_event"].clear() + + t = threading.Thread( + target=self._update_loop, + args=(cam_id,), + daemon=True + ) + runtime["thread"] = t + t.start() + + def _update_loop(self, cam_id): + runtime = self.cameras_runtime[cam_id] + backend = runtime["backend"] + + while not runtime["stop_event"].is_set(): + try: + if backend == "picamera2": + self._update_loop_picamera2(runtime) + elif backend == "opencv": + self._update_loop_opencv(runtime) + else: + raise RuntimeError(f"Backend desconhecido: {backend}") + + except Exception as e: + print(f"[ERRO LOOP {cam_id}/{backend}] {e}") + time.sleep(0.05) + + def _update_loop_picamera2(self, runtime): + request = None + try: + picam2 = runtime["picam2"] + request = picam2.capture_request() + + raw = request.make_array("raw") + + with self.frame_lock: + if ( + runtime["buffer"] is None or + runtime["buffer"].shape != raw.shape or + runtime["buffer"].dtype != raw.dtype + ): + runtime["buffer"] = raw.copy() + else: + np.copyto(runtime["buffer"], raw) + + runtime["last_frame"] = runtime["buffer"] + runtime["frame_id"] += 1 + runtime["frame_ts"] = time.perf_counter() + + finally: + if request is not None: + try: + request.release() + except Exception: + pass + + def _update_loop_opencv(self, runtime): + cap = runtime["cap"] + + ok, frame = cap.read() + if not ok or frame is None: + raise RuntimeError("Falha ao ler frame da câmera USB") + + with self.frame_lock: + if ( + runtime["buffer"] is None or + runtime["buffer"].shape != frame.shape or + runtime["buffer"].dtype != frame.dtype + ): + runtime["buffer"] = frame.copy() + else: + np.copyto(runtime["buffer"], frame) + + runtime["last_frame"] = runtime["buffer"] + runtime["frame_id"] += 1 + runtime["frame_ts"] = time.perf_counter() + + # ========================================================= + # Leitura consolidada + # ========================================================= + + def capture_raw_frames(self): + result = {} + + with self.frame_lock: + for cam_id, runtime in self.cameras_runtime.items(): + if runtime["last_frame"] is None: + continue + + frame = runtime["last_frame"] + h, w = frame.shape[:2] + channels = frame.shape[2] if frame.ndim == 3 else 1 + + result[cam_id] = ( + frame, + w, + h, + channels, + runtime["frame_id"], + runtime["frame_ts"] + ) + + return result + + # ========================================================= + # Controles + # ========================================================= + + def apply_controls(self): + with self.camera_lock: + for cam_id, runtime in self.cameras_runtime.items(): + backend = runtime.get("backend") + + if backend == "picamera2": + self._apply_controls_picamera2(runtime) + + elif backend == "opencv": + self._apply_controls_opencv(runtime) + + return True + + def _apply_controls_picamera2(self, runtime): + picam2 = runtime.get("picam2") + if picam2 is None: + return + + controls = {} + frame_us = int(1_000_000 / max(1, self.state.fps or 10)) + + controls["FrameDurationLimits"] = (frame_us, frame_us) + controls["AeEnable"] = bool(self.state.ae_enable) + controls["AwbEnable"] = bool(self.state.awb_enable) + + if not self.state.ae_enable: + if self.state.exposure_time_us is not None: + controls["ExposureTime"] = int(self.state.exposure_time_us) + if self.state.analogue_gain is not None: + controls["AnalogueGain"] = float(self.state.analogue_gain) + + # Só faz sentido real para câmera colorida no backend Picamera2. + # Como RE/NIR são mono, normalmente AWB/ColourGains serão ignorados. + if not self.state.awb_enable and self.state.colour_gains is not None: + r_gain, b_gain = self.state.colour_gains + controls["ColourGains"] = (float(r_gain), float(b_gain)) + + try: + picam2.set_controls(controls) + except Exception as e: + print(f"[ERRO CONTROLS PICAM2] {e} | controls={controls}") + + def _apply_controls_opencv(self, runtime): + cap = runtime.get("cap") + if cap is None: + return + + # FPS + try: + cap.set(cv2.CAP_PROP_FPS, float(self.state.fps)) + except Exception: + pass + + # Exposição + if self.state.exposure_time_us is not None: + try: + # Nem toda webcam respeita isso; tentativa best-effort + cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) # manual em muitos backends V4L2 + except Exception: + pass + + try: + # OpenCV costuma usar escala dependente do driver. + # Mantemos como tentativa simples, depois calibramos na prática. + cap.set(cv2.CAP_PROP_EXPOSURE, float(self.state.exposure_time_us)) + except Exception: + pass + + # Ganho + if self.state.analogue_gain is not None: + try: + cap.set(cv2.CAP_PROP_GAIN, float(self.state.analogue_gain)) + except Exception: + pass + + # AWB / WB + try: + if self.state.awb_enable: + cap.set(cv2.CAP_PROP_AUTO_WB, 1) + else: + cap.set(cv2.CAP_PROP_AUTO_WB, 0) + except Exception: + pass + + # ========================================================= + # Sensor modes + # ========================================================= + + def get_sensor_modes(self): + if self._sensor_modes_cache is not None: + return self._sensor_modes_cache + + result = [] + + for cam in self.state.cameras: + if getattr(cam, "interface", "CSI").upper() != "CSI": + continue + + temp = None + try: + temp = Picamera2(camera_num=cam.index) + modes = temp.sensor_modes + + cam_modes = [] + for i, m in enumerate(modes): + cam_modes.append({ + "camera_id": cam.id, + "camera_index": cam.index, + "role": cam.role, + "interface": cam.interface, + "mode_index": i, + "format": str(m.get("format")) if m.get("format") is not None else None, + "size": list(m.get("size")) if m.get("size") is not None else None, + "bit_depth": m.get("bit_depth"), + "fps": m.get("fps"), + "crop_limits": list(m.get("crop_limits")) if m.get("crop_limits") is not None else None, + "exposure_limits": list(m.get("exposure_limits")) if m.get("exposure_limits") is not None else None, + }) + + result.extend(cam_modes) + + except Exception as e: + result.append({ + "camera_id": cam.id, + "camera_index": cam.index, + "role": cam.role, + "interface": cam.interface, + "error": str(e), + }) + + finally: + if temp is not None: + try: + temp.close() + except Exception: + pass + + self._sensor_modes_cache = result + return result + + # ========================================================= + # Encerramento + # ========================================================= + + def stop(self): + with self.camera_lock: + for runtime in self.cameras_runtime.values(): + runtime["stop_event"].set() + + for runtime in self.cameras_runtime.values(): + t = runtime.get("thread") + if t: + t.join(timeout=1.5) + + for runtime in self.cameras_runtime.values(): + cam = runtime.get("picam2") + if cam: + try: + cam.stop() + except Exception: + pass + try: + cam.close() + except Exception: + pass + + cap = runtime.get("cap") + if cap: + try: + cap.release() + except Exception: + pass + + self.cameras_runtime.clear() + self.initialized = False + self._reconfigure_needed = False + self._sensor_modes_cache = None + + for cam in self.state.cameras: + self.state.set_camera_connected(cam.index, False) \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/frame_service.py b/Python/raspi/cam_3/pi/frame_service.py new file mode 100644 index 000000000..ad800f561 --- /dev/null +++ b/Python/raspi/cam_3/pi/frame_service.py @@ -0,0 +1,513 @@ +import time +import threading +import base64 +import numpy as np + + +class FrameService: + def __init__(self, state, trigger_manager, camera_manager): + self.state = state + self.trigger = trigger_manager + self.camera = camera_manager + self._capture_lock = threading.RLock() + self.raw_processors = {} + + # ========================================================= + # Helpers + # ========================================================= + + def _get_camera_spec(self, cam_id): + cam = self.state.get_camera(cam_id) + if cam is None: + raise RuntimeError(f"Câmera '{cam_id}' não encontrada no state") + return cam + + def _ensure_raw_processor_for_camera(self, cam_id): + cam = self._get_camera_spec(cam_id) + rp = self.raw_processors.get(cam_id) + + if ( + rp is None or + rp.sensor_width != cam.width or + rp.sensor_height != cam.height or + rp.bayer_pattern.upper() != cam.bayer_pattern.upper() + ): + from raw_processor_core import RawProcessorCore + rp = RawProcessorCore( + sensor_width=cam.width, + sensor_height=cam.height, + bayer_pattern=cam.bayer_pattern, + ) + self.raw_processors[cam_id] = rp + + return rp + + def _capture_with_retry(self, required_sources, max_attempts=10, retry_delay_s=0.02): + last_frames = None + + for _ in range(max_attempts): + frames = self.camera.capture_raw_frames() + + ok = True + for cam_id in required_sources: + info = frames.get(cam_id) + if info is None: + ok = False + break + + frame, width, height, channels, frame_id, frame_ts = info + if frame is None or width <= 0 or height <= 0 or channels <= 0: + ok = False + break + + if ok: + return frames + + last_frames = frames + time.sleep(retry_delay_s) + + raise RuntimeError( + f"Capture retornou frames insuficientes após {max_attempts} tentativas. " + f"required_sources={required_sources}, received_sources={list((last_frames or {}).keys())}" + ) + + def _is_usb_rgb_camera(self, cam): + return getattr(cam, "interface", "").upper() == "USB" and cam.role == "rgb" + + def _is_csi_multispec_camera(self, cam): + return getattr(cam, "interface", "").upper() == "CSI" and cam.role in ("re", "nir") + + def _normalize_raw16_to_float(self, raw16: np.ndarray, bit_depth: int) -> np.ndarray: + max_val = float((1 << int(bit_depth)) - 1) + arr = raw16.astype(np.float32) / max_val + return np.clip(arr, 0.0, 1.0) + + def _convert_usb_bgr_to_rgb_chw_float(self, frame_bgr: np.ndarray) -> np.ndarray: + if frame_bgr.ndim != 3 or frame_bgr.shape[2] != 3: + raise RuntimeError(f"Frame RGB USB inválido para conversão BGR->RGB: shape={frame_bgr.shape}") + + rgb = frame_bgr[:, :, ::-1] # BGR -> RGB + rgb = rgb.astype(np.float32) / 255.0 + chw = np.transpose(rgb, (2, 0, 1)) + return np.clip(chw, 0.0, 1.0) + + def _convert_output_dtype(self, arr, bit_depth=None): + """ + Converte arrays float32 normalizados [0..1], uint8, uint16 ou inteiros crus + para self.state.output_dtype. + """ + if bit_depth is None: + bit_depth = 10 + + max_sensor_value = float((1 << int(bit_depth)) - 1) + + if self.state.output_dtype == "uint8": + if arr.dtype == np.uint8: + return arr + + if arr.dtype == np.float32: + return (arr * 255.0).clip(0, 255).astype(np.uint8) + + if arr.dtype.kind in ("u", "i"): + max_val = arr.max() if arr.size > 0 else 0 + if max_val <= 255: + return arr.astype(np.uint8) + return ((arr.astype(np.float32) / max_sensor_value) * 255.0).clip(0, 255).astype(np.uint8) + + raise RuntimeError(f"dtype não suportado para uint8: {arr.dtype}") + + if self.state.output_dtype == "uint16": + if arr.dtype == np.uint16: + return arr + + if arr.dtype == np.uint8: + return (arr.astype(np.uint16) << 8) + + if arr.dtype == np.float32: + return (arr * 65535.0).clip(0, 65535).astype(np.uint16) + + if arr.dtype.kind in ("u", "i"): + return arr.astype(np.uint16) + + raise RuntimeError(f"dtype não suportado para uint16: {arr.dtype}") + + if self.state.output_dtype == "float32": + if arr.dtype == np.float32: + return arr + + if arr.dtype == np.uint8: + return arr.astype(np.float32) / 255.0 + + if arr.dtype.kind in ("u", "i"): + max_val = arr.max() if arr.size > 0 else 0 + if max_val <= 255: + return arr.astype(np.float32) / 255.0 + return arr.astype(np.float32) / max_sensor_value + + raise RuntimeError(f"dtype não suportado para float32: {arr.dtype}") + + raise RuntimeError(f"output_dtype inválido: {self.state.output_dtype}") + + # ========================================================= + # Captura principal + # ========================================================= + + def capture_frame_raw(self): + with self._capture_lock: + if not self.state.initialized: + raise RuntimeError("Módulo não inicializado") + + t0_perf = time.perf_counter() + t0_unix = time.time() + + dt_trigger = 0.0 + dt_settle = 0.0 + dt_capture = 0.0 + dt_process = 0.0 + + required_sources = list(self.state.payload.sources) + required_cams = [self._get_camera_spec(cam_id) for cam_id in required_sources] + has_csi_source = any(getattr(cam, "interface", "").upper() == "CSI" for cam in required_cams) + + try: + # Trigger só vale para as CSI RE/NIR. + # A USB RGB não responde a esse trigger. + if self.state.trigger_enabled and has_csi_source: + trig_start = time.perf_counter() + self.trigger.pulse() + trig_end = time.perf_counter() + dt_trigger = trig_end - trig_start + + settle_ms = float(getattr(self.state, "trigger_settle_delay_ms", 0.0) or 0.0) + if settle_ms > 0: + settle_start = time.perf_counter() + time.sleep(settle_ms / 1000.0) + settle_end = time.perf_counter() + dt_settle = settle_end - settle_start + + cap_start = time.perf_counter() + raw_frames = self._capture_with_retry(required_sources) + cap_end = time.perf_counter() + dt_capture = cap_end - cap_start + + proc_start = time.perf_counter() + frame_out, meta_extra = self._build_output_frame(raw_frames) + proc_end = time.perf_counter() + dt_process = proc_end - proc_start + + except Exception as e: + raise RuntimeError(f"Falha durante captura/processamento de frame: {e}") from e + + if frame_out is None: + raise RuntimeError("Frame processado retornou nulo") + + total_end = time.perf_counter() + + self.state.stream_frame_id += 1 + frame_id = self.state.stream_frame_id + + camera_frames_meta = {} + for cam_id, info in raw_frames.items(): + frame, width, height, channels, cam_frame_id, cam_frame_ts = info + cam = self._get_camera_spec(cam_id) + + camera_frames_meta[cam_id] = { + "camera_frame_id": int(cam_frame_id), + "camera_frame_ts": cam_frame_ts, + "width": int(width), + "height": int(height), + "channels": int(channels), + "role": cam.role, + "interface": getattr(cam, "interface", None), + "bayer_pattern": cam.bayer_pattern, + "bit_depth": int(cam.bit_depth), + "device_path": getattr(cam, "device_path", None), + } + + meta = { + "frame_id": frame_id, + "ts_pi": t0_unix, + "ts_pi_monotonic": t0_perf, + + "dt_trigger": dt_trigger, + "dt_settle": dt_settle, + "dt_capture": dt_capture, + "dt_process": dt_process, + "dt_total_pi": total_end - t0_perf, + + "camera_frames": camera_frames_meta, + "capture_mode_resolved": self.state.resolve_capture_mode(), + "payload_format_version": self.state.payload_format_version, + } + + meta.update(meta_extra) + return frame_out, meta + + def capture_frame_base64(self): + frame, meta = self.capture_frame_raw() + + if isinstance(frame, dict): + encoded = {} + total_size = 0 + + for cam_id, arr in frame.items(): + frame_bytes = arr.tobytes() + total_size += len(frame_bytes) + encoded[cam_id] = { + "encoding": "base64", + "size": len(frame_bytes), + "data": base64.b64encode(frame_bytes).decode("ascii"), + } + + meta["encoding"] = "base64-multi" + meta["size"] = total_size + meta["frames"] = encoded + return meta + + frame_bytes = frame.tobytes() + meta["encoding"] = "base64" + meta["size"] = len(frame_bytes) + meta["data"] = base64.b64encode(frame_bytes).decode("ascii") + return meta + + # ========================================================= + # Roteamento por frame_type + # ========================================================= + + def _build_output_frame(self, raw_frames): + if self.state.frame_type == "RAW_BRUTO": + return self._process_raw_bruto(raw_frames) + + if self.state.frame_type == "RGB": + return self._process_rgb(raw_frames) + + if self.state.frame_type == "MULTISPEC": + return self._process_multispec(raw_frames) + + raise RuntimeError(f"frame_type inválido: {self.state.frame_type}") + + # ========================================================= + # RAW_BRUTO + # ========================================================= + + def _process_raw_bruto(self, raw_frames): + sources = list(self.state.payload.sources) + + if len(sources) == 1: + cam_id = sources[0] + frame, width, height, channels, cam_frame_id, cam_frame_ts = raw_frames[cam_id] + cam = self._get_camera_spec(cam_id) + + if channels == 1: + output_layout = "HW" + output_channel_names = ["RAW_NATIVE"] + else: + output_layout = "HWC" + output_channel_names = [f"C{i}" for i in range(channels)] + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": str(frame.dtype), + "dtype": str(frame.dtype), + "output_layout": output_layout, + "output_channels": int(channels), + "output_channel_names": output_channel_names, + "output_width": int(width), + "output_height": int(height), + "width": int(width), + "height": int(height), + "channels": int(channels), + "payload_sources": [cam_id], + "source_camera": { + "id": cam_id, + "role": cam.role, + "interface": getattr(cam, "interface", None), + "bayer_pattern": cam.bayer_pattern, + "bit_depth": int(cam.bit_depth), + "device_path": getattr(cam, "device_path", None), + }, + } + return frame, meta_extra + + frames_out = {} + sources_meta = [] + + for cam_id in sources: + frame, width, height, channels, cam_frame_id, cam_frame_ts = raw_frames[cam_id] + cam = self._get_camera_spec(cam_id) + frames_out[cam_id] = frame + + sources_meta.append({ + "id": cam_id, + "role": cam.role, + "interface": getattr(cam, "interface", None), + "width": int(width), + "height": int(height), + "channels": int(channels), + "bayer_pattern": cam.bayer_pattern, + "bit_depth": int(cam.bit_depth), + "device_path": getattr(cam, "device_path", None), + }) + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": "multi", + "dtype": "multi", + "output_layout": "MULTI_NATIVE", + "output_channels": len(frames_out), + "output_channel_names": [f"NATIVE_{cam_id.upper()}" for cam_id in sources], + "output_width": None, + "output_height": None, + "width": None, + "height": None, + "channels": len(frames_out), + "payload_sources": sources, + "raw_sources": sources_meta, + } + + return frames_out, meta_extra + + # ========================================================= + # RGB + # ========================================================= + + def _process_rgb(self, raw_frames): + cam_id = self.state.payload.sources[0] + frame, width, height, channels, _, _ = raw_frames[cam_id] + cam = self._get_camera_spec(cam_id) + + if not self._is_usb_rgb_camera(cam): + raise RuntimeError( + f"Modo RGB espera câmera USB role=rgb, mas recebeu cam_id={cam_id}, " + f"role={cam.role}, interface={getattr(cam, 'interface', None)}" + ) + + rgb_chw = self._convert_usb_bgr_to_rgb_chw_float(frame) + rgb_chw = self._convert_output_dtype(rgb_chw, bit_depth=8) + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": self.state.output_dtype, + "dtype": str(rgb_chw.dtype), + "output_layout": "CHW", + "output_channels": 3, + "output_channel_names": ["R", "G", "B"], + "output_width": int(rgb_chw.shape[2]), + "output_height": int(rgb_chw.shape[1]), + "width": int(rgb_chw.shape[2]), + "height": int(rgb_chw.shape[1]), + "channels": 3, + "payload_sources": [cam_id], + "source_camera": { + "id": cam_id, + "role": cam.role, + "interface": getattr(cam, "interface", None), + "bit_depth": int(cam.bit_depth), + "source_width": int(width), + "source_height": int(height), + "device_path": getattr(cam, "device_path", None), + }, + "rgb_note": "RGB derivado diretamente de frame USB BGR8", + } + + return rgb_chw, meta_extra + + # ========================================================= + # MULTISPEC + # ========================================================= + + def _process_multispec(self, raw_frames): + sources = list(self.state.payload.sources) + if len(sources) < 2: + raise RuntimeError("MULTISPEC requer pelo menos RGB + RE ou RGB + NIR") + + rgb_cam = self.state.get_active_camera_by_role("rgb") + re_cam = self.state.get_active_camera_by_role("re") + nir_cam = self.state.get_active_camera_by_role("nir") + + if rgb_cam is None: + raise RuntimeError("MULTISPEC requer uma câmera RGB ativa") + + # RGB USB + rgb_frame, rgb_w, rgb_h, rgb_channels, _, _ = raw_frames[rgb_cam.id] + if rgb_channels != 3: + raise RuntimeError(f"Frame RGB USB inválido: channels={rgb_channels}, shape={rgb_frame.shape}") + + rgb_chw = self._convert_usb_bgr_to_rgb_chw_float(rgb_frame) + + spectral_parts = [] + source_cameras = [{ + "id": rgb_cam.id, + "role": rgb_cam.role, + "interface": getattr(rgb_cam, "interface", None), + "bit_depth": int(rgb_cam.bit_depth), + "device_path": getattr(rgb_cam, "device_path", None), + }] + channel_names = ["R", "G", "B"] + + if re_cam is not None and re_cam.id in raw_frames: + re_packed, *_ = raw_frames[re_cam.id] + if re_packed.ndim == 3 and re_packed.shape[2] == 1: + re_packed = re_packed[:, :, 0] + + rp_re = self._ensure_raw_processor_for_camera(re_cam.id) + re_raw16 = rp_re.unpack_raw10_packed(re_packed) + re_single = self._normalize_raw16_to_float(re_raw16, re_cam.bit_depth)[None, :, :] + spectral_parts.append(re_single) + channel_names.append("RE") + source_cameras.append({ + "id": re_cam.id, + "role": re_cam.role, + "interface": getattr(re_cam, "interface", None), + "bayer_pattern": re_cam.bayer_pattern, + "bit_depth": int(re_cam.bit_depth), + }) + + if nir_cam is not None and nir_cam.id in raw_frames: + nir_packed, *_ = raw_frames[nir_cam.id] + if nir_packed.ndim == 3 and nir_packed.shape[2] == 1: + nir_packed = nir_packed[:, :, 0] + + rp_nir = self._ensure_raw_processor_for_camera(nir_cam.id) + nir_raw16 = rp_nir.unpack_raw10_packed(nir_packed) + nir_single = self._normalize_raw16_to_float(nir_raw16, nir_cam.bit_depth)[None, :, :] + spectral_parts.append(nir_single) + channel_names.append("NIR") + source_cameras.append({ + "id": nir_cam.id, + "role": nir_cam.role, + "interface": getattr(nir_cam, "interface", None), + "bayer_pattern": nir_cam.bayer_pattern, + "bit_depth": int(nir_cam.bit_depth), + }) + + if not spectral_parts: + raise RuntimeError("MULTISPEC requer pelo menos um canal espectral CSI além do RGB") + + arrays = [rgb_chw] + spectral_parts + + min_h = min(arr.shape[1] for arr in arrays) + min_w = min(arr.shape[2] for arr in arrays) + + arrays = [arr[:, :min_h, :min_w] for arr in arrays] + multispec = np.concatenate(arrays, axis=0) + multispec = self._convert_output_dtype(multispec) + + meta_extra = { + "frame_type": self.state.frame_type, + "output_dtype": self.state.output_dtype, + "dtype": str(multispec.dtype), + "output_layout": "CHW", + "output_channels": int(multispec.shape[0]), + "output_channel_names": channel_names, + "output_width": int(multispec.shape[2]), + "output_height": int(multispec.shape[1]), + "width": int(multispec.shape[2]), + "height": int(multispec.shape[1]), + "channels": int(multispec.shape[0]), + "payload_sources": [cam["id"] for cam in source_cameras], + "source_cameras": source_cameras, + "multispec_note": "Modo adaptativo: RGB USB + 1 ou 2 canais espectrais CSI", + } + + return multispec, meta_extra \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/main.py b/Python/raspi/cam_3/pi/main.py new file mode 100644 index 000000000..48c85c723 --- /dev/null +++ b/Python/raspi/cam_3/pi/main.py @@ -0,0 +1,5 @@ +from server import ModuleServer + +if __name__ == "__main__": + server = ModuleServer(host="0.0.0.0", port=5000) + server.start() diff --git a/Python/raspi/cam_3/pi/protocol.py b/Python/raspi/cam_3/pi/protocol.py new file mode 100644 index 000000000..7f8f9af8f --- /dev/null +++ b/Python/raspi/cam_3/pi/protocol.py @@ -0,0 +1,35 @@ +import json + + +MAX_MESSAGE_SIZE = 1024 * 1024 # 1 MB + + +def decode_message(raw_line: str) -> dict: + if raw_line is None: + raise ValueError("Mensagem ausente") + + if len(raw_line) > MAX_MESSAGE_SIZE: + raise ValueError("Mensagem excede tamanho máximo permitido") + + raw_line = raw_line.strip() + if not raw_line: + raise ValueError("Mensagem vazia") + + try: + data = json.loads(raw_line) + except json.JSONDecodeError as e: + raise ValueError(f"JSON inválido: {e.msg}") from e + + if not isinstance(data, dict): + raise ValueError("Mensagem JSON deve ser um objeto") + + return data + + +def encode_message(data: dict) -> bytes: + if not isinstance(data, dict): + raise ValueError("Resposta deve ser um objeto dict") + + return ( + json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n" + ).encode("utf-8") \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/raw_processor_core.py b/Python/raspi/cam_3/pi/raw_processor_core.py new file mode 100644 index 000000000..37206b999 --- /dev/null +++ b/Python/raspi/cam_3/pi/raw_processor_core.py @@ -0,0 +1,123 @@ +import numpy as np +import math +from typing import Optional + + +class RawProcessorCore: + def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"): + self.sensor_width = sensor_width + self.sensor_height = sensor_height + self.bayer_pattern = bayer_pattern.upper() + + def unpack_raw10_packed( + self, + packed_frame: np.ndarray, + sensor_width: Optional[int] = None, + sensor_height: Optional[int] = None + ): + if packed_frame.ndim == 3 and packed_frame.shape[2] == 1: + packed_frame = packed_frame[:, :, 0] + + width = sensor_width if sensor_width is not None else self.sensor_width + height = sensor_height if sensor_height is not None else self.sensor_height + + expected_packed_width = math.ceil(width * 10 / 8) + + actual_h, actual_w = packed_frame.shape[:2] + padding = actual_w - expected_packed_width + + if actual_h != height: + raise ValueError( + f"[ERRO FRAME] Altura packed inesperada: {packed_frame.shape}, " + f"esperado altura={height}" + ) + + if actual_w < expected_packed_width: + raise ValueError( + f"[ERRO FRAME] Largura packed menor que a útil esperada: {packed_frame.shape}, " + f"esperado pelo menos ({height}, {expected_packed_width})" + ) + + if padding > 64: + raise ValueError( + f"[ERRO FRAME] Padding excessivo no packed: {packed_frame.shape}, " + f"esperado útil ({height}, {expected_packed_width}), padding={padding}" + ) + + packed_frame = packed_frame[:, :expected_packed_width] + groups = packed_frame.reshape(height, width // 4, 5).astype(np.uint16) + + b0 = groups[:, :, 0] + b1 = groups[:, :, 1] + b2 = groups[:, :, 2] + b3 = groups[:, :, 3] + b4 = groups[:, :, 4] + + p0 = (b0 << 2) | ((b4 >> 0) & 0x03) + p1 = (b1 << 2) | ((b4 >> 2) & 0x03) + p2 = (b2 << 2) | ((b4 >> 4) & 0x03) + p3 = (b3 << 2) | ((b4 >> 6) & 0x03) + + raw16 = np.empty((height, width), dtype=np.uint16) + raw16[:, 0::4] = p0 + raw16[:, 1::4] = p1 + raw16[:, 2::4] = p2 + raw16[:, 3::4] = p3 + + return raw16 + + def extract_bayer_channels(self, raw16: np.ndarray) -> dict: + p = self.bayer_pattern + + if p == "GBRG": + g1 = raw16[0::2, 0::2] + b = raw16[0::2, 1::2] + r = raw16[1::2, 0::2] + g2 = raw16[1::2, 1::2] + elif p == "GRBG": + g1 = raw16[0::2, 0::2] + r = raw16[0::2, 1::2] + b = raw16[1::2, 0::2] + g2 = raw16[1::2, 1::2] + elif p == "RGGB": + r = raw16[0::2, 0::2] + g1 = raw16[0::2, 1::2] + g2 = raw16[1::2, 0::2] + b = raw16[1::2, 1::2] + elif p == "BGGR": + b = raw16[0::2, 0::2] + g1 = raw16[0::2, 1::2] + g2 = raw16[1::2, 0::2] + r = raw16[1::2, 1::2] + else: + raise ValueError(f"Padrão Bayer não suportado: {p}") + + return {"R": r, "G1": g1, "G2": g2, "B": b} + + def build_training_rgb( + self, + raw16: np.ndarray, + output_dtype: str = "float32", + bit_depth: int = 10, + ) -> np.ndarray: + ch = self.extract_bayer_channels(raw16) + + max_val = float((1 << bit_depth) - 1) + + r = ch["R"].astype(np.float32) / max_val + g = ((ch["G1"].astype(np.float32) + ch["G2"].astype(np.float32)) * 0.5) / max_val + b = ch["B"].astype(np.float32) / max_val + + chw = np.stack([r, g, b], axis=0).astype(np.float32) + chw = np.clip(chw, 0.0, 1.0) + + if output_dtype == "float32": + return chw + + if output_dtype == "uint8": + return (chw * 255.0).clip(0, 255).astype(np.uint8) + + if output_dtype == "uint16": + return (chw * 65535.0).clip(0, 65535).astype(np.uint16) + + raise ValueError(f"output_dtype não suportado: {output_dtype}") \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/raw_processor_preview.py b/Python/raspi/cam_3/pi/raw_processor_preview.py new file mode 100644 index 000000000..95ede77ad --- /dev/null +++ b/Python/raspi/cam_3/pi/raw_processor_preview.py @@ -0,0 +1,125 @@ +import cv2 +import numpy as np +from typing import Optional + + +class RawProcessorPreview: + def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"): + self.sensor_width = sensor_width + self.sensor_height = sensor_height + self.bayer_pattern = bayer_pattern.upper() + + def raw16_to_vis8( + self, raw16: np.ndarray, + black_level: Optional[int] = None, + white_level: Optional[int] = None, + gamma: float = 2.2, + bit_depth: int = 10 + ) -> np.ndarray: + """ + Conversão para visualização: + - auto-level + - gamma + """ + max_val = float((1 << bit_depth) - 1) + + raw = raw16.astype(np.float32) + + if black_level is None: + black_level = float(raw.min()) + if white_level is None: + white_level = float(raw.max()) + + if white_level <= black_level: + norm = raw / max_val + else: + norm = (raw - black_level) / (white_level - black_level) + + norm = np.clip(norm, 0.0, 1.0) + + if gamma is not None and gamma > 0: + norm = np.power(norm, 1.0 / gamma) + + return (norm * 255.0).clip(0, 255).astype(np.uint8) + + def _debayer_code(self): + mapping = { + # Troque de BayerGB para BayerGR para inverter R e B + "GBRG": cv2.COLOR_BayerGR2BGR, + "GRBG": cv2.COLOR_BayerGB2BGR, + "RGGB": cv2.COLOR_BayerBG2BGR, + "BGGR": cv2.COLOR_BayerRG2BGR, + } + if self.bayer_pattern not in mapping: + raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}") + return mapping[self.bayer_pattern] + + def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray: + """ + Gray-world simples para deixar o preview mais agradável. + Não usar no raw de treino. + """ + img = bgr.astype(np.float32) + + mean_b = float(img[:, :, 0].mean()) + mean_g = float(img[:, :, 1].mean()) + mean_r = float(img[:, :, 2].mean()) + + mean_gray = (mean_b + mean_g + mean_r) / 3.0 + + eps = 1e-6 + gain_b = mean_gray / max(mean_b, eps) + gain_g = mean_gray / max(mean_g, eps) + gain_r = mean_gray / max(mean_r, eps) + + # strength=1 aplica total, strength=0 não aplica + gain_b = 1.0 + (gain_b - 1.0) * strength + gain_g = 1.0 + (gain_g - 1.0) * strength + gain_r = 1.0 + (gain_r - 1.0) * strength + + img[:, :, 0] *= gain_b + img[:, :, 1] *= gain_g + img[:, :, 2] *= gain_r + + 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: + """ + Ajuste leve de contraste/brilho para preview. + """ + out = cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta) + return out + + def raw16_to_preview_bgr( + self, + raw16: np.ndarray, + gamma: float = 2.2, + wb_strength: float = 0.8, + apply_wb: bool = True, + apply_contrast: bool = True, + bit_depth: int = 10, + ) -> np.ndarray: + """ + Pipeline de preview bonito: + 1. auto-level + gamma no mosaico + 2. demosaic + 3. white balance simples + 4. leve contraste final + """ + vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth) + bgr = cv2.cvtColor(vis8, self._debayer_code()) + + if apply_wb: + bgr = self.apply_preview_white_balance(bgr, strength=wb_strength) + + if apply_contrast: + bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0) + + return bgr + + def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes: + bgr = self.raw16_to_preview_bgr(raw16) + ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)]) + if not ok: + raise RuntimeError("Falha ao codificar preview JPG") + return enc.tobytes() diff --git a/Python/raspi/cam_3/pi/server.py b/Python/raspi/cam_3/pi/server.py new file mode 100644 index 000000000..20438664b --- /dev/null +++ b/Python/raspi/cam_3/pi/server.py @@ -0,0 +1,654 @@ +import socket +import traceback +import threading + +from protocol import decode_message, encode_message +from state import ModuleState + + +def parse_bool(value): + if isinstance(value, bool): + return value + + if isinstance(value, (int, float)): + return bool(value) + + if isinstance(value, str): + s = value.strip().lower() + if s in ("1", "true", "yes", "on"): + return True + if s in ("0", "false", "no", "off"): + return False + + raise ValueError("Valor booleano inválido") + + +def parse_frame_type(value): + valid = {"RAW_BRUTO", "RGB", "MULTISPEC"} + if value not in valid: + raise ValueError(f"frame_type inválido: {value}") + return value + + +def parse_output_dtype(value): + valid = {"uint8", "uint16", "float32"} + if value not in valid: + raise ValueError(f"output_dtype inválido: {value}") + return value + + +def parse_capture_mode(value): + valid = {"AUTO", "SINGLE", "DOUBLE", "TRIPLE"} + if value not in valid: + raise ValueError(f"capture_mode inválido: {value}") + return value + + +def parse_bayer_pattern(value): + valid = {"GBRG", "GRBG", "RGGB", "BGGR"} + if value not in valid: + raise ValueError(f"bayer_pattern inválido: {value}") + return value + + +class ModuleServer: + def __init__(self, host="0.0.0.0", port=5000): + self.host = host + self.port = port + self.state = ModuleState() + self.running = False + self.lock = threading.RLock() + + from camera_manager import CameraManager + from trigger_manager import TriggerManager + from frame_service import FrameService + from stream_sender import StreamSender + + self.camera = CameraManager(self.state) + self.trigger = TriggerManager( + pin=self.state.trigger_pin, + active_high=self.state.trigger_active_high, + pulse_ms=self.state.trigger_pulse_ms + ) + self.frame_service = FrameService(self.state, self.trigger, self.camera) + self.stream_sender = StreamSender(self.state, self.frame_service) + + # ========================================================= + # Helpers internos + # ========================================================= + + def _mark_reconfigure_needed(self): + if hasattr(self.camera, "mark_reconfigure_needed"): + self.camera.mark_reconfigure_needed() + + def _is_reconfigure_needed(self): + return bool(getattr(self.camera, "_reconfigure_needed", False)) + + def _restart_module_if_needed(self): + """ + Reinicia trigger + câmeras se o módulo já estiver inicializado + e alguma configuração estrutural tiver mudado. + """ + if not self.state.initialized: + return + + if not self._is_reconfigure_needed(): + return + + if getattr(self.stream_sender, "is_running", False): + self.stream_sender.stop() + + self.camera.stop() + self.trigger.stop() + + self.trigger.begin() + ok = self.camera.begin() + if not ok: + raise RuntimeError("Falha ao reinicializar câmeras após reconfiguração") + + self.state.initialized = True + self.state.streaming = False + self.state.status = "ready" + self.state.status_detail = None + self.state.last_error = None + + if hasattr(self.camera, "_reconfigure_needed"): + self.camera._reconfigure_needed = False + + def _get_active_cameras(self): + return [cam for cam in self.state.cameras if cam.connected and cam.enabled] + + def _get_primary_camera(self): + """ + Preferência de câmera 'primária': + 1. RGB + 2. RE + 3. NIR + 4. primeira ativa + 5. primeira cadastrada + """ + for role in ("rgb", "re", "nir"): + cam = self.state.get_active_camera_by_role(role) + if cam is not None: + return cam + + active = self._get_active_cameras() + if active: + return active[0] + + if self.state.cameras: + return self.state.cameras[0] + + return None + + def _payload_dict(self): + payload = self.state.payload + return { + "payload_format_version": self.state.payload_format_version, + "frame_type": self.state.frame_type, + "output_dtype": self.state.output_dtype, + "output_layout": payload.layout, + "output_channels": payload.channels, + "output_channel_names": payload.channel_names, + "output_width": payload.width, + "output_height": payload.height, + "payload_sources": payload.sources, + "payload_complete": payload.complete, + } + + def _source_dict(self): + primary = self._get_primary_camera() + + if primary is None: + return { + "source_camera": None, + "source_width": 0, + "source_height": 0, + "source_bayer_pattern": None, + "source_bit_depth": None, + "source_cameras": [], + } + + source_cameras = [] + for cam_id in self.state.payload.sources: + cam = self.state.get_camera(cam_id) + if cam is None: + continue + + source_cameras.append({ + "id": cam.id, + "index": cam.index, + "role": cam.role, + "interface": cam.interface, + "width": cam.width, + "height": cam.height, + "bayer_pattern": cam.bayer_pattern, + "bit_depth": cam.bit_depth, + "connected": cam.connected, + "enabled": cam.enabled, + "available": cam.available, + "model": cam.model, + "serial": cam.serial, + }) + + return { + "source_camera": primary.id, + "source_width": primary.width, + "source_height": primary.height, + "source_bayer_pattern": primary.bayer_pattern, + "source_bit_depth": primary.bit_depth, + "source_cameras": source_cameras, + } + + def _build_config_response(self): + return { + "ok": True, + "module": self.state.module_name, + "version": self.state.version, + "status": self.state.status, + "initialized": self.state.initialized, + "streaming": self.state.streaming, + + "fps": self.state.fps, + "jpeg_quality": self.state.jpeg_quality, + + "capture_mode": self.state.capture_mode, + "detected_mode": self.state.detected_mode, + + "camera_count_detected": self.state.camera_count_detected, + "camera_count_active": self.state.camera_count_active, + "active_camera_ids": self.state.active_camera_ids, + + "has_re": self.state.has_re, + "has_nir": self.state.has_nir, + "has_rgb": self.state.has_rgb, + "has_multispec_pair": self.state.has_multispec_pair, + "has_full_multispec": self.state.has_full_multispec, + + "re_camera_id": self.state.re_camera_id, + "nir_camera_id": self.state.nir_camera_id, + "rgb_camera_id": self.state.rgb_camera_id, + + "cameras": [cam.to_dict() for cam in self.state.cameras], + + **self._payload_dict(), + **self._source_dict(), + } + + # ========================================================= + # Comandos + # ========================================================= + + def handle_command(self, msg: dict) -> dict: + with self.lock: + cmd = msg.get("cmd") + self.state.last_command = cmd + + try: + if cmd == "ping": + return {"ok": True, "reply": "pong"} + + if cmd == "get_status": + return {"ok": True, **self.state.to_dict()} + + if cmd == "get_config": + return self._build_config_response() + + if cmd == "begin": + frame_type = parse_frame_type(msg.get("frame_type", self.state.frame_type)) + output_dtype = parse_output_dtype(msg.get("output_dtype", self.state.output_dtype)) + capture_mode = parse_capture_mode(msg.get("capture_mode", self.state.capture_mode)) + + self.state.frame_type = frame_type + self.state.output_dtype = output_dtype + self.state.capture_mode = capture_mode + self.state.update_payload_spec() + + if self.state.initialized: + self._restart_module_if_needed() + return { + "ok": True, + "status": self.state.status, + "initialized": True, + **self._payload_dict(), + **self._source_dict(), + } + + self.state.status = "initializing" + self.state.status_detail = None + + self.trigger.begin() + ok = self.camera.begin() + if not ok: + raise RuntimeError("Falha ao inicializar câmeras") + + self.state.initialized = True + self.state.streaming = False + self.state.status = "ready" + self.state.last_error = None + + return { + "ok": True, + "status": self.state.status, + "initialized": True, + **self._payload_dict(), + **self._source_dict(), + } + + if cmd == "stop": + self.state.status = "stopping" + + if getattr(self.stream_sender, "is_running", False): + self.stream_sender.stop() + + self.camera.stop() + self.trigger.stop() + + self.state.initialized = False + self.state.streaming = False + self.state.status = "idle" + self.state.status_detail = None + + return {"ok": True, "status": self.state.status} + + if cmd == "capture_frame": + if not self.state.initialized: + return {"ok": False, "error": "Módulo não inicializado"} + + self._restart_module_if_needed() + + data = self.frame_service.capture_frame_base64() + return {"ok": True, **data} + + if cmd == "set_fps": + value = int(msg.get("value")) + if value <= 0 or value > 120: + return {"ok": False, "error": "fps inválido"} + + self.state.fps = value + self._mark_reconfigure_needed() + return {"ok": True, "fps": self.state.fps} + + if cmd == "set_jpeg_quality": + value = int(msg.get("value")) + if value < 1 or value > 100: + return {"ok": False, "error": "jpeg_quality inválido"} + + self.state.jpeg_quality = value + return {"ok": True, "jpeg_quality": self.state.jpeg_quality} + + if cmd == "set_frame_type": + frame_type = parse_frame_type(msg.get("value")) + self.state.frame_type = frame_type + self.state.update_payload_spec() + self._mark_reconfigure_needed() + + return { + "ok": True, + "frame_type": self.state.frame_type, + **self._payload_dict(), + } + + if cmd == "set_output_dtype": + output_dtype = parse_output_dtype(msg.get("value")) + self.state.output_dtype = output_dtype + self.state.update_payload_spec() + self._mark_reconfigure_needed() + + return { + "ok": True, + "output_dtype": self.state.output_dtype, + **self._payload_dict(), + } + + if cmd == "set_capture_mode": + capture_mode = parse_capture_mode(msg.get("value")) + self.state.capture_mode = capture_mode + self.state.update_payload_spec() + self._mark_reconfigure_needed() + + return { + "ok": True, + "capture_mode": self.state.capture_mode, + "detected_mode": self.state.detected_mode, + **self._payload_dict(), + } + + if cmd == "set_camera_enabled": + index = int(msg.get("index")) + enabled = parse_bool(msg.get("enabled")) + self.state.set_camera_enabled(index, enabled) + self._mark_reconfigure_needed() + + cam = self.state.get_camera_by_index(index) + return { + "ok": True, + "camera": cam.to_dict(), + "camera_count_active": self.state.camera_count_active, + "active_camera_ids": self.state.active_camera_ids, + **self._payload_dict(), + } + + if cmd == "set_camera_bayer": + index = int(msg.get("index")) + pattern = parse_bayer_pattern(msg.get("pattern")) + + cam = self.state.get_camera_by_index(index) + if cam is None: + return {"ok": False, "error": f"Câmera de índice {index} não existe"} + + cam.bayer_pattern = pattern + self.state.update_payload_spec() + self._mark_reconfigure_needed() + + return { + "ok": True, + "camera": cam.to_dict(), + **self._source_dict(), + } + + if cmd == "set_camera_resolution": + index = int(msg.get("index")) + width = int(msg.get("width")) + height = int(msg.get("height")) + + if width <= 0 or height <= 0: + return {"ok": False, "error": "resolução inválida"} + + cam = self.state.get_camera_by_index(index) + if cam is None: + return {"ok": False, "error": f"Câmera de índice {index} não existe"} + + cam.width = width + cam.height = height + self.state.update_payload_spec() + self._mark_reconfigure_needed() + + return { + "ok": True, + "camera": cam.to_dict(), + **self._payload_dict(), + **self._source_dict(), + } + + if cmd == "start_stream": + host = msg.get("host") + port = int(msg.get("port")) + fps = float(msg.get("fps", self.state.fps)) + + if not host: + return {"ok": False, "error": "host obrigatório"} + if port <= 0 or port > 65535: + return {"ok": False, "error": "porta inválida"} + if fps <= 0: + return {"ok": False, "error": "fps inválido"} + if not self.state.initialized: + return {"ok": False, "error": "Módulo não inicializado"} + + self._restart_module_if_needed() + + if getattr(self.stream_sender, "is_running", False): + return { + "ok": True, + "streaming": True, + "host": self.state.stream_host, + "port": self.state.stream_port, + "fps": self.state.stream_fps, + **self._payload_dict(), + **self._source_dict(), + } + + self.stream_sender.start(host, port, fps) + self.state.streaming = True + self.state.stream_host = host + self.state.stream_port = port + self.state.stream_fps = fps + self.state.status = "streaming" + + return { + "ok": True, + "streaming": True, + "host": host, + "port": port, + "fps": fps, + **self._payload_dict(), + **self._source_dict(), + } + + if cmd == "stop_stream": + if getattr(self.stream_sender, "is_running", False): + self.stream_sender.stop() + + self.state.streaming = False + self.state.stream_host = None + self.state.stream_port = None + self.state.stream_fps = None + self.state.status = "ready" if self.state.initialized else "idle" + + return { + "ok": True, + "streaming": False, + "status": self.state.status + } + + if cmd == "set_ae_enable": + self.state.ae_enable = parse_bool(msg.get("value")) + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "ae_enable": self.state.ae_enable} + + if cmd == "set_awb_enable": + self.state.awb_enable = parse_bool(msg.get("value")) + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "awb_enable": self.state.awb_enable} + + if cmd == "set_exposure_time": + value = msg.get("value") + if value is not None: + value = int(value) + if value <= 0: + return {"ok": False, "error": "ExposureTime inválido"} + + self.state.exposure_time_us = value + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "exposure_time_us": self.state.exposure_time_us} + + if cmd == "set_analogue_gain": + value = msg.get("value") + if value is not None: + value = float(value) + if value <= 0: + return {"ok": False, "error": "AnalogueGain inválido"} + + self.state.analogue_gain = value + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "analogue_gain": self.state.analogue_gain} + + if cmd == "set_colour_gains": + r_gain = msg.get("r_gain") + b_gain = msg.get("b_gain") + + if r_gain is None or b_gain is None: + return {"ok": False, "error": "r_gain e b_gain são obrigatórios"} + + r_gain = float(r_gain) + b_gain = float(b_gain) + + if r_gain <= 0 or b_gain <= 0: + return {"ok": False, "error": "ColourGains inválidos"} + + self.state.colour_gains = [r_gain, b_gain] + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "colour_gains": self.state.colour_gains} + + if cmd == "clear_exposure_time": + self.state.exposure_time_us = None + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "exposure_time_us": None} + + if cmd == "clear_analogue_gain": + self.state.analogue_gain = None + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "analogue_gain": None} + + if cmd == "clear_colour_gains": + self.state.colour_gains = None + if self.camera.initialized: + self.camera.apply_controls() + return {"ok": True, "colour_gains": None} + + if cmd == "get_camera_controls": + return { + "ok": True, + "ae_enable": self.state.ae_enable, + "awb_enable": self.state.awb_enable, + "exposure_time_us": self.state.exposure_time_us, + "analogue_gain": self.state.analogue_gain, + "colour_gains": self.state.colour_gains, + "fps": self.state.fps, + } + + if cmd == "get_sensor_modes": + modes = self.camera.get_sensor_modes() + return {"ok": True, "sensor_modes": modes} + + return {"ok": False, "error": f"Comando desconhecido: {cmd}"} + + except Exception as e: + self.state.set_error(str(e)) + return {"ok": False, "error": str(e)} + + # ========================================================= + # Rede TCP + # ========================================================= + + def client_thread(self, conn, addr): + print(f"[INFO] Cliente conectado: {addr}") + buffer = b"" + + try: + conn.settimeout(1.0) + + while self.running: + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + + if not chunk: + break + + buffer += chunk + + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + if not line.strip(): + continue + + try: + msg = decode_message(line.decode("utf-8")) + response = self.handle_command(msg) + except Exception as e: + response = {"ok": False, "error": str(e)} + + conn.sendall(encode_message(response)) + + except Exception as e: + print(f"[ERRO] Cliente {addr}: {e}") + traceback.print_exc() + finally: + try: + conn.close() + except Exception: + pass + print(f"[INFO] Cliente desconectado: {addr}") + + def start(self): + self.running = True + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket: + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind((self.host, self.port)) + server_socket.listen(5) + server_socket.settimeout(1.0) + + print(f"[INFO] Servidor ouvindo em {self.host}:{self.port}") + + while self.running: + try: + conn, addr = server_socket.accept() + except socket.timeout: + continue + + t = threading.Thread( + target=self.client_thread, + args=(conn, addr), + daemon=True + ) + t.start() \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/state.py b/Python/raspi/cam_3/pi/state.py new file mode 100644 index 000000000..17af09a66 --- /dev/null +++ b/Python/raspi/cam_3/pi/state.py @@ -0,0 +1,621 @@ +from dataclasses import dataclass, field, asdict +from typing import List, Optional, Dict, Any + + +@dataclass +class CameraSpec: + id: str + index: int + role: str = "generic" # re | nir | rgb | generic + interface: str = "CSI" # CSI | USB + + connected: bool = False + enabled: bool = True + available: bool = False + + width: int = 640 + height: int = 480 + bayer_pattern: str = "GBRG" + bit_depth: int = 10 + + model: Optional[str] = None + serial: Optional[str] = None + + # Novos campos + device_path: Optional[str] = None # ex: /dev/video0 + usb_backend: str = "V4L2" # V4L2 | DEFAULT + preferred: bool = True # ajuda em resolução de conflitos + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class PayloadSpec: + frame_type: str = "RAW_BRUTO" # RAW_BRUTO | RGB | MULTISPEC + dtype: str = "uint8" # uint8 | uint16 | float32 + layout: str = "HW" # HW | CHW | MULTI_HW + channels: int = 1 + channel_names: List[str] = field(default_factory=lambda: ["BAYER"]) + width: int = 640 + height: int = 480 + sources: List[str] = field(default_factory=lambda: ["cam2"]) + complete: bool = False + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +class ModuleState: + def __init__(self): + self.module_name = "multispectral" + self.version = "0.3.0" + self.payload_format_version = 3 + + self.status = "idle" + self.status_detail = None + self.last_error = None + self.last_command = None + + self.initialized = False + self.streaming = False + + # Estratégia do módulo + # AUTO = decide com base nas câmeras disponíveis + # SINGLE = usa apenas uma câmera relevante para o frame_type + # DOUBLE = exige a camera RGB + 1 camera CSI + # TRIPLE = exige o conjunto completo + self.capture_mode = "AUTO" # AUTO | SINGLE | DOUBLE | TRIPLE + self.detected_mode = "NONE" # NONE | PARTIAL | RGB_ONLY | MS_ONLY | DOUBLE | TRIPLE + self.multi_camera_enabled = True + self.max_cameras = 3 + + # Configuração global base + self.fps = 10 + self.jpeg_quality = 90 + + # Frame desejado de saída + self.frame_type = "RAW_BRUTO" # RAW_BRUTO | RGB | MULTISPEC + self.output_dtype = "uint8" # uint8 | uint16 | float32 + + # Lista fixa das câmeras do módulo + # cam0 = RE (CSI) + # cam1 = NIR (CSI) + # cam2 = RGB (USB) + self.cameras: List[CameraSpec] = [ + CameraSpec( + id="cam0", + index=0, + role="re", + interface="CSI", + bayer_pattern="GBRG", + bit_depth=10, + ), + CameraSpec( + id="cam1", + index=1, + role="nir", + interface="CSI", + bayer_pattern="GBRG", + bit_depth=10, + ), + CameraSpec( + id="cam2", + index=2, + role="rgb", + interface="USB", + bayer_pattern="GBRG", # pode ficar por compatibilidade, embora USB RGB não use isso + bit_depth=8, # melhor refletir a realidade da USB + device_path="/dev/v4l/by-id/usb-Sonix_Technology_Co.__Ltd._USB_2.0_Camera_SN0001-video-index0", + usb_backend="V4L2", + ), + ] + + # Resumo dinâmico da topologia + self.camera_count_detected = 0 + self.camera_count_active = 0 + self.active_camera_ids: List[str] = [] + + self.re_camera_id = "cam0" + self.nir_camera_id = "cam1" + self.rgb_camera_id = "cam2" + + self.has_re = False + self.has_nir = False + self.has_rgb = False + self.has_multispec_pair = False + self.has_full_multispec = False + + # Spec do payload atual + self.payload = PayloadSpec() + + # Trigger + # Idealmente aplicado às duas CSI (RE e NIR) + self.trigger_enabled = True + self.trigger_pin = 18 + self.trigger_active_high = True + self.trigger_pulse_ms = 5.0 + self.trigger_settle_delay_ms = 0.0 + + # Streaming + self.stream_host = None + self.stream_port = None + self.stream_fps = None + self.stream_frame_id = 0 + + # Codec + self.codec_family = "none" # numcodecs + self.codec_name = "blosc" + self.codec_params = { + "cname": "zstd", + "clevel": 3, + "shuffle": "BITSHUFFLE", + } + + # Controle das câmeras + self.ae_enable = True + self.awb_enable = True + self.exposure_time_us = 15000 + self.analogue_gain = 1.0 + self.colour_gains = [1.0, 1.0] + self.frame_duration_limits = None + + # Inicialização + self.refresh_topology() + self.update_payload_spec() + + # ========================================================= + # Helpers de câmera + # ========================================================= + + def get_camera(self, cam_id: str) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.id == cam_id: + return cam + return None + + def get_camera_by_index(self, index: int) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.index == index: + return cam + return None + + def get_camera_by_role(self, role: str) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.role == role: + return cam + return None + + def get_camera_by_device_path(self, device_path: str) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.device_path == device_path: + return cam + return None + + def get_active_camera_by_role(self, role: str) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.role == role and cam.connected and cam.enabled: + return cam + return None + + def get_enabled_camera_by_role(self, role: str) -> Optional[CameraSpec]: + for cam in self.cameras: + if cam.role == role and cam.enabled: + return cam + return None + + def get_required_camera_ids_for_frame_type(self) -> List[str]: + + def get_all_enabled_camera_ids(): + return [cam.id for cam in self.cameras if cam.enabled] + + if self.frame_type == "RGB": + cam = get_enabled_camera_by_role("rgb") + return [cam.id] if cam else [] + + if self.frame_type == "MULTISPEC": + ids = [] + re_cam = get_enabled_camera_by_role("re") + nir_cam = get_enabled_camera_by_role("nir") + rgb_cam = get_enabled_camera_by_role("rgb") + + if re_cam: + ids.append(re_cam.id) + if nir_cam: + ids.append(nir_cam.id) + if rgb_cam: + ids.append(rgb_cam.id) + + return ids + + if self.frame_type == "RAW_BRUTO": + resolved = self.resolve_capture_mode() + + if resolved == "TRIPLE": + return [cam.id for cam in self.cameras if cam.enabled] + + if resolved == "DOUBLE": + ids = [] + rgb_cam = self.get_enabled_camera_by_role("rgb") + re_cam = self.get_enabled_camera_by_role("re") + nir_cam = self.get_enabled_camera_by_role("nir") + + if rgb_cam: + ids.append(rgb_cam.id) + + if re_cam: + ids.append(re_cam.id) + elif nir_cam: + ids.append(nir_cam.id) + + return ids + + if resolved == "SINGLE": + for role in ("rgb", "re", "nir"): + cam = self.get_enabled_camera_by_role(role) + if cam: + return [cam.id] + + return [] + + return [] + + def set_camera_device_path(self, index: int, device_path: Optional[str]) -> None: + cam = self.get_camera_by_index(index) + if cam is None: + raise ValueError(f"Câmera de índice {index} não existe.") + + cam.device_path = device_path + + def set_camera_usb_backend(self, index: int, usb_backend: str) -> None: + cam = self.get_camera_by_index(index) + if cam is None: + raise ValueError(f"Câmera de índice {index} não existe.") + + cam.usb_backend = usb_backend + + # ========================================================= + # Topologia / câmeras + # ========================================================= + + def set_camera_connected( + self, + index: int, + connected: bool, + *, + width: Optional[int] = None, + height: Optional[int] = None, + bayer_pattern: Optional[str] = None, + bit_depth: Optional[int] = None, + model: Optional[str] = None, + serial: Optional[str] = None, + ) -> None: + cam = self.get_camera_by_index(index) + if cam is None: + raise ValueError(f"Câmera de índice {index} não existe.") + + cam.connected = connected + cam.available = connected and cam.enabled + + if width is not None: + cam.width = width + if height is not None: + cam.height = height + if bayer_pattern is not None: + cam.bayer_pattern = bayer_pattern + if bit_depth is not None: + cam.bit_depth = bit_depth + if model is not None: + cam.model = model + if serial is not None: + cam.serial = serial + + self.refresh_topology() + self.update_payload_spec() + + def set_camera_enabled(self, index: int, enabled: bool) -> None: + cam = self.get_camera_by_index(index) + if cam is None: + raise ValueError(f"Câmera de índice {index} não existe.") + + cam.enabled = enabled + cam.available = cam.connected and cam.enabled + + self.refresh_topology() + self.update_payload_spec() + + def refresh_topology(self) -> None: + detected = [cam for cam in self.cameras if cam.connected] + active = [cam for cam in self.cameras if cam.connected and cam.enabled] + + self.camera_count_detected = len(detected) + self.camera_count_active = len(active) + self.active_camera_ids = [cam.id for cam in active] + + re_cam = self.get_active_camera_by_role("re") + nir_cam = self.get_active_camera_by_role("nir") + rgb_cam = self.get_active_camera_by_role("rgb") + + self.has_re = re_cam is not None + self.has_nir = nir_cam is not None + self.has_rgb = rgb_cam is not None + self.has_multispec_pair = self.has_re and self.has_nir + self.has_full_multispec = self.has_re and self.has_nir and self.has_rgb + + if self.has_full_multispec and self.multi_camera_enabled: + self.detected_mode = "TRIPLE" + elif self.has_rgb and (self.has_re or self.has_nir): + self.detected_mode = "DOUBLE" + elif self.has_multispec_pair and not self.has_rgb: + self.detected_mode = "MS_ONLY" + elif self.has_rgb and not self.has_multispec_pair: + self.detected_mode = "RGB_ONLY" + elif self.camera_count_active > 0: + self.detected_mode = "PARTIAL" + else: + self.detected_mode = "NONE" + + def resolve_capture_mode(self) -> str: + enabled_re = self.get_enabled_camera_by_role("re") is not None + enabled_nir = self.get_enabled_camera_by_role("nir") is not None + enabled_rgb = self.get_enabled_camera_by_role("rgb") is not None + + if self.capture_mode == "AUTO": + if enabled_rgb and enabled_re and enabled_nir and self.multi_camera_enabled: + return "TRIPLE" + if enabled_rgb and (enabled_re or enabled_nir): + return "DOUBLE" + if enabled_rgb or enabled_re or enabled_nir: + return "SINGLE" + return "NONE" + + if self.capture_mode == "TRIPLE": + return "TRIPLE" if (enabled_rgb and enabled_re and enabled_nir and self.multi_camera_enabled) else "NONE" + + if self.capture_mode == "DOUBLE": + return "DOUBLE" if (enabled_rgb and (enabled_re or enabled_nir)) else "NONE" + + if self.capture_mode == "SINGLE": + return "SINGLE" if (enabled_rgb or enabled_re or enabled_nir) else "NONE" + + return "NONE" + + # ========================================================= + # Payload + # ========================================================= + + def update_payload_spec(self) -> None: + resolved_mode = self.resolve_capture_mode() + + if resolved_mode == "NONE": + self.payload = PayloadSpec( + frame_type=self.frame_type, + dtype=self.output_dtype, + layout="HW", + channels=0, + channel_names=[], + width=0, + height=0, + sources=[], + complete=False, + ) + return + + rgb_cam = self.get_active_camera_by_role("rgb") + re_cam = self.get_active_camera_by_role("re") + nir_cam = self.get_active_camera_by_role("nir") + + if self.frame_type == "RAW_BRUTO": + sources = self.get_required_camera_ids_for_frame_type() + + if len(sources) == 1: + cam = self.get_camera(sources[0]) + self.payload = PayloadSpec( + frame_type="RAW_BRUTO", + dtype=self.output_dtype, + layout="HW", + channels=1, + channel_names=["BAYER"], + width=cam.width if cam else 0, + height=cam.height if cam else 0, + sources=sources, + complete=(cam is not None), + ) + return + + if len(sources) >= 2: + # para 3 câmeras usamos MULTI_HW + # cada origem será empacotada separadamente + ref_cam = self.get_camera(sources[0]) if sources else None + self.payload = PayloadSpec( + frame_type="RAW_BRUTO", + dtype=self.output_dtype, + layout="MULTI_HW", + channels=len(sources), + channel_names=[f"BAYER_{cam_id.upper()}" for cam_id in sources], + width=ref_cam.width if ref_cam else 0, + height=ref_cam.height if ref_cam else 0, + sources=sources, + complete=True, + ) + return + + self.payload = PayloadSpec( + frame_type="RAW_BRUTO", + dtype=self.output_dtype, + layout="HW", + channels=0, + channel_names=[], + width=0, + height=0, + sources=[], + complete=False, + ) + return + + if self.frame_type == "RGB": + if rgb_cam is None: + self.payload = PayloadSpec( + frame_type="RGB", + dtype=self.output_dtype, + layout="CHW", + channels=0, + channel_names=[], + width=0, + height=0, + sources=[], + complete=False, + ) + return + + self.payload = PayloadSpec( + frame_type="RGB", + dtype=self.output_dtype, + layout="CHW", + channels=3, + channel_names=["R", "G", "B"], + width=rgb_cam.width // 2, + height=rgb_cam.height // 2, + sources=[rgb_cam.id], + complete=True, + ) + return + + if self.frame_type == "MULTISPEC": + resolved = self.resolve_capture_mode() + + if resolved == "TRIPLE" and rgb_cam and re_cam and nir_cam: + out_w = min(rgb_cam.width, re_cam.width // 2, nir_cam.width // 2) + out_h = min(rgb_cam.height, re_cam.height, nir_cam.height) + + self.payload = PayloadSpec( + frame_type="MULTISPEC", + dtype=self.output_dtype, + layout="CHW", + channels=5, + channel_names=["R", "G", "B", "RE", "NIR"], + width=out_w, + height=out_h, + sources=[rgb_cam.id, re_cam.id, nir_cam.id], + complete=True, + ) + return + + if resolved == "DOUBLE" and rgb_cam and (re_cam or nir_cam): + ms_cam = re_cam if re_cam is not None else nir_cam + ms_name = "RE" if re_cam is not None else "NIR" + + out_w = min(rgb_cam.width, ms_cam.width // 2) + out_h = min(rgb_cam.height, ms_cam.height) + + self.payload = PayloadSpec( + frame_type="MULTISPEC", + dtype=self.output_dtype, + layout="CHW", + channels=4, + channel_names=["R", "G", "B", ms_name], + width=out_w, + height=out_h, + sources=[rgb_cam.id, ms_cam.id], + complete=True, + ) + return + + self.payload = PayloadSpec( + frame_type="MULTISPEC", + dtype=self.output_dtype, + layout="CHW", + channels=0, + channel_names=[], + width=0, + height=0, + sources=[], + complete=False, + ) + return + + raise ValueError(f"frame_type inválido: {self.frame_type}") + + # ========================================================= + # Estado / erro + # ========================================================= + + def clear_error(self) -> None: + self.last_error = None + if self.status == "error": + self.status = "idle" + self.status_detail = None + + def set_error(self, error: str) -> None: + self.last_error = str(error) + self.status = "error" + self.status_detail = str(error) + + # ========================================================= + # Serialização + # ========================================================= + + def to_dict(self) -> Dict[str, Any]: + return { + "module": self.module_name, + "version": self.version, + + "status": self.status, + "status_detail": self.status_detail, + "last_error": self.last_error, + "last_command": self.last_command, + + "initialized": self.initialized, + "streaming": self.streaming, + + "capture_mode": self.capture_mode, + "detected_mode": self.detected_mode, + "multi_camera_enabled": self.multi_camera_enabled, + "max_cameras": self.max_cameras, + + "camera_count_detected": self.camera_count_detected, + "camera_count_active": self.camera_count_active, + "active_camera_ids": self.active_camera_ids, + + "has_re": self.has_re, + "has_nir": self.has_nir, + "has_rgb": self.has_rgb, + "has_multispec_pair": self.has_multispec_pair, + "has_full_multispec": self.has_full_multispec, + + "re_camera_id": self.re_camera_id, + "nir_camera_id": self.nir_camera_id, + "rgb_camera_id": self.rgb_camera_id, + + "cameras": [cam.to_dict() for cam in self.cameras], + + "fps": self.fps, + "jpeg_quality": self.jpeg_quality, + + "trigger_enabled": self.trigger_enabled, + "trigger_pin": self.trigger_pin, + "trigger_active_high": self.trigger_active_high, + "trigger_pulse_ms": self.trigger_pulse_ms, + "trigger_settle_delay_ms": self.trigger_settle_delay_ms, + + "stream_host": self.stream_host, + "stream_port": self.stream_port, + "stream_fps": self.stream_fps, + "stream_frame_id": self.stream_frame_id, + + "codec_family": self.codec_family, + "codec_name": self.codec_name, + "codec_params": self.codec_params, + + "ae_enable": self.ae_enable, + "awb_enable": self.awb_enable, + "exposure_time_us": self.exposure_time_us, + "analogue_gain": self.analogue_gain, + "colour_gains": self.colour_gains, + "frame_duration_limits": self.frame_duration_limits, + + "payload_format_version": self.payload_format_version, + "frame_type": self.frame_type, + "output_dtype": self.output_dtype, + "payload": self.payload.to_dict(), + } \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/stream_sender.py b/Python/raspi/cam_3/pi/stream_sender.py new file mode 100644 index 000000000..0ff310499 --- /dev/null +++ b/Python/raspi/cam_3/pi/stream_sender.py @@ -0,0 +1,469 @@ +import json +import socket +import threading +import time +import queue +from numcodecs import Blosc + + +class StreamSender: + _QUEUE_SENTINEL = object() + + def __init__(self, state, frame_service): + self.state = state + self.frame_service = frame_service + + self._lock = threading.RLock() + self._stop_event = threading.Event() + + self._sock = None + self._thread_capture = None + self._thread_send = None + + self._queue = queue.Queue(maxsize=2) + + self._codec = None + self._codec_signature = None + + self._t_json_pack = 0.0 + self._t_send_header = 0.0 + self._t_send_payload = 0.0 + + self._frames_dropped = 0 + self._capture_errors = 0 + self._send_errors = 0 + self._last_sent_capture_signature = None + + @property + def is_running(self): + return not self._stop_event.is_set() and ( + self._thread_capture is not None or self._thread_send is not None + ) + + # ========================================================= + # Lifecycle + # ========================================================= + + def start(self, host: str, port: int, fps: float): + with self._lock: + if self.is_running: + raise RuntimeError("Stream já está em execução") + + if not self.state.initialized: + raise RuntimeError("Módulo não inicializado") + + self._stop_event.clear() + self._clear_queue() + + self._t_json_pack = 0.0 + self._t_send_header = 0.0 + self._t_send_payload = 0.0 + self._frames_dropped = 0 + self._capture_errors = 0 + self._send_errors = 0 + + self.state.streaming = True + self.state.stream_host = host + self.state.stream_port = port + self.state.stream_fps = fps + self._last_sent_capture_signature = None + + self._thread_capture = threading.Thread( + target=self._worker_capture, + args=(fps,), + daemon=True + ) + self._thread_send = threading.Thread( + target=self._worker_send, + args=(host, port), + daemon=True + ) + + self._thread_capture.start() + self._thread_send.start() + + def stop(self): + with self._lock: + self._stop_event.set() + + try: + self._queue.put_nowait(self._QUEUE_SENTINEL) + except queue.Full: + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait(self._QUEUE_SENTINEL) + except queue.Full: + pass + + sock = self._sock + self._sock = None + + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + sock.close() + except Exception: + pass + + if self._thread_capture is not None: + self._thread_capture.join(timeout=2.0) + self._thread_capture = None + + if self._thread_send is not None: + self._thread_send.join(timeout=2.0) + self._thread_send = None + + self._clear_queue() + + self.state.streaming = False + self.state.stream_host = None + self.state.stream_port = None + self.state.stream_fps = None + + def _clear_queue(self): + while not self._queue.empty(): + try: + self._queue.get_nowait() + except queue.Empty: + break + + # ========================================================= + # Codec + # ========================================================= + + def _normalize_shuffle(self, shuffle_value): + if isinstance(shuffle_value, int): + return shuffle_value + + mapping = { + "NOSHUFFLE": Blosc.NOSHUFFLE, + "SHUFFLE": Blosc.SHUFFLE, + "BITSHUFFLE": Blosc.BITSHUFFLE, + } + + key = str(shuffle_value).upper() + if key not in mapping: + raise ValueError(f"shuffle inválido: {shuffle_value}") + + return mapping[key] + + def _build_codec_from_state(self): + family = self.state.codec_family + name = self.state.codec_name + params = dict(self.state.codec_params) + + if family == "none": + return None + + if family != "numcodecs": + raise ValueError(f"Família de codec não suportada: {family}") + + if name == "blosc": + params["shuffle"] = self._normalize_shuffle(params.get("shuffle", "SHUFFLE")) + return Blosc(**params) + + raise ValueError(f"Codec numcodecs não suportado: {name}") + + def _get_codec_signature_from_state(self): + return ( + self.state.codec_family, + self.state.codec_name, + tuple(sorted(self.state.codec_params.items())) + ) + + def _ensure_codec(self): + sig = self._get_codec_signature_from_state() + if self._codec is None or self._codec_signature != sig: + self._codec = self._build_codec_from_state() + self._codec_signature = sig + + def _compress(self, frame_bytes: bytes) -> bytes: + self._ensure_codec() + + if self.state.codec_family == "none": + return frame_bytes + + return self._codec.encode(frame_bytes) + + # ========================================================= + # Packet helpers + # ========================================================= + + def _send_packet(self, sock: socket.socket, header: dict, payload: bytes): + t0 = time.perf_counter() + header_bytes = json.dumps(header, separators=(",", ":")).encode("utf-8") + t1 = time.perf_counter() + + sock.sendall(len(header_bytes).to_bytes(4, "big")) + sock.sendall(header_bytes) + sock.sendall(len(payload).to_bytes(4, "big")) + t2 = time.perf_counter() + + sock.sendall(payload) + t3 = time.perf_counter() + + self._t_json_pack = t1 - t0 + self._t_send_header = t2 - t1 + self._t_send_payload = t3 - t2 + + def _build_capture_signature(self, meta): + camera_frames = meta.get("camera_frames", {}) + if not camera_frames: + return None + + items = [] + for cam_id in sorted(camera_frames.keys()): + items.append((cam_id, camera_frames[cam_id].get("camera_frame_id"))) + return tuple(items) + + def _describe_single_array(self, frame, meta): + return { + "multi_payload": False, + "payload_kind": "single_array", + "payload_parts": [ + { + "kind": "single_array", + "size_raw": int(frame.nbytes), + "dtype": str(frame.dtype), + "shape": list(frame.shape), + "layout": meta.get("output_layout"), + "channel_names": meta.get("output_channel_names"), + } + ], + } + + def _describe_multi_array_part(self, cam_id, arr, cam_meta): + return { + "camera_id": cam_id, + "size_raw": int(arr.nbytes), + "dtype": str(arr.dtype), + "shape": list(arr.shape), + "width": cam_meta.get("width"), + "height": cam_meta.get("height"), + "channels": cam_meta.get("channels"), + "role": cam_meta.get("role"), + "interface": cam_meta.get("interface"), + } + + def _serialize_frame_payload(self, frame, meta): + """ + Para array único: + payload = bytes diretos do array + Para dict de arrays: + payload = repetição de: + [4 bytes tamanho][bytes da parte] + """ + if not isinstance(frame, dict): + frame_bytes = frame.tobytes() + payload_meta = self._describe_single_array(frame, meta) + return frame_bytes, payload_meta + + payload = bytearray() + payload_parts = [] + + camera_frames_meta = meta.get("camera_frames", {}) + + for cam_id in meta.get("payload_sources", list(frame.keys())): + arr = frame[cam_id] + part_bytes = arr.tobytes() + + payload.extend(len(part_bytes).to_bytes(4, "big")) + payload.extend(part_bytes) + + payload_parts.append( + self._describe_multi_array_part( + cam_id, + arr, + camera_frames_meta.get(cam_id, {}) + ) + ) + + payload_meta = { + "multi_payload": True, + "payload_kind": "multi_array", + "payload_parts": payload_parts, + } + return bytes(payload), payload_meta + + def _build_stream_header(self, meta, raw_payload, comp_bytes, payload_meta, dt_bytes, dt_comp, dt_frame_period): + codec_name = None if self.state.codec_family == "none" else self.state.codec_name + codec_params = {} if self.state.codec_family == "none" else dict(self.state.codec_params) + + header = { + "frame_id": meta.get("frame_id"), + + "module": self.state.module_name, + "module_version": self.state.version, + + "frame_type": meta.get("frame_type"), + "payload_format_version": meta.get("payload_format_version"), + "capture_mode_resolved": meta.get("capture_mode_resolved"), + + "output_dtype": meta.get("output_dtype"), + "output_layout": meta.get("output_layout"), + "output_channels": meta.get("output_channels"), + "output_channel_names": meta.get("output_channel_names"), + "output_width": meta.get("output_width"), + "output_height": meta.get("output_height"), + + "payload_sources": meta.get("payload_sources"), + "camera_frames": meta.get("camera_frames"), + + "codec_family": self.state.codec_family, + "codec_name": codec_name, + "codec_params": codec_params, + + "payload_size_raw": len(raw_payload), + "payload_size_comp": len(comp_bytes), + + "dt_frame_period": dt_frame_period, + "dt_bytes": dt_bytes, + "dt_comp": dt_comp, + + "dt_trigger": meta.get("dt_trigger"), + "dt_settle": meta.get("dt_settle"), + "dt_capture": meta.get("dt_capture"), + "dt_process": meta.get("dt_process"), + "dt_total_pi": meta.get("dt_total_pi"), + + "ts_pi": meta.get("ts_pi"), + "ts_pi_monotonic": meta.get("ts_pi_monotonic"), + + **payload_meta, + } + + # carrega o restante do meta sem sobrescrever campos já consolidados + reserved = set(header.keys()) + for k, v in meta.items(): + if k not in reserved: + header[k] = v + + return header + + # ========================================================= + # Workers + # ========================================================= + + def _worker_capture(self, fps: float): + frame_interval = 1.0 / fps if fps > 0 else 0.0 + next_deadline = time.perf_counter() + last_frame_ts = None + + while not self._stop_event.is_set(): + try: + frame, meta = self.frame_service.capture_frame_raw() + except Exception as e: + self._capture_errors += 1 + print(f"[WARN] Capture falhou: {e}") + time.sleep(0.05) + continue + + if frame is None: + time.sleep(0.01) + continue + + capture_signature = self._build_capture_signature(meta) + if capture_signature is not None and capture_signature == self._last_sent_capture_signature: + time.sleep(0.001) + continue + + t_frame_ready = time.perf_counter() + dt_frame_period = 0.0 if last_frame_ts is None else (t_frame_ready - last_frame_ts) + last_frame_ts = t_frame_ready + + t_bytes0 = time.perf_counter() + raw_payload, payload_meta = self._serialize_frame_payload(frame, meta) + t_bytes1 = time.perf_counter() + + t_comp0 = time.perf_counter() + comp_bytes = self._compress(raw_payload) + t_comp1 = time.perf_counter() + + header = self._build_stream_header( + meta=meta, + raw_payload=raw_payload, + comp_bytes=comp_bytes, + payload_meta=payload_meta, + dt_bytes=(t_bytes1 - t_bytes0), + dt_comp=(t_comp1 - t_comp0), + dt_frame_period=dt_frame_period, + ) + + queued = False + try: + self._queue.put_nowait((header, comp_bytes)) + queued = True + except queue.Full: + self._frames_dropped += 1 + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait((header, comp_bytes)) + queued = True + except queue.Full: + self._frames_dropped += 1 + + if queued: + self._last_sent_capture_signature = capture_signature + + if frame_interval > 0: + next_deadline += frame_interval + now = time.perf_counter() + + if next_deadline < now - frame_interval: + next_deadline = now + + sleep_time = next_deadline - now + if sleep_time > 0: + time.sleep(sleep_time) + + def _worker_send(self, host: str, port: int): + try: + with socket.create_connection((host, port), timeout=5) as sock: + sock.settimeout(10) + with self._lock: + self._sock = sock + + while not self._stop_event.is_set(): + try: + item = self._queue.get(timeout=0.2) + except queue.Empty: + continue + + if item is self._QUEUE_SENTINEL: + break + + header, payload = item + + header["dt_pack_prev"] = self._t_json_pack + header["dt_send_header_prev"] = self._t_send_header + header["dt_send_payload_prev"] = self._t_send_payload + header["stream_drops"] = self._frames_dropped + header["capture_errors"] = self._capture_errors + header["send_errors"] = self._send_errors + + self._send_packet(sock, header, payload) + self.state.stream_frame_id = header["frame_id"] + + except Exception as e: + self._send_errors += 1 + if not self._stop_event.is_set(): + print(f"[WARN] StreamSender encerrado com erro: {e}") + finally: + with self._lock: + self._sock = None + + self.state.streaming = False + self.state.stream_host = None + self.state.stream_port = None + self.state.stream_fps = None + + self._stop_event.set() \ No newline at end of file diff --git a/Python/raspi/cam_3/pi/trigger_manager.py b/Python/raspi/cam_3/pi/trigger_manager.py new file mode 100644 index 000000000..c98c052b6 --- /dev/null +++ b/Python/raspi/cam_3/pi/trigger_manager.py @@ -0,0 +1,74 @@ +import time +import threading +import RPi.GPIO as GPIO + + +class TriggerManager: + def __init__(self, pin: int, active_high: bool = True, pulse_ms: float = 5.0): + if pin is None or int(pin) < 0: + raise ValueError("Pin inválido") + + pulse_ms = float(pulse_ms) + if pulse_ms <= 0: + raise ValueError("pulse_ms deve ser maior que zero") + + self.pin = int(pin) + self.active_high = bool(active_high) + self.pulse_ms = pulse_ms + self.initialized = False + + self._lock = threading.RLock() + self._active_level = GPIO.HIGH if self.active_high else GPIO.LOW + self._idle_level = GPIO.LOW if self.active_high else GPIO.HIGH + + def begin(self): + with self._lock: + if self.initialized: + return True + + try: + GPIO.setmode(GPIO.BCM) + GPIO.setwarnings(False) + GPIO.setup(self.pin, GPIO.OUT) + GPIO.output(self.pin, self._idle_level) + except Exception as e: + raise RuntimeError(f"Falha ao inicializar trigger GPIO {self.pin}: {e}") from e + + self.initialized = True + return True + + def stop(self): + with self._lock: + if not self.initialized: + return True + + try: + GPIO.output(self.pin, self._idle_level) + except Exception: + pass + + try: + GPIO.cleanup(self.pin) + except Exception: + pass + + self.initialized = False + return True + + def pulse(self): + with self._lock: + if not self.initialized: + raise RuntimeError("TriggerManager não inicializado") + + try: + GPIO.output(self.pin, self._active_level) + time.sleep(self.pulse_ms / 1000.0) + except Exception as e: + raise RuntimeError(f"Falha ao gerar pulso no GPIO {self.pin}: {e}") from e + finally: + try: + GPIO.output(self.pin, self._idle_level) + except Exception: + pass + + return True \ No newline at end of file diff --git a/Python/raspi/cam_3/stream_receiver.py b/Python/raspi/cam_3/stream_receiver.py new file mode 100644 index 000000000..e43253c21 --- /dev/null +++ b/Python/raspi/cam_3/stream_receiver.py @@ -0,0 +1,324 @@ +import json +import socket +import threading +import time +from numcodecs import Blosc +import numpy as np + + +class StreamReceiver: + def __init__(self, host="0.0.0.0", port=6001): + self.host = host + self.port = port + + self._server_sock = None + self._client_sock = None + self._thread = None + self._running = False + + self.last_frame = None + self.last_meta = None + self.last_receive_ts = None + + self._codec = None + self._codec_signature = None + + @property + def is_running(self): + return self._running + + # ========================================================= + # Lifecycle + # ========================================================= + + def start(self): + if self._running: + return + + self._running = True + self._thread = threading.Thread(target=self._worker, daemon=True) + self._thread.start() + + def stop(self): + self._running = False + + try: + if self._client_sock: + self._client_sock.close() + except Exception: + pass + + try: + if self._server_sock: + self._server_sock.close() + except Exception: + pass + + self._client_sock = None + self._server_sock = None + + # ========================================================= + # Socket helpers + # ========================================================= + + def _recv_exact(self, sock: socket.socket, n: int) -> bytes: + chunks = [] + remaining = n + + while remaining > 0: + chunk = sock.recv(remaining) + if not chunk: + raise ConnectionError("Conexão encerrada durante recv") + chunks.append(chunk) + remaining -= len(chunk) + + return b"".join(chunks) + + # ========================================================= + # Worker principal + # ========================================================= + + def _worker(self): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((self.host, self.port)) + server.listen(1) + server.settimeout(1.0) + + self._server_sock = server + print(f"[INFO] StreamReceiver ouvindo em {self.host}:{self.port}") + + while self._running: + try: + client, addr = server.accept() + except socket.timeout: + continue + + print(f"[INFO] StreamReceiver conectado por {addr}") + self._client_sock = client + + with client: + while self._running: + header_len = int.from_bytes(self._recv_exact(client, 4), "big") + header_bytes = self._recv_exact(client, header_len) + header = json.loads(header_bytes.decode("utf-8")) + + payload_len = int.from_bytes(self._recv_exact(client, 4), "big") + payload_comp = self._recv_exact(client, payload_len) + + self._ensure_codec(header) + if header.get("codec_family") == "none": + payload = payload_comp + else: + payload = self._codec.decode(payload_comp) + + expected = int(header["payload_size_raw"]) + if len(payload) != expected: + raise ValueError( + f"Tamanho descomprimido inválido: {len(payload)} != {expected}" + ) + + payload_kind = header.get("payload_kind") + if header.get("multi_payload", False) or payload_kind == "multi_array": + frame = self._decode_multi_payload(payload, header) + else: + frame = self._decode_single_payload(payload, header) + + self.last_frame = frame + self.last_meta = header + self.last_receive_ts = time.perf_counter() + + print("[INFO] StreamReceiver cliente desconectado") + self._client_sock = None + + except Exception as e: + print(f"[WARN] StreamReceiver encerrado com erro: {e}") + + finally: + self._running = False + self._client_sock = None + self._server_sock = None + + # ========================================================= + # Decode helpers + # ========================================================= + + def _numpy_dtype_from_string(self, dtype_str: str): + mapping = { + "uint8": np.uint8, + "uint16": np.uint16, + "float32": np.float32, + } + + if dtype_str not in mapping: + raise RuntimeError(f"dtype não suportado: {dtype_str}") + + return mapping[dtype_str] + + def _numpy_dtype_from_header(self, header: dict): + dtype_str = header.get("dtype") or header.get("output_dtype") or "uint8" + + if dtype_str == "multi": + # fallback conservador para protocolos mais antigos + return np.uint16 + + return self._numpy_dtype_from_string(dtype_str) + + def _reshape_from_shape(self, payload: bytes, dtype_str: str, shape): + dtype = self._numpy_dtype_from_string(dtype_str) + arr = np.frombuffer(payload, dtype=dtype) + return arr.reshape(tuple(shape)) + + def _reshape_from_layout(self, payload: bytes, dtype_str: str, layout: str, width: int, height: int, channels: int): + dtype = self._numpy_dtype_from_string(dtype_str) + arr = np.frombuffer(payload, dtype=dtype) + + if layout == "HW": + return arr.reshape(height, width) + + if layout == "CHW": + return arr.reshape(channels, height, width) + + if layout == "HWC": + return arr.reshape(height, width, channels) + + raise RuntimeError(f"Layout não suportado: {layout}") + + def _decode_single_payload(self, payload: bytes, header: dict): + payload_parts = header.get("payload_parts", []) or [] + first_part = payload_parts[0] if payload_parts else {} + + dtype_str = first_part.get("dtype") or header.get("dtype") or header.get("output_dtype") or "uint8" + shape = first_part.get("shape") + + if shape: + return self._reshape_from_shape(payload, dtype_str, shape) + + height = int(header.get("output_height", header.get("height"))) + width = int(header.get("output_width", header.get("width"))) + channels = int(header.get("output_channels", header.get("channels", 1))) + layout = header.get("output_layout", "HWC") + + return self._reshape_from_layout( + payload=payload, + dtype_str=dtype_str, + layout=layout, + width=width, + height=height, + channels=channels, + ) + + def _decode_multi_payload(self, payload: bytes, header: dict): + payload_parts = header.get("payload_parts", []) or [] + camera_frames = header.get("camera_frames", {}) or {} + + frames = {} + offset = 0 + + for part in payload_parts: + cam_id = part.get("camera_id") + if not cam_id: + raise RuntimeError("payload_parts sem camera_id") + + if offset + 4 > len(payload): + raise RuntimeError("Payload multi truncado ao ler tamanho da parte") + + part_size = int.from_bytes(payload[offset:offset + 4], "big") + offset += 4 + + if offset + part_size > len(payload): + raise RuntimeError(f"Payload multi truncado ao ler dados de {cam_id}") + + part_bytes = payload[offset:offset + part_size] + offset += part_size + + cam_meta = camera_frames.get(cam_id, {}) + + dtype_str = part.get("dtype") or header.get("dtype") or header.get("output_dtype") + shape = part.get("shape") + + if dtype_str == "multi" or dtype_str is None: + channels = int(part.get("channels", cam_meta.get("channels", 1))) + dtype_str = "uint8" if channels > 1 else "uint16" + + if shape: + frame = self._reshape_from_shape(part_bytes, dtype_str, shape) + else: + width = int(part.get("width", cam_meta.get("width", 0))) + height = int(part.get("height", cam_meta.get("height", 0))) + channels = int(part.get("channels", cam_meta.get("channels", 1))) + + layout = "HWC" if channels > 1 else "HW" + + frame = self._reshape_from_layout( + payload=part_bytes, + dtype_str=dtype_str, + layout=layout, + width=width, + height=height, + channels=channels, + ) + + frames[cam_id] = frame + + if offset != len(payload): + raise RuntimeError( + f"Payload multi com bytes sobrando: consumidos={offset}, total={len(payload)}" + ) + + return frames + + # ========================================================= + # Codec + # ========================================================= + + def _normalize_shuffle(self, shuffle_value): + if isinstance(shuffle_value, int): + return shuffle_value + + mapping = { + "NOSHUFFLE": Blosc.NOSHUFFLE, + "SHUFFLE": Blosc.SHUFFLE, + "BITSHUFFLE": Blosc.BITSHUFFLE, + } + + key = str(shuffle_value).upper() + if key not in mapping: + raise ValueError(f"shuffle inválido: {shuffle_value}") + + return mapping[key] + + def _build_codec_from_header(self, header: dict): + family = header.get("codec_family") + name = header.get("codec_name") + params = dict(header.get("codec_params", {})) + + if family != "numcodecs": + raise ValueError(f"Família de codec não suportada: {family}") + + if name == "blosc": + params["shuffle"] = self._normalize_shuffle(params.get("shuffle", "SHUFFLE")) + return Blosc(**params) + + raise ValueError(f"Codec numcodecs não suportado: {name}") + + def _get_codec_signature_from_header(self, header: dict): + return ( + header.get("codec_family"), + header.get("codec_name"), + tuple(sorted(dict(header.get("codec_params", {})).items())) + ) + + def _ensure_codec(self, header: dict): + family = header.get("codec_family") + + if family == "none": + self._codec = None + self._codec_signature = ("none", None, ()) + return + + sig = self._get_codec_signature_from_header(header) + if self._codec is None or self._codec_signature != sig: + self._codec = self._build_codec_from_header(header) + self._codec_signature = sig \ No newline at end of file diff --git a/Python/raspi/check_saved_files.py b/Python/raspi/check_saved_files.py new file mode 100644 index 000000000..12071f395 --- /dev/null +++ b/Python/raspi/check_saved_files.py @@ -0,0 +1,541 @@ +import os +import json +import argparse +from pathlib import Path + +import cv2 +import numpy as np + +from cam_2.pi.raw_processor_core import RawProcessorCore +from cam_2.pi.raw_processor_preview import RawProcessorPreview + + +def load_json(path: Path) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def normalize_float01_to_bgr(img_float: np.ndarray) -> np.ndarray: + """ + Recebe RGB float32 [0..1] em HWC e devolve BGR uint8. + """ + rgb_u8 = np.clip(img_float * 255.0, 0, 255).astype(np.uint8) + return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR) + + +def chw_to_hwc(arr: np.ndarray) -> np.ndarray: + if arr.ndim != 3: + raise ValueError(f"Esperado CHW 3D, recebido shape={arr.shape}") + return np.transpose(arr, (1, 2, 0)) + + +def build_visual_from_saved_payload(payload_path: Path, meta: dict, cam_id: str | None = None) -> tuple[np.ndarray, str]: + """ + Retorna: + preview_bgr_reconstructed + texto_descritivo + """ + saved_type = meta.get("saved_payload_type") + + # ========================================================= + # Caso MULTI payload por câmera + # ========================================================= + if saved_type == "raw_native_multi": + 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}" + ) + + 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_frames", {}) 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", "GBRG")) + + # 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 + # ========================================================= + saved_dtype = meta.get("saved_payload_dtype") + saved_shape = meta.get("saved_payload_shape") + + if saved_type is None or saved_dtype is None or saved_shape is None: + raise RuntimeError( + "JSON não contém saved_payload_type / saved_payload_dtype / saved_payload_shape" + ) + + np_dtype = np.dtype(saved_dtype) + raw = np.fromfile(str(payload_path), dtype=np_dtype) + arr = raw.reshape(tuple(saved_shape)) + + if saved_type == "rgb": + if arr.ndim != 3 or arr.shape[0] != 3: + raise RuntimeError(f"Payload RGB inválido, shape={arr.shape}") + + rgb_hwc = chw_to_hwc(arr.astype(np.float32)) + preview_bgr = normalize_float01_to_bgr(rgb_hwc) + desc = f"Reconstruido de RGB salvo | dtype={arr.dtype} | shape={arr.shape}" + return preview_bgr, desc + + if saved_type == "multispec": + if arr.ndim != 3 or arr.shape[0] < 3: + raise RuntimeError(f"Payload MULTISPEC inválido, shape={arr.shape}") + + rgb_hwc = chw_to_hwc(arr[:3].astype(np.float32)) + preview_bgr = normalize_float01_to_bgr(rgb_hwc) + desc = f"Reconstruido de MULTISPEC salvo | dtype={arr.dtype} | shape={arr.shape}" + return preview_bgr, desc + + if saved_type == "raw_native_single": + if arr.ndim == 3 and arr.shape[2] == 3 and arr.dtype == np.uint8: + preview_bgr = arr.copy() + desc = f"Reconstruido de RAW nativo USB | dtype={arr.dtype} | shape={arr.shape}" + return preview_bgr, desc + + stream_meta = meta.get("stream_meta", {}) + source_camera = stream_meta.get("source_camera", {}) or {} + + bayer = source_camera.get("bayer_pattern", meta.get("bayer_pattern", "GBRG")) + bit_depth = int(source_camera.get("bit_depth", 10)) + + sensor_height = int(meta.get("sensor_height")) + sensor_width = int(meta.get("sensor_width")) + + 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"Reconstruido de RAW packed mono | dtype={arr.dtype} | shape={arr.shape} | bayer={bayer} | bit_depth={bit_depth}" + return preview_bgr, desc + + if saved_type == "raw10_packed": + stream_meta = meta.get("stream_meta", {}) or {} + source_camera = stream_meta.get("source_camera", {}) or {} + + bayer = source_camera.get("bayer_pattern", meta.get("bayer_pattern", "GBRG")) + bit_depth = int(source_camera.get("bit_depth", 10)) + + sensor_height = int(meta.get("sensor_height")) + sensor_width = int(meta.get("sensor_width")) + + 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"Reconstruido de RAW10 packed | " + 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 + + raise RuntimeError(f"saved_payload_type não suportado neste script: {saved_type}") + + +def build_panels_from_group(group): + panels = [] + + meta = load_json(group["json"]) + + 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]}")) + + if group["final_raw"] is not None: + img, desc = build_visual_from_saved_payload(group["final_raw"], meta) + panels.append(("Reconstruido (final)", img, desc)) + + for cam_id, path in group["cameras"].items(): + img, desc = build_visual_from_saved_payload(path, meta, cam_id=cam_id) + panels.append((f"{cam_id} reconstruido", img, desc)) + + return panels + + +def compose_panels(panels, max_width=1600): + imgs = [] + + # aplica label + for title, img, subtitle in panels: + img_labeled = put_label(img, title, subtitle) + imgs.append(img_labeled) + + # normaliza tamanho base + max_h = max(img.shape[0] for img in imgs) + + resized = [] + for img in imgs: + scale = max_h / img.shape[0] + w = int(img.shape[1] * scale) + resized.append(cv2.resize(img, (w, max_h), interpolation=cv2.INTER_NEAREST)) + + # ========================= + # Montagem em grid 2x2 + # ========================= + rows = [] + gap = np.full((max_h, 20, 3), 30, dtype=np.uint8) + + for i in range(0, len(resized), 2): + row_imgs = resized[i:i+2] + + # se só tiver 1 imagem na linha, duplica espaço vazio + if len(row_imgs) == 1: + blank = np.zeros_like(row_imgs[0]) + row_imgs.append(blank) + + row = np.hstack([row_imgs[0], gap, row_imgs[1]]) + rows.append(row) + + # junta linhas + gap_h = np.full((20, rows[0].shape[1], 3), 30, dtype=np.uint8) + + canvas = rows[0] + for r in rows[1:]: + canvas = np.vstack([canvas, gap_h, r]) + + # ========================= + # Resize final + # ========================= + if canvas.shape[1] > max_width: + scale = max_width / canvas.shape[1] + canvas = cv2.resize( + canvas, + (int(canvas.shape[1] * scale), int(canvas.shape[0] * scale)), + interpolation=cv2.INTER_AREA + ) + + return canvas + + +def sort_panels(panels): + order = ["Preview salvo", "cam2", "cam0", "cam1"] + + def key(p): + title = p[0].lower() + for i, k in enumerate(order): + if k in title: + return i + return 99 + + return sorted(panels, key=key) + + +def fit_same_height(img_a: np.ndarray, img_b: np.ndarray, target_h: int = None): + if target_h is None: + target_h = max(img_a.shape[0], img_b.shape[0]) + + def resize_to_h(img, h): + scale = h / img.shape[0] + w = int(img.shape[1] * scale) + return cv2.resize(img, (w, h), interpolation=cv2.INTER_NEAREST) + + return resize_to_h(img_a, target_h), resize_to_h(img_b, target_h) + + +def put_label(img: np.ndarray, title: str, subtitle: str = "") -> np.ndarray: + out = img.copy() + cv2.putText(out, title, (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 3, cv2.LINE_AA) + cv2.putText(out, title, (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2, cv2.LINE_AA) + + if subtitle: + cv2.putText(out, subtitle, (12, 56), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 3, cv2.LINE_AA) + cv2.putText(out, subtitle, (12, 56), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA) + + return out + + +def resolve_capture_group(input_path: Path): + """ + Resolve todos os arquivos relacionados a uma captura. + + Retorna: + { + "json": Path, + "png": Path, + "final_raw": Path | None, + "cameras": { "cam0": Path, ... } + } + """ + input_path = input_path.resolve() + folder = input_path.parent + + name = input_path.stem + + # remove sufixo _camX se existir + if "_cam" in name: + base_name = name.split("_cam")[0] + else: + base_name = name + + json_path = folder / f"{base_name}.json" + png_path = folder / f"{base_name}.png" + + if not json_path.exists(): + raise FileNotFoundError(f"JSON não encontrado: {json_path}") + if not png_path.exists(): + raise FileNotFoundError(f"PNG não encontrado: {png_path}") + + meta = load_json(json_path) + + group = { + "json": json_path, + "png": png_path, + "final_raw": None, + "cameras": {} + } + + # ========================= + # Caso MULTI payload + # ========================= + if "saved_payload_paths" in meta: + for cam_id, fname in meta["saved_payload_paths"].items(): + path = folder / fname + if path.exists(): + group["cameras"][cam_id] = path + + # ========================= + # Caso payload único (.raw) + # ========================= + elif "saved_payload_path" in meta: + path = folder / meta["saved_payload_path"] + if path.exists(): + group["final_raw"] = path + + return group + + +def resolve_sensor_dims_for_raw10_packed(arr: np.ndarray, cam_meta: dict, meta: dict) -> tuple[int, int]: + """ + Para CSI RAW10 packed: + packed_width = ceil(sensor_width * 5 / 4) + Na prática aqui usamos: + sensor_width = packed_width * 4 // 5 + + Altura permanece a mesma. + """ + packed_h = int(arr.shape[0]) + packed_w = int(arr.shape[1]) + + interface = str(cam_meta.get("interface", "")).upper() + bit_depth = int(cam_meta.get("bit_depth", 10)) + + # USB ou RGB HWC não entra nessa lógica + if interface == "USB": + return packed_w, packed_h + + # Caso esperado: CSI RAW10 packed mono + if bit_depth == 10: + sensor_w = (packed_w * 4) // 5 + sensor_h = packed_h + return sensor_w, sensor_h + + # fallback conservador + return packed_w, packed_h + + +def list_capture_groups_from_dir(folder: Path) -> list[Path]: + """ + Lista todos os JSONs de captura do diretório, ordenados por nome. + Cada JSON representa uma captura. + """ + if not folder.exists() or not folder.is_dir(): + raise FileNotFoundError(f"Diretório não encontrado: {folder}") + + items = sorted(folder.glob("*.json")) + if not items: + raise RuntimeError(f"Nenhum arquivo .json encontrado em: {folder}") + + return items + + +def resolve_navigation_inputs(input_path: Path) -> tuple[list[Path], int]: + """ + Retorna: + entries: lista de JSONs de captura + start_index: índice inicial baseado no input fornecido + """ + input_path = input_path.resolve() + + # Caso 1: usuário passou uma pasta + if input_path.is_dir(): + entries = list_capture_groups_from_dir(input_path) + return entries, 0 + + # Caso 2: usuário passou arquivo + if not input_path.exists(): + raise FileNotFoundError(f"Arquivo não encontrado: {input_path}") + + folder = input_path.parent + entries = list_capture_groups_from_dir(folder) + + # Tenta descobrir qual JSON corresponde ao input + if input_path.suffix.lower() == ".json": + target_json = input_path.resolve() + else: + group = resolve_capture_group(input_path) + target_json = group["json"].resolve() + + try: + idx = entries.index(target_json) + except ValueError: + idx = 0 + + return entries, idx + + +def render_group_to_canvas(json_path: Path, max_width: int): + group = resolve_capture_group(json_path) + meta = load_json(group["json"]) + + panels = build_panels_from_group(group) + panels = sort_panels(panels) + canvas = compose_panels(panels, max_width=max_width) + + info = { + "json": group["json"], + "png": group["png"], + "final_raw": group["final_raw"], + "cameras": group["cameras"], + "meta": meta, + } + return canvas, info + + +def main(): + parser = argparse.ArgumentParser( + description="Valida visualmente payload salvo (.bin/.raw/.json/.png) comparando com o preview .png" + ) + parser.add_argument("input_path", help="Caminho para .json, .png, .bin, .raw ou diretório") + parser.add_argument("--max-width", type=int, default=1600, help="Largura máxima da janela final") + args = parser.parse_args() + + input_path = Path(args.input_path) + entries, current_idx = resolve_navigation_inputs(input_path) + + window_name = "Validacao do payload salvo | A=anterior | D=proximo | 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) + + # Cabeçalho adicional na imagem + overlay = canvas.copy() + text = f"{current_idx + 1}/{len(entries)} | {current_json.name}" + cv2.putText(overlay, text, (12, overlay.shape[0] - 16), + cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 0, 0), 3, cv2.LINE_AA) + cv2.putText(overlay, text, (12, overlay.shape[0] - 16), + cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 0), 1, cv2.LINE_AA) + + cv2.imshow(window_name, overlay) + + meta = info["meta"] + print("=" * 60) + print(f"[{current_idx + 1}/{len(entries)}]") + print("Entrada JSON :", info["json"]) + print("PNG :", info["png"]) + print("Final RAW :", info["final_raw"]) + print("Câmeras :", {k: str(v) for k, v in info["cameras"].items()}) + print("saved_payload_type :", meta.get("saved_payload_type")) + print("saved_payload_dtype:", meta.get("saved_payload_dtype")) + print("saved_payload_shape:", meta.get("saved_payload_shape")) + print("saved_payload_dtypes:", meta.get("saved_payload_dtypes")) + print("saved_payload_shapes:", meta.get("saved_payload_shapes")) + print("stream frame_type :", (meta.get("stream_meta") or {}).get("frame_type")) + print("=" * 60) + + k = cv2.waitKey(0) & 0xFF + + if k in (ord("q"), ord("Q"), 27): + break + elif k in (ord("d"), ord("D")): + current_idx = min(current_idx + 1, len(entries) - 1) + elif k in (ord("a"), ord("A")): + current_idx = max(current_idx - 1, 0) + + cv2.destroyAllWindows() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Python/raspi/multispectral_service.py b/Python/raspi/multispectral_service.py deleted file mode 100644 index 8326cb70e..000000000 --- a/Python/raspi/multispectral_service.py +++ /dev/null @@ -1,221 +0,0 @@ -import socket -import json -import numpy as np -import base64 -import time -from typing import Optional - - -class MultiSpectralService: - def __init__(self, host="192.168.105.6", port=5000, timeout=5): - self.host = host - self.port = port - self.timeout = timeout - self.sock = None - self.file = None - - def __enter__(self): - self.connect() - return self - - def __exit__(self, exc_type, exc, tb): - self.disconnect() - - def connect(self): - if self.sock is not None: - return - - self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout) - self.sock.settimeout(self.timeout) - self.file = self.sock.makefile("r", encoding="utf-8") - - def disconnect(self): - try: - if self.file: - self.file.close() - except: - pass - - try: - if self.sock: - self.sock.close() - except: - pass - - self.file = None - self.sock = None - - def _send_command(self, payload: dict) -> dict: - if self.sock is None: - self.connect() - - data = (json.dumps(payload) + "\n").encode("utf-8") - self.sock.sendall(data) - - line = self.file.readline() - if not line: - self.disconnect() - raise RuntimeError("Conexão encerrada pelo servidor") - - return json.loads(line.strip()) - - def _numpy_dtype_from_string(self, dtype_str: str): - mapping = { - "uint8": np.uint8, - "float32": np.float32, - "uint16": np.uint16, - } - if dtype_str not in mapping: - raise RuntimeError(f"dtype não suportado recebido do Pi: {dtype_str}") - return mapping[dtype_str] - - def ping(self): - return self._send_command({"cmd": "ping"}) - - def get_status(self): - return self._send_command({"cmd": "get_status"}) - - def get_config(self): - return self._send_command({"cmd": "get_config"}) - - def begin(self, frame_type: str = "RAW_BRUTO", output_dtype: str = "uint8"): - return self._send_command({ - "cmd": "begin", - "frame_type": frame_type, - "output_dtype": output_dtype, - }) - - def stop(self): - return self._send_command({"cmd": "stop"}) - - def set_fps(self, fps: int): - return self._send_command({"cmd": "set_fps", "value": fps}) - - def set_jpeg_quality(self, quality: int): - return self._send_command({"cmd": "set_jpeg_quality", "value": quality}) - - def set_bayer(self, bayer_pattern: str): - return self._send_command({ - "cmd": "set_bayer", - "pattern": bayer_pattern - }) - - def set_resolution(self, width: int, height: int): - return self._send_command({ - "cmd": "set_resolution", - "width": width, - "height": height - }) - - def capture_frame_array(self): - t0 = time.perf_counter() - - resp = self._send_command({"cmd": "capture_frame"}) - if not resp.get("ok"): - raise RuntimeError(resp.get("error", "Falha ao capturar frame")) - - raw = base64.b64decode(resp["data"]) - - frame_type = resp.get("frame_type") - output_layout = resp.get("output_layout", "HWC") - dtype_str = resp.get("dtype") or resp.get("output_dtype") or "uint8" - np_dtype = self._numpy_dtype_from_string(dtype_str) - - width = int(resp.get("output_width", resp.get("width"))) - height = int(resp.get("output_height", resp.get("height"))) - channels = int(resp.get("output_channels", resp.get("channels", 1))) - - arr = np.frombuffer(raw, dtype=np_dtype) - - if output_layout == "HW": - arr = arr.reshape(height, width) - - elif output_layout == "CHW": - arr = arr.reshape(channels, height, width) - - elif output_layout == "HWC": - arr = arr.reshape(height, width, channels) - - else: - raise RuntimeError(f"Layout não suportado recebido do Pi: {output_layout}") - - t1 = time.perf_counter() - - meta = { - "frame_type": frame_type, - "payload_format_version": resp.get("payload_format_version"), - - "width": width, - "height": height, - "channels": channels, - "dtype": dtype_str, - "output_dtype": resp.get("output_dtype"), - "output_layout": output_layout, - "output_channel_names": resp.get("output_channel_names"), - - "packed_width": resp.get("packed_width"), - "packed_height": resp.get("packed_height"), - "source_width": resp.get("source_width"), - "source_height": resp.get("source_height"), - "source_bayer_pattern": resp.get("source_bayer_pattern"), - "source_bit_depth": resp.get("source_bit_depth"), - - "size": resp.get("size"), - - "ts_pi": resp.get("ts_pi"), - "ts_pi_monotonic": resp.get("ts_pi_monotonic"), - "dt_trigger": resp.get("dt_trigger"), - "dt_settle": resp.get("dt_settle"), - "dt_capture": resp.get("dt_capture"), - "dt_process": resp.get("dt_process"), - "dt_total_pi": resp.get("dt_total_pi"), - - "dt_total_pc": t1 - t0, - } - - return arr, meta - - def start_stream(self, host: str, port: int, fps: float): - return self._send_command({ - "cmd": "start_stream", - "host": host, - "port": port, - "fps": fps - }) - - def stop_stream(self): - return self._send_command({"cmd": "stop_stream"}) - - def get_camera_controls(self): - return self._send_command({"cmd": "get_camera_controls"}) - - def set_ae_enable(self, value: bool): - return self._send_command({"cmd": "set_ae_enable", "value": bool(value)}) - - def set_awb_enable(self, value: bool): - return self._send_command({"cmd": "set_awb_enable", "value": bool(value)}) - - def set_exposure_time(self, exposure_time_us: Optional[int] = None): - return self._send_command({"cmd": "set_exposure_time", "value": exposure_time_us}) - - def clear_exposure_time(self): - return self._send_command({"cmd": "clear_exposure_time"}) - - def set_analogue_gain(self, gain: float | None): - return self._send_command({"cmd": "set_analogue_gain", "value": gain}) - - def clear_analogue_gain(self): - return self._send_command({"cmd": "clear_analogue_gain"}) - - def set_colour_gains(self, r_gain: float, b_gain: float): - return self._send_command({ - "cmd": "set_colour_gains", - "r_gain": r_gain, - "b_gain": b_gain - }) - - def clear_colour_gains(self): - return self._send_command({"cmd": "clear_colour_gains"}) - - def get_sensor_modes(self): - return self._send_command({"cmd": "get_sensor_modes"}) diff --git a/Python/raspi/pi/camera_manager.py b/Python/raspi/pi/camera_manager.py deleted file mode 100644 index 529acc715..000000000 --- a/Python/raspi/pi/camera_manager.py +++ /dev/null @@ -1,278 +0,0 @@ -from pathlib import Path -from datetime import datetime -import subprocess -from picamera2 import Picamera2 -import base64 -import io -from PIL import Image -from threading import Lock, RLock -import threading -import time -import numpy as np - - -class CameraManager: - def __init__(self, state): - self.state = state - self.picam2 = None - self.initialized = False - - self.output_dir = Path("/tmp/multispec_captures") - self.output_dir.mkdir(parents=True, exist_ok=True) - - self.last_raw_frame = None - self.last_raw_frame_id = 0 - self.last_raw_frame_ts = None - self._raw_buffer = None - self.frame_lock = Lock() - self.camera_lock = RLock() - - self._stop_event = threading.Event() - self._stream_thread = None - self._sensor_modes_cache = None - - self.current_width = None - self.current_height = None - self.current_fps = None - self._reconfigure_needed = False - - def mark_reconfigure_needed(self): - with self.camera_lock: - self._reconfigure_needed = True - - def needs_reconfigure(self): - return ( - self._reconfigure_needed or - not self.initialized or - self.current_width != self.state.width or - self.current_height != self.state.height or - self.current_fps != self.state.fps - ) - - def begin(self): - with self.camera_lock: - if self.picam2 is not None and self.needs_reconfigure(): - self.stop() - - if self.initialized and self.picam2 is not None: - return True - - self.picam2 = Picamera2() - - config = self.picam2.create_video_configuration( - main={"size": (640, 480), "format": "RGB888"}, - raw={"size": (self.state.width, self.state.height)}, - buffer_count=6 - ) - - if config is None: - self.picam2 = None - raise RuntimeError("Falha ao criar configuração da câmera. Verifique se a resolução é suportada.") - - self.picam2.configure(config) - self.picam2.start() - - self.initialized = True - self.current_width = self.state.width - self.current_height = self.state.height - self.current_fps = self.state.fps - self._reconfigure_needed = False - - self.apply_controls() - - with self.frame_lock: - self.last_raw_frame = None - self.last_raw_frame_id = 0 - self.last_raw_frame_ts = None - - self._stop_event.clear() - self._stream_thread = threading.Thread(target=self._update_loop, daemon=True) - self._stream_thread.start() - - if not self.wait_first_frame(timeout_s=2.0): - self.stop() - raise RuntimeError("Câmera inicializada, mas nenhum frame foi recebido a tempo") - - return True - - def _update_loop(self): - print("[DEBUG] Loop de atualização de frames iniciado") - - while not self._stop_event.is_set(): - request = None - try: - with self.camera_lock: - if self.picam2 is None: - time.sleep(0.05) - continue - request = self.picam2.capture_request() - - raw_array = request.make_array("raw") - self.last_raw_shape = raw_array.shape - self.last_raw_dtype = raw_array.dtype - - with self.frame_lock: - if ( - self._raw_buffer is None or - self._raw_buffer.shape != raw_array.shape or - self._raw_buffer.dtype != raw_array.dtype - ): - self._raw_buffer = raw_array.copy() - else: - np.copyto(self._raw_buffer, raw_array) - - self.last_raw_frame = self._raw_buffer - self.last_raw_frame_id += 1 - self.last_raw_frame_ts = time.perf_counter() - - except Exception as e: - print(f"[ERRO LOOP] {e}") - time.sleep(0.1) - finally: - if request is not None: - try: - request.release() - except Exception: - pass - - def stop(self): - with self.camera_lock: - self._stop_event.set() - - if self._stream_thread is not None: - self._stream_thread.join(timeout=1.5) - self._stream_thread = None - - if self.picam2 is not None: - try: - self.picam2.stop() - except Exception: - pass - try: - self.picam2.close() - except Exception: - pass - - self.picam2 = None - self.initialized = False - self._reconfigure_needed = False - - self.current_width = None - self.current_height = None - self.current_fps = None - - with self.frame_lock: - self.last_raw_frame = None - - with self.frame_lock: - self.last_raw_frame = None - self.last_raw_frame_id = 0 - self.last_raw_frame_ts = None - self._raw_buffer = None - - def capture_raw_frame(self): - with self.frame_lock: - if self.last_raw_frame is None: - return None, 0, 0, 0, 0, None - - frame = self.last_raw_frame - frame_id = self.last_raw_frame_id - frame_ts = self.last_raw_frame_ts - h, w = frame.shape[:2] - - return { - "frame": frame, - "width": w, - "height": h, - "channels": 1, - "frame_id": frame_id, - "timestamp": frame_ts, - } - - def _build_controls_from_state(self): - controls = {} - - frame_us = int(1_000_000 / max(1, self.state.fps or 10)) - controls["FrameDurationLimits"] = (frame_us, frame_us) - - controls["AeEnable"] = bool(self.state.ae_enable) - controls["AwbEnable"] = bool(self.state.awb_enable) - - if not self.state.ae_enable: - if self.state.exposure_time_us is not None: - controls["ExposureTime"] = int(self.state.exposure_time_us) - if self.state.analogue_gain is not None: - controls["AnalogueGain"] = float(self.state.analogue_gain) - - if not self.state.awb_enable and self.state.colour_gains is not None: - r_gain, b_gain = self.state.colour_gains - controls["ColourGains"] = (float(r_gain), float(b_gain)) - - return controls - - def apply_controls(self): - with self.camera_lock: - if self.picam2 is None: - return False - - controls = self._build_controls_from_state() - try: - self.picam2.set_controls(controls) - return True - except Exception as e: - print(f"[ERRO CONTROLS] {e} | controls={controls}") - return False - - def get_sensor_modes(self): - if self._sensor_modes_cache: - return self._sensor_modes_cache - - temp_picam2 = None - try: - if self.picam2 is not None: - cam = self.picam2 - else: - temp_picam2 = Picamera2() - cam = temp_picam2 - - modes = cam.sensor_modes - result = [] - - for i, m in enumerate(modes): - fmt = str(m.get("format")) if m.get("format") is not None else None - size = m.get("size") - bit_depth = m.get("bit_depth") - fps = m.get("fps") - crop_limits = m.get("crop_limits") - exposure_limits = m.get("exposure_limits") - - result.append({ - "index": i, - "format": fmt, - "size": list(size) if size is not None else None, - "bit_depth": bit_depth, - "fps": fps, - "crop_limits": list(crop_limits) if crop_limits is not None else None, - "exposure_limits": list(exposure_limits) if exposure_limits is not None else None, - }) - - self._sensor_modes_cache = result - return result - - finally: - if temp_picam2 is not None: - try: - temp_picam2.close() - except Exception: - pass - - def wait_first_frame(self, timeout_s=2.0): - t0 = time.perf_counter() - - while time.perf_counter() - t0 < timeout_s: - with self.frame_lock: - if self.last_raw_frame is not None: - return True - time.sleep(0.01) - - return False diff --git a/Python/raspi/pi/frame_service.py b/Python/raspi/pi/frame_service.py deleted file mode 100644 index 7708d952d..000000000 --- a/Python/raspi/pi/frame_service.py +++ /dev/null @@ -1,239 +0,0 @@ -import time -import threading -import base64 - - -class FrameService: - def __init__(self, state, trigger_manager, camera_manager): - self.state = state - self.trigger = trigger_manager - self.camera = camera_manager - self._capture_lock = threading.RLock() - - self._ensure_raw_processor() - - def _ensure_raw_processor(self): - if ( - not hasattr(self, "raw_processor") or - self.raw_processor.sensor_width != self.state.width or - self.raw_processor.sensor_height != self.state.height or - self.raw_processor.bayer_pattern.upper() != self.state.source_bayer_pattern.upper() - ): - from raw_processor_core import RawProcessorCore - self.raw_processor = RawProcessorCore( - sensor_width=self.state.width, - sensor_height=self.state.height, - bayer_pattern=self.state.source_bayer_pattern, - ) - - def _capture_with_retry(self, max_attempts=10, retry_delay_s=0.02): - last_info = None - - for _ in range(max_attempts): - info = self.camera.capture_raw_frame() - - if ( - info is not None and - info.get("frame") is not None and - info.get("width", 0) > 0 and - info.get("height", 0) > 0 and - info.get("channels", 0) > 0 - ): - return info - - last_info = info - time.sleep(retry_delay_s) - - if last_info is None: - raise RuntimeError(f"Capture retornou frame vazio após {max_attempts} tentativas") - - raise RuntimeError( - f"Capture retornou frame vazio após {max_attempts} tentativas: " - f"width={last_info.get('width')}, " - f"height={last_info.get('height')}, " - f"channels={last_info.get('channels')}, " - f"camera_frame_id={last_info.get('frame_id')}, " - f"camera_frame_ts={last_info.get('timestamp')}" - ) - - def capture_frame_raw(self): - with self._capture_lock: - if not self.state.initialized: - raise RuntimeError("Módulo não inicializado") - - t0_perf = time.perf_counter() - t0_unix = time.time() - - dt_trigger = 0.0 - dt_settle = 0.0 - dt_capture = 0.0 - dt_process = 0.0 - - try: - if self.state.trigger_enabled: - trig_start = time.perf_counter() - self.trigger.pulse() - trig_end = time.perf_counter() - dt_trigger = trig_end - trig_start - - settle_ms = float(getattr(self.state, "trigger_settle_delay_ms", 0.0) or 0.0) - if settle_ms > 0: - settle_start = time.perf_counter() - time.sleep(settle_ms / 1000.0) - settle_end = time.perf_counter() - dt_settle = settle_end - settle_start - - cap_start = time.perf_counter() - raw_frame_info = self._capture_with_retry() - cap_end = time.perf_counter() - dt_capture = cap_end - cap_start - - proc_start = time.perf_counter() - if self.state.frame_type != "RAW_BRUTO": - self._ensure_raw_processor() - frame_out, meta_extra = self._build_output_frame(raw_frame_info) - proc_end = time.perf_counter() - dt_process = proc_end - proc_start - - except Exception as e: - raise RuntimeError(f"Falha durante captura/processamento de frame: {e}") from e - - if frame_out is None: - raise RuntimeError("Frame processado retornou nulo") - - total_end = time.perf_counter() - - self.state.stream_frame_id += 1 - frame_id = self.state.stream_frame_id - - meta = { - "frame_id": frame_id, - "camera_frame_id": int(raw_frame_info["frame_id"]), - "camera_frame_ts": raw_frame_info["timestamp"], - - "ts_pi": t0_unix, - "ts_pi_monotonic": t0_perf, - - "dt_trigger": dt_trigger, - "dt_settle": dt_settle, - "dt_capture": dt_capture, - "dt_process": dt_process, - "dt_total_pi": total_end - t0_perf, - - "source_bayer_pattern": self.state.source_bayer_pattern, - "source_bit_depth": self.state.source_bit_depth, - } - - meta.update(meta_extra) - - return frame_out, meta - - def capture_frame_base64(self): - frame, meta = self.capture_frame_raw() - - frame_bytes = frame.tobytes() - meta["encoding"] = "base64" - meta["size"] = len(frame_bytes) - meta["data"] = base64.b64encode(frame_bytes).decode("ascii") - return meta - - def _build_output_frame(self, raw_frame_info): - if self.state.frame_type == "RAW_BRUTO": - return self._process_raw_bruto(raw_frame_info) - - if self.state.frame_type == "RGB": - return self._process_rgb(raw_frame_info) - - if self.state.frame_type == "RGBNIR": - return self._process_rgbnir(raw_frame_info) - - raise RuntimeError(f"frame_type inválido: {self.state.frame_type}") - - def _convert_output_dtype(self, arr): - max_sensor_value = (1 << int(self.state.source_bit_depth)) - 1 - if self.state.output_dtype == "uint8": - if arr.dtype == "uint8": - return arr - if arr.dtype == "float32": - return (arr * 255.0).clip(0, 255).astype("uint8") - if arr.dtype.kind in ("u", "i"): - # assume 10-bit/16-bit vindo do pipeline - max_val = arr.max() if arr.size > 0 else 0 - if max_val <= 255: - return arr.astype("uint8") - return ((arr.astype("float32") / max_sensor_value) * 255.0).clip(0, 255).astype("uint8") - raise RuntimeError(f"dtype não suportado para uint8: {arr.dtype}") - - elif self.state.output_dtype == "float32": - if arr.dtype == "float32": - return arr - if arr.dtype == "uint8": - return arr.astype("float32") / 255.0 - if arr.dtype.kind in ("u", "i"): - max_val = arr.max() if arr.size > 0 else 0 - if max_val <= 255: - return arr.astype("float32") / 255.0 - return arr.astype("float32") / max_sensor_value - raise RuntimeError(f"dtype não suportado para float32: {arr.dtype}") - - raise RuntimeError(f"output_dtype inválido: {self.state.output_dtype}") - - def _process_raw_bruto(self, raw_frame_info): - frame = raw_frame_info["frame"] - - meta_extra = { - "frame_type": self.state.frame_type, - "payload_format_version": self.state.payload_format_version, - "output_dtype": str(frame.dtype), - "dtype": str(frame.dtype), - "output_layout": "HW", - "output_channels": 1, - "output_channel_names": ["RAW10_PACKED"], - "output_width": int(raw_frame_info["width"]), - "output_height": int(raw_frame_info["height"]), - "source_width": int(self.state.width), - "source_height": int(self.state.height), - "packed_width": int(raw_frame_info["width"]), - "packed_height": int(raw_frame_info["height"]), - "width": int(raw_frame_info["width"]), - "height": int(raw_frame_info["height"]), - "channels": 1, - } - - return frame, meta_extra - - def _process_rgb(self, raw_frame_info): - packed = raw_frame_info["frame"] - b_depth = self.state.source_bit_depth - - if packed.ndim == 3 and packed.shape[2] == 1: - packed = packed[:, :, 0] - - raw16 = self.raw_processor.unpack_raw10_packed(packed) - rgb_chw = self.raw_processor.build_training_rgb(raw16, bit_depth=b_depth) - - rgb_chw = self._convert_output_dtype(rgb_chw) - - meta_extra = { - "frame_type": self.state.frame_type, - "payload_format_version": self.state.payload_format_version, - "output_dtype": self.state.output_dtype, - "dtype": str(rgb_chw.dtype), - "output_layout": "CHW", - "output_channels": 3, - "output_channel_names": ["R", "G", "B"], - "output_width": int(rgb_chw.shape[2]), - "output_height": int(rgb_chw.shape[1]), - "source_width": int(self.state.width), - "source_height": int(self.state.height), - "width": int(rgb_chw.shape[2]), - "height": int(rgb_chw.shape[1]), - "channels": 3, - "packed_width": int(raw_frame_info["width"]), - "packed_height": int(raw_frame_info["height"]), - } - - return rgb_chw, meta_extra - - def _process_rgbnir(self, raw_frame_info): - raise NotImplementedError("RGBNIR ainda não implementado") diff --git a/Python/raspi/pi/state.py b/Python/raspi/pi/state.py deleted file mode 100644 index c08a42589..000000000 --- a/Python/raspi/pi/state.py +++ /dev/null @@ -1,155 +0,0 @@ -class ModuleState: - def __init__(self): - self.module_name = "multispectral" - self.version = "0.1.0" - self.payload_format_version = 1 - - self.status = "idle" - self.status_detail = None - self.last_error = None - self.last_command = None - - self.camera_connected = False - self.camera_id = "cam0" - - self.initialized = False - self.streaming = False - - # Configuração da captura no sensor - self.fps = 10 - self.jpeg_quality = 90 - self.width = 640 - self.height = 480 - self.source_bayer_pattern = "GBRG" - self.source_bit_depth = 10 - - # Configuração do tipo de payload de saída - self.frame_type = "RAW_BRUTO" # RAW_BRUTO | RGB | RGBNIR - self.output_dtype = "uint8" # uint8 | float32 - self.output_layout = "CHW" # sempre CHW - self.output_channels = 1 - self.output_channel_names = ["RAW10_PACKED"] - self.output_width = 640 - self.output_height = 480 - - self.trigger_enabled = True - self.trigger_pin = 18 - self.trigger_active_high = True - self.trigger_pulse_ms = 5.0 - self.trigger_settle_delay_ms = 0.0 - - self.stream_host = None - self.stream_port = None - self.stream_fps = None - self.stream_frame_id = 0 - - self.codec_family = "none" - self.codec_name = "blosc" - self.codec_params = { - "cname": "zstd", - "clevel": 3, - "shuffle": "BITSHUFFLE", - } - - self.ae_enable = True - self.awb_enable = True - self.exposure_time_us = 15000 - self.analogue_gain = 1.0 - self.colour_gains = [1.0, 1.0] - self.frame_duration_limits = None - - self.update_output_spec() - - def update_output_spec(self): - if self.frame_type == "RAW_BRUTO": - self.output_layout = "HW" - self.output_channels = 1 - self.output_channel_names = ["BAYER"] - self.output_width = self.width - self.output_height = self.height - - elif self.frame_type == "RGB": - self.output_layout = "CHW" - self.output_channels = 3 - self.output_channel_names = ["R", "G", "B"] - self.output_width = self.width // 2 - self.output_height = self.height // 2 - - elif self.frame_type == "RGBNIR": - self.output_layout = "CHW" - self.output_channels = 5 - self.output_channel_names = ["R", "G", "B", "NIR", "RE"] - self.output_width = self.width // 2 - self.output_height = self.height // 2 - - else: - raise ValueError(f"frame_type inválido: {self.frame_type}") - - def clear_error(self): - self.last_error = None - if self.status == "error": - self.status = "idle" - self.status_detail = None - - def set_error(self, error: str): - self.last_error = str(error) - self.status = "error" - self.status_detail = str(error) - - def to_dict(self): - return { - "module": self.module_name, - "version": self.version, - - "status": self.status, - "status_detail": self.status_detail, - "last_error": self.last_error, - "last_command": self.last_command, - - "camera_connected": self.camera_connected, - "camera_id": self.camera_id, - - "initialized": self.initialized, - "streaming": self.streaming, - - "fps": self.fps, - "jpeg_quality": self.jpeg_quality, - "width": self.width, - "height": self.height, - - "trigger_enabled": self.trigger_enabled, - "trigger_pin": self.trigger_pin, - "trigger_active_high": self.trigger_active_high, - "trigger_pulse_ms": self.trigger_pulse_ms, - "trigger_settle_delay_ms": self.trigger_settle_delay_ms, - - "stream_host": self.stream_host, - "stream_port": self.stream_port, - "stream_fps": self.stream_fps, - "stream_frame_id": self.stream_frame_id, - - "codec_family": self.codec_family, - "codec_name": self.codec_name, - "codec_params": self.codec_params, - - "ae_enable": self.ae_enable, - "awb_enable": self.awb_enable, - "exposure_time_us": self.exposure_time_us, - "analogue_gain": self.analogue_gain, - "colour_gains": self.colour_gains, - "frame_duration_limits": self.frame_duration_limits, - - "payload_format_version": self.payload_format_version, - - "source_bayer_pattern": self.source_bayer_pattern, - "source_bit_depth": self.source_bit_depth, - - "frame_type": self.frame_type, - "output_dtype": self.output_dtype, - "output_layout": self.output_layout, - "output_channels": self.output_channels, - "output_channel_names": self.output_channel_names, - "output_width": self.output_width, - "output_height": self.output_height, - } - \ No newline at end of file diff --git a/Python/raspi/test_service.py b/Python/raspi/test_service.py index 1ba115f66..a324aebfa 100644 --- a/Python/raspi/test_service.py +++ b/Python/raspi/test_service.py @@ -1,5 +1,5 @@ import time -from multispectral_service import MultiSpectralService +from cam_3.multispectral_service import MultiSpectralService svc = MultiSpectralService(host="192.168.105.6", port=5000)