a5e8472e88
/camera/calibrate non salva piu' (calcolo_id in memoria, ultimi 8); nuove rotte GET/POST/DELETE /camera/calibrazioni e PUT /camera/calibrazione-attiva (409 se la risoluzione non combacia col frame della sorgente); DELETE /camera/calibration ora scollega. Test: rotte nuove e test esistenti aggiornati (save=true -> salva con nome). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
512 lines
25 KiB
Python
512 lines
25 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 (#83, logica in scripts/calibrazione.py).
|
||
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
|
||
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
|
||
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
|
||
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
|
||
#84: SENZA cols/rows = calibrazione densa (default): una foto di
|
||
scacchiera che riempie il campo, griglia trovata da sola, mappa di
|
||
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
|
||
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
|
||
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
|
||
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
|
||
calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
|
||
densa, residuo_rms_px, residuo_max_px, copertura_pct;
|
||
422 scacchiera non trovata o parametri invalidi
|
||
GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
|
||
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
|
||
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
|
||
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
|
||
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
|
||
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
|
||
da ogni sorgente) | 404
|
||
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
|
||
sorgente e carica la calibrazione solo se la risoluzione (larghezza x
|
||
altezza) coincide: 409 altrimenti; 404 nome ignoto; 200 come GET sotto
|
||
GET /camera/calibration?source=<src> -> 200 la calibrazione ATTIVA {source,
|
||
nome, px_per_mm, mm_per_px, ..., image_width, image_height,
|
||
calibrated_at} | 404 "sorgente non calibrata" (lo legge il backend)
|
||
DELETE /camera/calibration?source=<src> -> 204 (SCOLLEGA la calibrazione attiva,
|
||
la libreria resta)
|
||
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=<src> -> {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 <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||
della camera nel servizio il file, se c'e', viene ricaricato.
|
||
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (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 logging
|
||
import os
|
||
import sys
|
||
import threading
|
||
import uuid # #91 id dei calcoli in attesa di nome
|
||
from collections import OrderedDict
|
||
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",)
|
||
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||
_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
|
||
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||
lock_calcoli = threading.Lock()
|
||
|
||
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 (#91: quella attiva)
|
||
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)) # #91: la calibrazione ATTIVA della sorgente
|
||
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.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
|
||
return Response(status_code=204)
|
||
|
||
@app.post("/camera/calibrate")
|
||
async def calibrazione_calcola(
|
||
source: str | None = Form(None), # #91: serve solo se manca l'immagine
|
||
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), # #91: ignorato, il calcolo non salva mai (si salva con nome)
|
||
):
|
||
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")
|
||
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
|
||
else:
|
||
nome = _sorgente(source)
|
||
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
|
||
origine = nome
|
||
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 = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
||
"calibrated_at": calibrazione.ora_iso(), "origine": origine}
|
||
calcolo_id = uuid.uuid4().hex
|
||
with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
|
||
calcoli[calcolo_id] = (corpo, mappa)
|
||
while len(calcoli) > _MAX_CALCOLI:
|
||
calcoli.popitem(last=False)
|
||
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
|
||
|
||
# ---- #91: archivio unico di calibrazioni con nome
|
||
|
||
@app.get("/camera/calibrazioni")
|
||
def calibrazioni_elenco(source: str | None = None):
|
||
attiva = archivio.attiva_di(_sorgente(source)) if source is not None else None
|
||
return {"calibrazioni": archivio.elenco(), "attiva": attiva}
|
||
|
||
@app.post("/camera/calibrazioni", status_code=201)
|
||
def calibrazioni_salva(corpo: dict = Body(default=None)):
|
||
corpo = corpo if isinstance(corpo, dict) else {}
|
||
calcolo_id = corpo.get("calcolo_id")
|
||
if not isinstance(calcolo_id, str) or not calcolo_id:
|
||
raise HTTPException(400, "manca «calcolo_id»")
|
||
sovrascrivi = corpo.get("sovrascrivi", False)
|
||
if not isinstance(sovrascrivi, bool):
|
||
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
|
||
try:
|
||
nome = calibrazione.pulisci_nome(corpo.get("nome"))
|
||
except calibrazione.NomeNonValido as e:
|
||
raise HTTPException(400, str(e))
|
||
with lock_calcoli:
|
||
voce = calcoli.get(calcolo_id)
|
||
if voce is None:
|
||
raise HTTPException(404, "calcolo non più disponibile: ricalcolare")
|
||
dati, mappa = voce
|
||
try:
|
||
return archivio.salva_nome(nome, dati, mappa, sovrascrivi)
|
||
except calibrazione.NomeGiaUsato as e:
|
||
raise HTTPException(409, str(e))
|
||
|
||
@app.delete("/camera/calibrazioni", status_code=204)
|
||
def calibrazioni_elimina(nome: str = ""):
|
||
if not archivio.elimina_nome(nome):
|
||
raise HTTPException(404, "calibrazione non trovata")
|
||
return Response(status_code=204)
|
||
|
||
@app.put("/camera/calibrazione-attiva")
|
||
def calibrazione_attiva(corpo: dict = Body(default=None)):
|
||
corpo = corpo if isinstance(corpo, dict) else {}
|
||
sorgente = _sorgente(corpo.get("source"))
|
||
richiesto = corpo.get("nome")
|
||
dati = archivio.leggi_nome(richiesto) if isinstance(richiesto, str) else None
|
||
if dati is None:
|
||
raise HTTPException(404, "calibrazione non trovata")
|
||
nome = dati["nome"]
|
||
frame = _grab(sorgente, 5000) # stesso lock e stessi errori di /camera/grab (404/503/504)
|
||
alt, larg = frame.shape[:2]
|
||
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||
if not isinstance(cw, int) or not isinstance(ch, int):
|
||
raise HTTPException(409, f"la calibrazione «{nome}» non ha la risoluzione registrata: "
|
||
"non si può caricare")
|
||
if (cw, ch) != (larg, alt): # la mappa vale solo per frame della stessa risoluzione
|
||
raise HTTPException(409, f"la calibrazione «{nome}» è per immagini {cw}×{ch}, "
|
||
f"la sorgente dà {larg}×{alt}: non si può caricare")
|
||
archivio.attiva(sorgente, nome)
|
||
return archivio.leggi(sorgente)
|
||
|
||
# ---- #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()
|