ajustes de hierarquia para arquivos pi

This commit is contained in:
Diego Freitas 2026-04-20 15:56:31 -03:00
parent 3addf89bd9
commit 8debec9195
34 changed files with 7277 additions and 1229 deletions

7
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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/<modelo>/brutas/cana_<estado>/<horario>/<YYYYMMDD>/
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

View File

@ -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"})

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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(),
}

View File

@ -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

View File

@ -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]

View File

@ -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()

View File

@ -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"})

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,5 @@
from server import ModuleServer
if __name__ == "__main__":
server = ModuleServer(host="0.0.0.0", port=5000)
server.start()

View File

@ -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")

View File

@ -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}")

View File

@ -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()

View File

@ -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 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()

View File

@ -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(),
}

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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"})

View File

@ -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

View File

@ -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")

View File

@ -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,
}

View File

@ -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)