7fa5bacdfe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
265 lines
12 KiB
Python
265 lines
12 KiB
Python
"""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=<src> -> 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=<src> -> 204 (toglie JSON e mappa)
|
|
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
|
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 <cartella immagini>] \
|
|
[--config-dir <cartella calibrazioni>]
|
|
--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 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
|
|
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",)
|
|
|
|
# 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 _grab(nome: str, timeout: int):
|
|
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
|
try:
|
|
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
|
with lock:
|
|
sorgente = aperte.get(nome)
|
|
if sorgente is None:
|
|
sorgente = open_source(nome, None, config=_config())
|
|
with lock:
|
|
aperte[nome] = sorgente
|
|
return sorgente.grab(timeout)
|
|
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)))
|
|
|
|
@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)}
|
|
|
|
@app.get("/calibrazione", response_class=HTMLResponse)
|
|
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()
|