"""Servizio camera minimo di stazione (TieMeasureFlow, #72). Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL, OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo `grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py): GET /camera/sources -> {"sources": [{name, kind, label}]} POST /camera/grab json {source, trigger="continuo", timeout_ms=5000, grezzo=false} -> PNG #84: se la sorgente ha una mappa di distorsione salvata il frame esce raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è. Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout. Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py): GET /camera/calibration?source= -> 200 {source, px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm, image_width, image_height, scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata" POST /camera/calibrate multipart: source, square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente), save=false, cols/rows (opzionali). #84: SENZA cols/rows = calibrazione densa (default): una foto di scacchiera che riempie il campo, griglia trovata da sola, mappa di distorsione salvata accanto al JSON (.npz); mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON cols/rows (angoli interni) = metodo planare di prima. Campi: quelli di C1 + overlay_png (b64) + saved + metodo ("densa"|"planare"), distorsione (bool) e, per la densa, residuo_rms_px, residuo_max_px, copertura_pct; 422 scacchiera non trovata o parametri invalidi DELETE /camera/calibration?source= -> 204 (toglie JSON e mappa) GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano GET /acquisizione -> stessa pagina (#87) Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default: POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096), timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito). Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO), X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250), X-Media (livello medio 0-255), tutti sul frame pieno in grigio. GET /camera/parametri?source= -> {source, regolabile, parametri: {esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}}; dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte). Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false. PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET. Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3). 400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta POST /camera/parametri/salva json {source} -> {source, salvati, file} scrive /settaggi/-.json; alla prima apertura della camera nel servizio il file, se c'e', viene ricaricato. Il risultato salvato sta in un JSON per sorgente in --config-dir (default %ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove). Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo raggiunge; questo servizio permette di scegliere l'host di ascolto. Uso (dalla radice del repo): vendor/visionsuite/.venv/bin/python scripts/camera_service.py \ [--host 127.0.0.1] [--port 8765] [--folder ] \ [--config-dir ] --folder abilita la sorgente finta `camera:folder` (loop sulle immagini). Da Docker su Linux: --host 172.17.0.1 (gateway di docker0). Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite): vs-camera, fastapi, uvicorn, opencv, python-multipart (rotta /camera/calibrate), visionsuite.calibration (vs-core). AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la camera a chiunque raggiunga la porta: solo su rete isolata. """ from __future__ import annotations import argparse import logging import os import sys import threading from contextlib import asynccontextmanager from pathlib import Path import cv2 import numpy as np from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop from fastapi.responses import HTMLResponse, Response from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError from visionsuite.camera.sources import SourceNotFound, list_sources, open_source # #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto) sys.path.insert(0, str(Path(__file__).resolve().parent)) import calibrazione # noqa: E402 from calibrazione_pagina import PAGINA # noqa: E402 _TRIGGER = ("continuo",) _log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi) # #87: i quattro parametri regolabili, nell'ordine di presentazione _PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto") # #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto) _ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno") # errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso _MESSAGGI = { "Camera is not open": "La camera non è aperta", "Camera is not grabbing": "La camera non sta acquisendo", "Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)", "Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita", } _PREFISSI = ( ("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"), ("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"), ("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"), ("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"), ) def _traduci(messaggio: str) -> str: if messaggio in _MESSAGGI: return _MESSAGGI[messaggio] for inglese, italiano in _PREFISSI: if messaggio.startswith(inglese): return italiano + messaggio[len(inglese):] return messaggio def _config_dir_default() -> Path: """#83: cartella delle calibrazioni salvate.""" if os.name == "nt": base = os.environ.get("ProgramData") or "C:/ProgramData" return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore" return Path("acquisitore") def create_app(folder: Path | str | None = None, config_dir: Path | str | None = None) -> FastAPI: archivio = calibrazione.Archivio(config_dir if config_dir is not None else _config_dir_default()) # #83 aperte: dict = {} lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab def _config() -> dict: if folder is None: return {} return {"folder": {"folder": str(folder), "loop": True}} def _lock_di(nome: str) -> threading.Lock: with lock: return lock_sorgenti.setdefault(nome, threading.Lock()) @asynccontextmanager async def _vita(_app): yield with lock: # alla chiusura rilascia le camere aperte sorgenti = list(aperte.values()) aperte.clear() for s in sorgenti: try: s.close() except Exception: pass app = FastAPI(title="TMF camera service", lifespan=_vita) @app.get("/camera/sources") def camera_sources(): voci = list_sources(None, cameras=True, config=_config()) return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label} for v in voci if v.kind in ("camera", "virtual")]} def _apri(nome: str): """#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome).""" with lock: sorgente = aperte.get(nome) if sorgente is None: sorgente = open_source(nome, None, config=_config()) # TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda if hasattr(sorgente, "solo_ultimo_frame"): try: sorgente.solo_ultimo_frame() except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso _log.warning("frame piu' recente non impostabile su %s: %s", nome, e) _carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono with lock: aperte[nome] = sorgente return sorgente def _file_settaggi(nome: str) -> Path: # #87: stesso schema di nome della calibrazione, in una sottocartella return archivio.cartella / "settaggi" / archivio._file(nome).name def _carica_settaggi(nome: str, sorgente) -> None: percorso = _file_settaggi(nome) if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"): return try: sorgente.carica_settaggi(percorso) except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura _log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e) def _con_sorgente(nome: str, fn): """#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP.""" try: with _lock_di(nome): # solo questa sorgente; le altre non aspettano return fn(_apri(nome)) except SourceNotFound as e: raise HTTPException(404, _traduci(str(e))) except TimeoutError as e: raise HTTPException(504, _traduci(str(e)) or "timeout") except (CameraGrabError, CameraError) as e: raise HTTPException(503, _traduci(str(e))) def _grab(nome: str, timeout: int): """Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP.""" return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente @app.post("/camera/grab") def camera_grab(corpo: dict = Body(default=None)): nome = corpo.get("source") if isinstance(corpo, dict) else None if not isinstance(nome, str) or not nome.strip(): raise HTTPException(400, "manca «source»") nome = nome.strip() trigger = corpo.get("trigger", "continuo") if trigger not in _TRIGGER: raise HTTPException(400, "«trigger» supportato: solo continuo") grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato if not isinstance(grezzo, bool): raise HTTPException(400, "«grezzo» deve essere true o false") timeout = corpo.get("timeout_ms", 5000) if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000: raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000") frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato raddrizzato = False if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache mappa = archivio.leggi_mappa(nome) if mappa is not None: corretto = calibrazione.raddrizza(frame, mappa) if corretto is not None: # None = dimensioni diverse da quelle calibrate frame, raddrizzato = corretto, True ok, buf = cv2.imencode(".png", frame) if not ok: raise HTTPException(500, "codifica PNG fallita") return Response(buf.tobytes(), media_type="image/png", headers={"X-Raddrizzato": "si" if raddrizzato else "no"}) # ---- #83: calibrazione a scacchiera per sorgente def _sorgente(valore) -> str: if not isinstance(valore, str) or not valore.strip(): raise HTTPException(400, "manca «source»") return valore.strip() @app.get("/camera/calibration") def calibrazione_leggi(source: str = ""): dati = archivio.leggi(_sorgente(source)) if dati is None: raise HTTPException(404, "sorgente non calibrata") return dati @app.delete("/camera/calibration", status_code=204) def calibrazione_elimina(source: str = ""): archivio.elimina(_sorgente(source)) return Response(status_code=204) @app.post("/camera/calibrate") async def calibrazione_calcola( source: str = Form(...), square_mm: float = Form(...), cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa rows: int | None = Form(None), image: UploadFile | None = File(None), save: bool = Form(False), ): nome = _sorgente(source) contenuto = await image.read() if image is not None else b"" if contenuto: frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) if frame is None: raise HTTPException(422, "l'immagine caricata non è leggibile") else: frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504) if (cols is None) != (rows is None): raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)") mappa = None try: if cols is None: # #84: densa, nessuna griglia da dichiarare risultato, mappa = await run_in_threadpool( calibrazione.calibra_scacchiera_densa, frame, square_mm) else: # metodo planare di #83, invariato risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm) except calibrazione.CalibrazioneError as e: raise HTTPException(422, str(e)) corpo = {"source": nome, **{k: v for k, v in risultato.items() if k != "overlay_png"}, "calibrated_at": calibrazione.ora_iso()} if save: archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)} # ---- #87: acquisizione (anteprima, parametri, settaggi) def _regolabile(s) -> bool: # #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro if not (hasattr(s, "supporta") and hasattr(s, "dominio")): return False return any(s.supporta(n) for n in _PARAMETRI) def _parametri(nome: str, s) -> dict: """#87: forma di GET /camera/parametri; da chiamare sotto lock.""" parametri: dict = {} if _regolabile(s): for n in _PARAMETRI: if not s.supporta(n): continue try: parametri[n] = s.dominio(n) except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta _log.warning("dominio di %s su %s non letto: %s", n, nome, e) return {"source": nome, "regolabile": bool(parametri), "parametri": parametri} def _grigio8(frame): g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti g = (g >> 8).astype(np.uint8) elif g.dtype != np.uint8: g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0)) return g @app.post("/camera/anteprima") def camera_anteprima(corpo: dict = Body(default=None)): corpo = corpo if isinstance(corpo, dict) else {} nome = _sorgente(corpo.get("source")) grezzo = corpo.get("grezzo", False) if not isinstance(grezzo, bool): raise HTTPException(400, "«grezzo» deve essere true o false") lato = corpo.get("lato_max", 1280) if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096: raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096") timeout = corpo.get("timeout_ms", 5000) if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000: raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000") frame = _grab(nome, timeout) raddrizzato = False if not grezzo: # come /camera/grab mappa = archivio.leggi_mappa(nome) if mappa is not None: corretto = calibrazione.raddrizza(frame, mappa) if corretto is not None: frame, raddrizzato = corretto, True alt, larg = frame.shape[:2] grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var()) saturati = float((grigio >= 250).mean() * 100.0) media = float(grigio.mean()) if frame.dtype != np.uint8: # il JPEG vuole 8 bit frame = grigio scala = lato / max(alt, larg) if scala < 1.0: # mai ingrandita frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))), interpolation=cv2.INTER_AREA) ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) if not ok: raise HTTPException(500, "codifica JPEG fallita") return Response(buf.tobytes(), media_type="image/jpeg", headers={ "X-Raddrizzato": "si" if raddrizzato else "no", "X-Larghezza": str(larg), "X-Altezza": str(alt), "X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}", "X-Media": f"{media:.1f}"}) @app.get("/camera/parametri") def parametri_leggi(source: str = ""): nome = _sorgente(source) return _con_sorgente(nome, lambda s: _parametri(nome, s)) @app.put("/camera/parametri") def parametri_scrivi(corpo: dict = Body(default=None)): corpo = corpo if isinstance(corpo, dict) else {} nome = _sorgente(corpo.get("source")) valori = corpo.get("valori") if not isinstance(valori, dict) or not valori: raise HTTPException(400, "manca «valori»") for n, v in valori.items(): if n not in _PARAMETRI: raise HTTPException(400, f"parametro non riconosciuto: «{n}»") if isinstance(v, bool) or not isinstance(v, (int, float, str)): raise HTTPException(400, f"valore non valido per «{n}»") def scrivi(s): if not _regolabile(s): raise HTTPException(409, "questa sorgente non ha parametri regolabili") for n in _ORDINE_SCRITTURA: # prima gli *_auto if n in valori: try: s.scrivi(n, valori[n], assesta=3) except CameraStateError as e: raise HTTPException(422, _traduci(str(e))) return _parametri(nome, s) # valori riletti dalla camera return _con_sorgente(nome, scrivi) @app.post("/camera/parametri/salva") def parametri_salva(corpo: dict = Body(default=None)): nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None) def salva(s): if not _regolabile(s) or not hasattr(s, "salva_settaggi"): raise HTTPException(409, "questa sorgente non ha parametri regolabili") percorso = _file_settaggi(nome) percorso.parent.mkdir(parents=True, exist_ok=True) s.salva_settaggi(percorso) return {"source": nome, "salvati": True, "file": percorso.name} return _con_sorgente(nome, salva) @app.get("/calibrazione", response_class=HTMLResponse) @app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina def calibrazione_pagina(): return HTMLResponse(PAGINA) return app def main() -> None: import uvicorn p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)") p.add_argument("--host", default="127.0.0.1") p.add_argument("--port", type=int, default=8765) p.add_argument("--folder", default=None, help="cartella immagini per camera:folder") p.add_argument("--config-dir", default=None, help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83 a = p.parse_args() uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port) if __name__ == "__main__": main()