Files
TieMeasureFlow/scripts/camera_service.py
T

795 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
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.
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
400 se manca source
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
su disco; scarta i frame tenuti (anteprima/blocca).
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
per sorgente in orientamento.json (#110) (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).
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
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 json # #110: orientamento.json
import logging
import math # #95: mm/px finito nella calibrazione manuale
import os
import re # #92: nome sicuro per le immagini caricate
import sys
import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
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 _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
# #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
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
_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 = {}
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
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()
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
def _carica_orientamenti() -> dict:
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
if not file_orientamento.is_file():
return {}
try:
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
if not isinstance(dati, dict):
raise ValueError("non e' un oggetto JSON")
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
except (OSError, ValueError) as e:
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
return {}
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
def _specchia(nome: str, frame):
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
with lock:
o = orientamenti.get(nome) or {}
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
if orizz and vert:
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
if orizz:
return cv2.flip(frame, 1)
if vert:
return cv2.flip(frame, 0)
return frame
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())
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
cache_camere: dict = {"voci": None, "t": 0.0}
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
def _camere(aggiorna: bool = False) -> list:
with lock_ricerca:
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
if fresca and not aggiorna:
return cache_camere["voci"]
voci = list_sources(None, cameras=True, config=_config())
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
cache_camere["t"] = time.monotonic()
return cache_camere["voci"]
def _prima_ricerca() -> None:
try:
_camere()
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
_log.exception("prima ricerca camere fallita")
@asynccontextmanager
async def _vita(_app):
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
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(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
elenco = list(_camere(aggiorna))
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco}
def _chiudi(nome: str) -> None:
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
with _lock_di(nome):
with lock:
s = aperte.pop(nome, None)
grezzi.pop(nome, None)
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
if s is not None:
try:
s.close()
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
pass
def _nome_immagine(grezzo: str) -> str:
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
base = Path((grezzo or "").replace("\\", "/")).name
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
radice, punto, estensione = pulito.rpartition(".")
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
return ""
return f"{radice[:100]}.{estensione}"
@app.post("/camera/immagini", status_code=201)
async def immagini_carica(image: UploadFile | None = File(None)):
if image is None:
raise HTTPException(400, "manca il file «image»")
nome = _nome_immagine(image.filename or "")
if not nome:
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
contenuto = await image.read()
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
raise HTTPException(422, "il file caricato non è leggibile come immagine")
cartella_immagini.mkdir(parents=True, exist_ok=True)
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
@app.delete("/camera/immagini", status_code=204)
def immagini_elimina(nome: str = ""):
percorso = cartella_immagini / nome
# #92: solo il nome di un file dentro la cartella, niente percorsi
if not nome or Path(nome).name != nome or not percorso.is_file():
raise HTTPException(404, "immagine non trovata")
_chiudi(nome)
percorso.unlink()
return Response(status_code=204)
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, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
sorgente.loop = True
# 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: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
@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)
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
):
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")
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
nome = _sorgente(source)
with lock:
frame = bloccati.get(nome)
if frame is None:
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
origine = nome
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"],
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
# ---- #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.post("/camera/calibrazioni/manuale", status_code=201)
def calibrazioni_manuale(corpo: dict = Body(default=None)):
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
mm_px = corpo.get("mm_per_px")
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
raise HTTPException(400, "mm/px deve essere un numero positivo")
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:
frame = grezzi.get(sorgente)
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
frame = _grab(sorgente, 5000)
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
"image_width": larg, "image_height": alt, "origine": sorgente,
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
try:
return archivio.salva_nome(nome, dati, None, 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")
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
f = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
with lock:
grezzi[nome] = f.copy()
return f
frame = _con_sorgente(nome, leggi)
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.post("/camera/blocca")
def camera_blocca(corpo: dict = Body(default=None)):
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
bloccati[nome] = frame.copy()
alt, larg = frame.shape[:2]
return {"source": nome, "larghezza": larg, "altezza": alt}
@app.delete("/camera/blocca", status_code=204)
def camera_sblocca(source: str = ""):
with lock: # #94: idempotente, 204 anche se non c'era nulla
bloccati.pop(source.strip(), None)
return Response(status_code=204)
@app.post("/camera/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
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")
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
attiva = archivio.attiva_di(nome)
if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome)
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
dati = archivio.leggi(nome) or {}
cw, ch = dati.get("image_width"), dati.get("image_height")
alt, larg = frame.shape[:2]
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
corretto, raddrizzato = frame, "no"
else:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
raddrizzato = "si"
alt, larg = corretto.shape[:2]
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
corretto = _grigio8(corretto)
scala = lato / max(alt, larg)
if scala < 1.0: # mai ingrandita
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", corretto, [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": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@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)
# ---- #110: specchiatura orizzontale/verticale per sorgente
def _orientamento_di(nome: str) -> dict:
with lock:
o = orientamenti.get(nome) or {}
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
@app.get("/camera/orientamento")
def orientamento_leggi(source: str = ""):
return _orientamento_di(_sorgente(source))
@app.put("/camera/orientamento")
def orientamento_scrivi(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
if not isinstance(orizz, bool) or not isinstance(vert, bool):
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
with lock:
prima = orientamenti.get(nome) or {}
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
if orizz or vert:
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
else:
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
# scrittura atomica di tutto il dizionario (tmp + replace)
try:
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, file_orientamento)
except OSError as e:
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
# i frame tenuti hanno l'orientamento di prima
grezzi.pop(nome, None)
bloccati.pop(nome, None)
avviso = None
if cambiato and archivio.leggi_mappa(nome) is not None:
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
"con l'orientamento di prima: va rifatta")
return {**_orientamento_di(nome), "avviso": avviso}
@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()