0af6692786
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
145 lines
5.8 KiB
Python
145 lines
5.8 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 vendor/visionsuite/packages/vs-gui/.../server.py):
|
|
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.
|
|
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>]
|
|
--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.
|
|
|
|
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 threading
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
from fastapi import Body, FastAPI, HTTPException
|
|
from fastapi.responses import Response
|
|
|
|
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
|
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
|
|
|
_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 create_app(folder: Path | str | None = None) -> FastAPI:
|
|
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")]}
|
|
|
|
@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")
|
|
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)))
|
|
ok, buf = cv2.imencode(".png", frame)
|
|
if not ok:
|
|
raise HTTPException(500, "codifica PNG fallita")
|
|
return Response(buf.tobytes(), media_type="image/png")
|
|
|
|
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")
|
|
a = p.parse_args()
|
|
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|