Acquisitore: calibrazione a scacchiera per sorgente, rotte e pagina /calibrazione (#83)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 22:43:32 +02:00
parent 9f2d624b4f
commit 351739ec36
4 changed files with 660 additions and 21 deletions
+114 -21
View File
@@ -6,17 +6,33 @@ OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
GET /camera/sources -> {"sources": [{name, kind, label}]}
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
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, cols, rows (angoli interni),
square_mm, image (file, opzionale: senza, grab dalla sorgente come
/camera/grab), save=false -> campi di sopra + overlay_png (b64) + saved;
422 scacchiera non trovata o parametri invalidi
DELETE /camera/calibration?source=<src> -> 204
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>]
[--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.
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.
@@ -24,17 +40,26 @@ 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
from fastapi import Body, FastAPI, HTTPException
from fastapi.responses import Response
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
@@ -61,7 +86,18 @@ def _traduci(messaggio: str) -> str:
return messaggio
def create_app(folder: Path | str | None = None) -> FastAPI:
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
@@ -95,6 +131,24 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
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
@@ -107,26 +161,63 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
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")
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
frame = 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)))
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
ok, buf = cv2.imencode(".png", frame)
if not ok:
raise HTTPException(500, "codifica PNG fallita")
return Response(buf.tobytes(), media_type="image/png")
# ---- #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(...),
cols: int = Form(...),
rows: int = Form(...),
square_mm: float = Form(...),
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)
try:
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)
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
@app.get("/calibrazione", response_class=HTMLResponse)
def calibrazione_pagina():
return HTMLResponse(PAGINA)
return app
@@ -136,8 +227,10 @@ def main() -> None:
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), host=a.host, port=a.port)
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
if __name__ == "__main__":