merge
This commit is contained in:
commit
fc1289f177
|
|
@ -78,3 +78,5 @@ AgroBase/AgroBase/bin/x64/Debug/Python/venv/
|
|||
!AgroBase/AgroBase/bin/x64/Debug/Parametros/
|
||||
|
||||
/Exemplos
|
||||
/Python/raspi/cam_2/imx296_pi/
|
||||
/Python/raspi/cam_3/imx296_pi/
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
"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,
|
||||
"bayer_pattern": self.state.source_bayer_pattern
|
||||
"camera": cam.to_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
if cmd == "set_resolution":
|
||||
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:
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from server import ModuleServer
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ModuleServer(host="0.0.0.0", port=5000)
|
||||
server.start()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,654 @@
|
|||
import socket
|
||||
import traceback
|
||||
import threading
|
||||
|
||||
from protocol import decode_message, encode_message
|
||||
from state import ModuleState
|
||||
|
||||
|
||||
def parse_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
s = value.strip().lower()
|
||||
if s in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if s in ("0", "false", "no", "off"):
|
||||
return False
|
||||
|
||||
raise ValueError("Valor booleano inválido")
|
||||
|
||||
|
||||
def parse_frame_type(value):
|
||||
valid = {"RAW_BRUTO", "RGB", "MULTISPEC"}
|
||||
if value not in valid:
|
||||
raise ValueError(f"frame_type inválido: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def parse_output_dtype(value):
|
||||
valid = {"uint8", "uint16", "float32"}
|
||||
if value not in valid:
|
||||
raise ValueError(f"output_dtype inválido: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def parse_capture_mode(value):
|
||||
valid = {"AUTO", "SINGLE", "DOUBLE", "TRIPLE"}
|
||||
if value not in valid:
|
||||
raise ValueError(f"capture_mode inválido: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def parse_bayer_pattern(value):
|
||||
valid = {"GBRG", "GRBG", "RGGB", "BGGR"}
|
||||
if value not in valid:
|
||||
raise ValueError(f"bayer_pattern inválido: {value}")
|
||||
return value
|
||||
|
||||
|
||||
class ModuleServer:
|
||||
def __init__(self, host="0.0.0.0", port=5000):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.state = ModuleState()
|
||||
self.running = False
|
||||
self.lock = threading.RLock()
|
||||
|
||||
from camera_manager import CameraManager
|
||||
from trigger_manager import TriggerManager
|
||||
from frame_service import FrameService
|
||||
from stream_sender import StreamSender
|
||||
|
||||
self.camera = CameraManager(self.state)
|
||||
self.trigger = TriggerManager(
|
||||
pin=self.state.trigger_pin,
|
||||
active_high=self.state.trigger_active_high,
|
||||
pulse_ms=self.state.trigger_pulse_ms
|
||||
)
|
||||
self.frame_service = FrameService(self.state, self.trigger, self.camera)
|
||||
self.stream_sender = StreamSender(self.state, self.frame_service)
|
||||
|
||||
# =========================================================
|
||||
# Helpers internos
|
||||
# =========================================================
|
||||
|
||||
def _mark_reconfigure_needed(self):
|
||||
if hasattr(self.camera, "mark_reconfigure_needed"):
|
||||
self.camera.mark_reconfigure_needed()
|
||||
|
||||
def _is_reconfigure_needed(self):
|
||||
return bool(getattr(self.camera, "_reconfigure_needed", False))
|
||||
|
||||
def _restart_module_if_needed(self):
|
||||
"""
|
||||
Reinicia trigger + câmeras se o módulo já estiver inicializado
|
||||
e alguma configuração estrutural tiver mudado.
|
||||
"""
|
||||
if not self.state.initialized:
|
||||
return
|
||||
|
||||
if not self._is_reconfigure_needed():
|
||||
return
|
||||
|
||||
if getattr(self.stream_sender, "is_running", False):
|
||||
self.stream_sender.stop()
|
||||
|
||||
self.camera.stop()
|
||||
self.trigger.stop()
|
||||
|
||||
self.trigger.begin()
|
||||
ok = self.camera.begin()
|
||||
if not ok:
|
||||
raise RuntimeError("Falha ao reinicializar câmeras após reconfiguração")
|
||||
|
||||
self.state.initialized = True
|
||||
self.state.streaming = False
|
||||
self.state.status = "ready"
|
||||
self.state.status_detail = None
|
||||
self.state.last_error = None
|
||||
|
||||
if hasattr(self.camera, "_reconfigure_needed"):
|
||||
self.camera._reconfigure_needed = False
|
||||
|
||||
def _get_active_cameras(self):
|
||||
return [cam for cam in self.state.cameras if cam.connected and cam.enabled]
|
||||
|
||||
def _get_primary_camera(self):
|
||||
"""
|
||||
Preferência de câmera 'primária':
|
||||
1. RGB
|
||||
2. RE
|
||||
3. NIR
|
||||
4. primeira ativa
|
||||
5. primeira cadastrada
|
||||
"""
|
||||
for role in ("rgb", "re", "nir"):
|
||||
cam = self.state.get_active_camera_by_role(role)
|
||||
if cam is not None:
|
||||
return cam
|
||||
|
||||
active = self._get_active_cameras()
|
||||
if active:
|
||||
return active[0]
|
||||
|
||||
if self.state.cameras:
|
||||
return self.state.cameras[0]
|
||||
|
||||
return None
|
||||
|
||||
def _payload_dict(self):
|
||||
payload = self.state.payload
|
||||
return {
|
||||
"payload_format_version": self.state.payload_format_version,
|
||||
"frame_type": self.state.frame_type,
|
||||
"output_dtype": self.state.output_dtype,
|
||||
"output_layout": payload.layout,
|
||||
"output_channels": payload.channels,
|
||||
"output_channel_names": payload.channel_names,
|
||||
"output_width": payload.width,
|
||||
"output_height": payload.height,
|
||||
"payload_sources": payload.sources,
|
||||
"payload_complete": payload.complete,
|
||||
}
|
||||
|
||||
def _source_dict(self):
|
||||
primary = self._get_primary_camera()
|
||||
|
||||
if primary is None:
|
||||
return {
|
||||
"source_camera": None,
|
||||
"source_width": 0,
|
||||
"source_height": 0,
|
||||
"source_bayer_pattern": None,
|
||||
"source_bit_depth": None,
|
||||
"source_cameras": [],
|
||||
}
|
||||
|
||||
source_cameras = []
|
||||
for cam_id in self.state.payload.sources:
|
||||
cam = self.state.get_camera(cam_id)
|
||||
if cam is None:
|
||||
continue
|
||||
|
||||
source_cameras.append({
|
||||
"id": cam.id,
|
||||
"index": cam.index,
|
||||
"role": cam.role,
|
||||
"interface": cam.interface,
|
||||
"width": cam.width,
|
||||
"height": cam.height,
|
||||
"bayer_pattern": cam.bayer_pattern,
|
||||
"bit_depth": cam.bit_depth,
|
||||
"connected": cam.connected,
|
||||
"enabled": cam.enabled,
|
||||
"available": cam.available,
|
||||
"model": cam.model,
|
||||
"serial": cam.serial,
|
||||
})
|
||||
|
||||
return {
|
||||
"source_camera": primary.id,
|
||||
"source_width": primary.width,
|
||||
"source_height": primary.height,
|
||||
"source_bayer_pattern": primary.bayer_pattern,
|
||||
"source_bit_depth": primary.bit_depth,
|
||||
"source_cameras": source_cameras,
|
||||
}
|
||||
|
||||
def _build_config_response(self):
|
||||
return {
|
||||
"ok": True,
|
||||
"module": self.state.module_name,
|
||||
"version": self.state.version,
|
||||
"status": self.state.status,
|
||||
"initialized": self.state.initialized,
|
||||
"streaming": self.state.streaming,
|
||||
|
||||
"fps": self.state.fps,
|
||||
"jpeg_quality": self.state.jpeg_quality,
|
||||
|
||||
"capture_mode": self.state.capture_mode,
|
||||
"detected_mode": self.state.detected_mode,
|
||||
|
||||
"camera_count_detected": self.state.camera_count_detected,
|
||||
"camera_count_active": self.state.camera_count_active,
|
||||
"active_camera_ids": self.state.active_camera_ids,
|
||||
|
||||
"has_re": self.state.has_re,
|
||||
"has_nir": self.state.has_nir,
|
||||
"has_rgb": self.state.has_rgb,
|
||||
"has_multispec_pair": self.state.has_multispec_pair,
|
||||
"has_full_multispec": self.state.has_full_multispec,
|
||||
|
||||
"re_camera_id": self.state.re_camera_id,
|
||||
"nir_camera_id": self.state.nir_camera_id,
|
||||
"rgb_camera_id": self.state.rgb_camera_id,
|
||||
|
||||
"cameras": [cam.to_dict() for cam in self.state.cameras],
|
||||
|
||||
**self._payload_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
# =========================================================
|
||||
# Comandos
|
||||
# =========================================================
|
||||
|
||||
def handle_command(self, msg: dict) -> dict:
|
||||
with self.lock:
|
||||
cmd = msg.get("cmd")
|
||||
self.state.last_command = cmd
|
||||
|
||||
try:
|
||||
if cmd == "ping":
|
||||
return {"ok": True, "reply": "pong"}
|
||||
|
||||
if cmd == "get_status":
|
||||
return {"ok": True, **self.state.to_dict()}
|
||||
|
||||
if cmd == "get_config":
|
||||
return self._build_config_response()
|
||||
|
||||
if cmd == "begin":
|
||||
frame_type = parse_frame_type(msg.get("frame_type", self.state.frame_type))
|
||||
output_dtype = parse_output_dtype(msg.get("output_dtype", self.state.output_dtype))
|
||||
capture_mode = parse_capture_mode(msg.get("capture_mode", self.state.capture_mode))
|
||||
|
||||
self.state.frame_type = frame_type
|
||||
self.state.output_dtype = output_dtype
|
||||
self.state.capture_mode = capture_mode
|
||||
self.state.update_payload_spec()
|
||||
|
||||
if self.state.initialized:
|
||||
self._restart_module_if_needed()
|
||||
return {
|
||||
"ok": True,
|
||||
"status": self.state.status,
|
||||
"initialized": True,
|
||||
**self._payload_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
self.state.status = "initializing"
|
||||
self.state.status_detail = None
|
||||
|
||||
self.trigger.begin()
|
||||
ok = self.camera.begin()
|
||||
if not ok:
|
||||
raise RuntimeError("Falha ao inicializar câmeras")
|
||||
|
||||
self.state.initialized = True
|
||||
self.state.streaming = False
|
||||
self.state.status = "ready"
|
||||
self.state.last_error = None
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"status": self.state.status,
|
||||
"initialized": True,
|
||||
**self._payload_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
if cmd == "stop":
|
||||
self.state.status = "stopping"
|
||||
|
||||
if getattr(self.stream_sender, "is_running", False):
|
||||
self.stream_sender.stop()
|
||||
|
||||
self.camera.stop()
|
||||
self.trigger.stop()
|
||||
|
||||
self.state.initialized = False
|
||||
self.state.streaming = False
|
||||
self.state.status = "idle"
|
||||
self.state.status_detail = None
|
||||
|
||||
return {"ok": True, "status": self.state.status}
|
||||
|
||||
if cmd == "capture_frame":
|
||||
if not self.state.initialized:
|
||||
return {"ok": False, "error": "Módulo não inicializado"}
|
||||
|
||||
self._restart_module_if_needed()
|
||||
|
||||
data = self.frame_service.capture_frame_base64()
|
||||
return {"ok": True, **data}
|
||||
|
||||
if cmd == "set_fps":
|
||||
value = int(msg.get("value"))
|
||||
if value <= 0 or value > 120:
|
||||
return {"ok": False, "error": "fps inválido"}
|
||||
|
||||
self.state.fps = value
|
||||
self._mark_reconfigure_needed()
|
||||
return {"ok": True, "fps": self.state.fps}
|
||||
|
||||
if cmd == "set_jpeg_quality":
|
||||
value = int(msg.get("value"))
|
||||
if value < 1 or value > 100:
|
||||
return {"ok": False, "error": "jpeg_quality inválido"}
|
||||
|
||||
self.state.jpeg_quality = value
|
||||
return {"ok": True, "jpeg_quality": self.state.jpeg_quality}
|
||||
|
||||
if cmd == "set_frame_type":
|
||||
frame_type = parse_frame_type(msg.get("value"))
|
||||
self.state.frame_type = frame_type
|
||||
self.state.update_payload_spec()
|
||||
self._mark_reconfigure_needed()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"frame_type": self.state.frame_type,
|
||||
**self._payload_dict(),
|
||||
}
|
||||
|
||||
if cmd == "set_output_dtype":
|
||||
output_dtype = parse_output_dtype(msg.get("value"))
|
||||
self.state.output_dtype = output_dtype
|
||||
self.state.update_payload_spec()
|
||||
self._mark_reconfigure_needed()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"output_dtype": self.state.output_dtype,
|
||||
**self._payload_dict(),
|
||||
}
|
||||
|
||||
if cmd == "set_capture_mode":
|
||||
capture_mode = parse_capture_mode(msg.get("value"))
|
||||
self.state.capture_mode = capture_mode
|
||||
self.state.update_payload_spec()
|
||||
self._mark_reconfigure_needed()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"capture_mode": self.state.capture_mode,
|
||||
"detected_mode": self.state.detected_mode,
|
||||
**self._payload_dict(),
|
||||
}
|
||||
|
||||
if cmd == "set_camera_enabled":
|
||||
index = int(msg.get("index"))
|
||||
enabled = parse_bool(msg.get("enabled"))
|
||||
self.state.set_camera_enabled(index, enabled)
|
||||
self._mark_reconfigure_needed()
|
||||
|
||||
cam = self.state.get_camera_by_index(index)
|
||||
return {
|
||||
"ok": True,
|
||||
"camera": cam.to_dict(),
|
||||
"camera_count_active": self.state.camera_count_active,
|
||||
"active_camera_ids": self.state.active_camera_ids,
|
||||
**self._payload_dict(),
|
||||
}
|
||||
|
||||
if cmd == "set_camera_bayer":
|
||||
index = int(msg.get("index"))
|
||||
pattern = parse_bayer_pattern(msg.get("pattern"))
|
||||
|
||||
cam = self.state.get_camera_by_index(index)
|
||||
if cam is None:
|
||||
return {"ok": False, "error": f"Câmera de índice {index} não existe"}
|
||||
|
||||
cam.bayer_pattern = pattern
|
||||
self.state.update_payload_spec()
|
||||
self._mark_reconfigure_needed()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"camera": cam.to_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
if cmd == "set_camera_resolution":
|
||||
index = int(msg.get("index"))
|
||||
width = int(msg.get("width"))
|
||||
height = int(msg.get("height"))
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
return {"ok": False, "error": "resolução inválida"}
|
||||
|
||||
cam = self.state.get_camera_by_index(index)
|
||||
if cam is None:
|
||||
return {"ok": False, "error": f"Câmera de índice {index} não existe"}
|
||||
|
||||
cam.width = width
|
||||
cam.height = height
|
||||
self.state.update_payload_spec()
|
||||
self._mark_reconfigure_needed()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"camera": cam.to_dict(),
|
||||
**self._payload_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
if cmd == "start_stream":
|
||||
host = msg.get("host")
|
||||
port = int(msg.get("port"))
|
||||
fps = float(msg.get("fps", self.state.fps))
|
||||
|
||||
if not host:
|
||||
return {"ok": False, "error": "host obrigatório"}
|
||||
if port <= 0 or port > 65535:
|
||||
return {"ok": False, "error": "porta inválida"}
|
||||
if fps <= 0:
|
||||
return {"ok": False, "error": "fps inválido"}
|
||||
if not self.state.initialized:
|
||||
return {"ok": False, "error": "Módulo não inicializado"}
|
||||
|
||||
self._restart_module_if_needed()
|
||||
|
||||
if getattr(self.stream_sender, "is_running", False):
|
||||
return {
|
||||
"ok": True,
|
||||
"streaming": True,
|
||||
"host": self.state.stream_host,
|
||||
"port": self.state.stream_port,
|
||||
"fps": self.state.stream_fps,
|
||||
**self._payload_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
self.stream_sender.start(host, port, fps)
|
||||
self.state.streaming = True
|
||||
self.state.stream_host = host
|
||||
self.state.stream_port = port
|
||||
self.state.stream_fps = fps
|
||||
self.state.status = "streaming"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"streaming": True,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"fps": fps,
|
||||
**self._payload_dict(),
|
||||
**self._source_dict(),
|
||||
}
|
||||
|
||||
if cmd == "stop_stream":
|
||||
if getattr(self.stream_sender, "is_running", False):
|
||||
self.stream_sender.stop()
|
||||
|
||||
self.state.streaming = False
|
||||
self.state.stream_host = None
|
||||
self.state.stream_port = None
|
||||
self.state.stream_fps = None
|
||||
self.state.status = "ready" if self.state.initialized else "idle"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"streaming": False,
|
||||
"status": self.state.status
|
||||
}
|
||||
|
||||
if cmd == "set_ae_enable":
|
||||
self.state.ae_enable = parse_bool(msg.get("value"))
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "ae_enable": self.state.ae_enable}
|
||||
|
||||
if cmd == "set_awb_enable":
|
||||
self.state.awb_enable = parse_bool(msg.get("value"))
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "awb_enable": self.state.awb_enable}
|
||||
|
||||
if cmd == "set_exposure_time":
|
||||
value = msg.get("value")
|
||||
if value is not None:
|
||||
value = int(value)
|
||||
if value <= 0:
|
||||
return {"ok": False, "error": "ExposureTime inválido"}
|
||||
|
||||
self.state.exposure_time_us = value
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "exposure_time_us": self.state.exposure_time_us}
|
||||
|
||||
if cmd == "set_analogue_gain":
|
||||
value = msg.get("value")
|
||||
if value is not None:
|
||||
value = float(value)
|
||||
if value <= 0:
|
||||
return {"ok": False, "error": "AnalogueGain inválido"}
|
||||
|
||||
self.state.analogue_gain = value
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "analogue_gain": self.state.analogue_gain}
|
||||
|
||||
if cmd == "set_colour_gains":
|
||||
r_gain = msg.get("r_gain")
|
||||
b_gain = msg.get("b_gain")
|
||||
|
||||
if r_gain is None or b_gain is None:
|
||||
return {"ok": False, "error": "r_gain e b_gain são obrigatórios"}
|
||||
|
||||
r_gain = float(r_gain)
|
||||
b_gain = float(b_gain)
|
||||
|
||||
if r_gain <= 0 or b_gain <= 0:
|
||||
return {"ok": False, "error": "ColourGains inválidos"}
|
||||
|
||||
self.state.colour_gains = [r_gain, b_gain]
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "colour_gains": self.state.colour_gains}
|
||||
|
||||
if cmd == "clear_exposure_time":
|
||||
self.state.exposure_time_us = None
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "exposure_time_us": None}
|
||||
|
||||
if cmd == "clear_analogue_gain":
|
||||
self.state.analogue_gain = None
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "analogue_gain": None}
|
||||
|
||||
if cmd == "clear_colour_gains":
|
||||
self.state.colour_gains = None
|
||||
if self.camera.initialized:
|
||||
self.camera.apply_controls()
|
||||
return {"ok": True, "colour_gains": None}
|
||||
|
||||
if cmd == "get_camera_controls":
|
||||
return {
|
||||
"ok": True,
|
||||
"ae_enable": self.state.ae_enable,
|
||||
"awb_enable": self.state.awb_enable,
|
||||
"exposure_time_us": self.state.exposure_time_us,
|
||||
"analogue_gain": self.state.analogue_gain,
|
||||
"colour_gains": self.state.colour_gains,
|
||||
"fps": self.state.fps,
|
||||
}
|
||||
|
||||
if cmd == "get_sensor_modes":
|
||||
modes = self.camera.get_sensor_modes()
|
||||
return {"ok": True, "sensor_modes": modes}
|
||||
|
||||
return {"ok": False, "error": f"Comando desconhecido: {cmd}"}
|
||||
|
||||
except Exception as e:
|
||||
self.state.set_error(str(e))
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# =========================================================
|
||||
# Rede TCP
|
||||
# =========================================================
|
||||
|
||||
def client_thread(self, conn, addr):
|
||||
print(f"[INFO] Cliente conectado: {addr}")
|
||||
buffer = b""
|
||||
|
||||
try:
|
||||
conn.settimeout(1.0)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
chunk = conn.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
buffer += chunk
|
||||
|
||||
while b"\n" in buffer:
|
||||
line, buffer = buffer.split(b"\n", 1)
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = decode_message(line.decode("utf-8"))
|
||||
response = self.handle_command(msg)
|
||||
except Exception as e:
|
||||
response = {"ok": False, "error": str(e)}
|
||||
|
||||
conn.sendall(encode_message(response))
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERRO] Cliente {addr}: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[INFO] Cliente desconectado: {addr}")
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
|
||||
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server_socket.bind((self.host, self.port))
|
||||
server_socket.listen(5)
|
||||
server_socket.settimeout(1.0)
|
||||
|
||||
print(f"[INFO] Servidor ouvindo em {self.host}:{self.port}")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
conn, addr = server_socket.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
t = threading.Thread(
|
||||
target=self.client_thread,
|
||||
args=(conn, addr),
|
||||
daemon=True
|
||||
)
|
||||
t.start()
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import time
|
||||
from cam_3.multispectral_service import MultiSpectralService
|
||||
|
||||
svc = MultiSpectralService(host="192.168.105.6", port=5000)
|
||||
|
||||
svc.connect()
|
||||
|
||||
print("PING:", svc.ping())
|
||||
print("STATUS:", svc.get_status())
|
||||
print("SET FPS:", svc.set_fps(15))
|
||||
print("SET JPG:", svc.set_jpeg_quality(85))
|
||||
print("SET RES:", svc.set_resolution(1280, 720))
|
||||
print("BEGIN:", svc.begin())
|
||||
jpg = None
|
||||
for i in range(1, 6): # Começa em 1 e vai até 5
|
||||
t0 = time.time()
|
||||
jpg = svc.capture_jpg_base64()
|
||||
status = "OK" if jpg is not None else "Falha"
|
||||
tempo = time.time() - t0
|
||||
print(f"CAPTURE {i} JPG: {status}, Tempo: {tempo:.4f}")
|
||||
print("STOP:", svc.stop())
|
||||
print("CONFIG:", svc.get_config())
|
||||
print("STATUS FINAL:", svc.get_status())
|
||||
|
||||
svc.disconnect()
|
||||
|
||||
if jpg is not None:
|
||||
with open("capture.jpg", "wb") as f:
|
||||
f.write(jpg)
|
||||
Loading…
Reference in New Issue