diff --git a/scripts/calibrazione_pagina.py b/scripts/calibrazione_pagina.py
index 33a9409..500ddb2 100644
--- a/scripts/calibrazione_pagina.py
+++ b/scripts/calibrazione_pagina.py
@@ -3,6 +3,8 @@
Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in
italiano. Parla con le rotte dello stesso servizio (/camera/sources,
/camera/calibration, /camera/calibrate).
+#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm):
+scacchiera che riempie il campo, griglia trovata da sola, mappa di distorsione.
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
"""
@@ -41,20 +43,18 @@ PAGINA = """
-
-
-
+
- Gli angoli interni sono quelli dove si toccano quattro quadrati: una scacchiera di 10 x 7 quadrati ha 9 x 6 angoli.
- Tenere la scacchiera piana e ferma, ben illuminata.
+ Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
+ La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.
-
+
-
+
@@ -64,7 +64,7 @@ PAGINA = """
-
+
Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.
@@ -95,12 +95,19 @@ function numero(v, cifre) { return Number(v).toFixed(cifre); }
function righe(d) {
return [
["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"],
+ ["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
+ ["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + d.n_corners + ")"]
+ ].concat(d.metodo !== "densa" ? [
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
- ["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"],
- ["Angoli trovati", d.n_corners + " (" + d.cols + " x " + d.rows + ")"],
- ["Lato del quadrato", d.square_mm + " mm"],
+ ["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"]
+ ] : [
+ ["Residuo massimo", numero(d.residuo_max_px, 3) + " px"],
+ ["Residuo RMS", numero(d.residuo_rms_px, 3) + " px"],
+ ["Copertura del campo", numero(d.copertura_pct, 1) + " %"]
+ ]).concat([
+ ["Lato dello scacco", d.square_mm + " mm"],
["Immagine", d.image_width + " x " + d.image_height + " px"]
- ];
+ ]);
}
function tabella(el, d) {
@@ -157,9 +164,19 @@ async function aggiornaStato() {
}
function parametri() {
- return {
- cols: $("cols").value, rows: $("rows").value, quadrato: $("quadrato").value
- };
+ return { quadrato: $("quadrato").value };
+}
+
+// avvisi sulla qualita' (solo calibrazione densa): copertura bassa o residuo alto
+function avvisi(d) {
+ var a = [];
+ if (d.metodo !== "densa") {
+ if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica.");
+ } else {
+ if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + numero(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non e' misurata.");
+ if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + numero(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari.");
+ }
+ return a.join(" ");
}
async function calcola(conFile, salva) {
@@ -168,8 +185,6 @@ async function calcola(conFile, salva) {
var p = parametri();
var f = new FormData();
f.append("source", sorgente);
- f.append("cols", p.cols);
- f.append("rows", p.rows);
f.append("square_mm", p.quadrato);
f.append("save", salva ? "true" : "false");
if (conFile) {
@@ -186,11 +201,9 @@ async function calcola(conFile, salva) {
ultimo = { conFile: conFile, dati: d };
tabella($("tabella"), d);
$("overlay").src = "data:image/png;base64," + d.overlay_png;
- $("avviso").textContent = d.scale_spread_pct > 1
- ? "Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica."
- : "";
+ $("avviso").textContent = avvisi(d);
$("risultato").hidden = false;
- messaggio(d.saved ? "Calibrazione salvata." : "Calcolo riuscito: controllare e salvare.", "ok");
+ messaggio(d.saved ? "Calibrazione salvata." : "Calibrazione riuscita: controllare e salvare.", "ok");
if (d.saved) aggiornaStato();
} catch (e) {
messaggio("Servizio non raggiungibile.", "errore");
diff --git a/scripts/camera_service.py b/scripts/camera_service.py
index ad4e894..3f221e9 100644
--- a/scripts/camera_service.py
+++ b/scripts/camera_service.py
@@ -4,18 +4,27 @@ 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} -> PNG
+ 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= -> 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;
+ 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= -> 204
+ DELETE /camera/calibration?source= -> 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).
@@ -158,14 +167,25 @@ def create_app(folder: Path | str | None = None,
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")
+ return Response(buf.tobytes(), media_type="image/png",
+ headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
# ---- #83: calibrazione a scacchiera per sorgente
@@ -189,9 +209,9 @@ def create_app(folder: Path | str | None = None,
@app.post("/camera/calibrate")
async def calibrazione_calcola(
source: str = Form(...),
- cols: int = Form(...),
- rows: int = 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),
):
@@ -203,15 +223,22 @@ def create_app(folder: Path | str | None = 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:
- risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
+ 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)
+ 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)
diff --git a/scripts/tests/test_calibrazione.py b/scripts/tests/test_calibrazione.py
index 726ec1a..12bcb29 100644
--- a/scripts/tests/test_calibrazione.py
+++ b/scripts/tests/test_calibrazione.py
@@ -36,6 +36,27 @@ def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int =
return img
+def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
+ """#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
+ yy, xx = np.mgrid[0:h, 0:w]
+ piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
+ cx, cy = w / 2.0, h / 2.0
+ dx, dy = (xx - cx).astype(np.float32), (yy - cy).astype(np.float32)
+ f = 1 + k * (dx * dx + dy * dy)
+ img = cv2.remap(piana, cx + dx * f, cy + dy * f, cv2.INTER_LINEAR, borderValue=255)
+ return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
+
+
+def residuo_reticolo(griglia: np.ndarray) -> float:
+ """Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
+ nr, nc, _ = griglia.shape
+ ii, jj = np.mgrid[0:nr, 0:nc]
+ A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
+ P = griglia.reshape(-1, 2)
+ coef, *_ = np.linalg.lstsq(A, P, rcond=None)
+ return float(np.hypot(*(A @ coef - P).T).max())
+
+
def _png(img: np.ndarray) -> bytes:
ok, buf = cv2.imencode(".png", img)
assert ok
@@ -62,6 +83,29 @@ def client(cartella, tmp_path):
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
CHIAVI_C1 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
+# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
+CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione"}
+CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
+SORGENTE = "camera:folder"
+
+
+@pytest.fixture
+def cartella_densa(tmp_path):
+ # #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
+ cartella = tmp_path / "immagini_dense"
+ cartella.mkdir()
+ cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
+ return cartella
+
+
+@pytest.fixture
+def client_denso(cartella_densa, tmp_path):
+ mod = _carica()
+ with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
+ c.config = tmp_path / "config_densa"
+ yield c
+
+
NON_IMMAGINE = b"questo non e una immagine"
@@ -112,7 +156,8 @@ def test_c2_immagine_caricata_senza_salvare(client):
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
corpo = r.json()
- assert CHIAVI_C1 | {"overlay_png", "saved"} == set(corpo)
+ assert CHIAVI_PLANARE | {"overlay_png", "saved"} == set(corpo)
+ assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["saved"] is False
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# senza save non si scrive nulla
@@ -129,7 +174,7 @@ def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r1.status_code == 200
- assert set(r1.json()) == CHIAVI_C1 # nessun overlay nel file salvato
+ assert set(r1.json()) == CHIAVI_PLANARE # nessun overlay nel file salvato
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r1.json()["source"] == "camera:folder"
file_salvati = list(client.config.glob("*.json"))
@@ -180,5 +225,151 @@ def test_c4_pagina_html(client):
r = client.get("/calibrazione")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
- assert "Acquisisci e calcola" in r.text
+ assert "Acquisisci e calibra" in r.text
+ assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
+
+
+# ---- #84: calibrazione densa (logica pura)
+
+def test_densa_trova_la_griglia_da_sola_e_la_scala():
+ _carica()
+ import calibrazione
+ r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
+ assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
+ assert r["n_corners"] == 15 * 11
+ assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
+ assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
+ assert r["metodo"] == "densa" and r["distorsione"] is True
+ assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
+ assert 70 < r["copertura_pct"] <= 100
+ assert (r["image_width"], r["image_height"]) == (640, 480)
+ assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
+ assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
+
+
+def test_densa_raddrizza_su_griglia_regolare():
+ _carica()
+ import calibrazione
+ img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
+ r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
+ assert (r["cols"], r["rows"]) == (15, 11)
+ # prima la griglia e' curva, dopo e' un reticolo regolare
+ assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
+ pronta = calibrazione._prepara_mappa(mappa)
+ dritta = calibrazione.raddrizza(img, pronta)
+ assert dritta.shape == img.shape
+ assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
+
+
+@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
+def test_densa_senza_griglia_solleva(cosa):
+ _carica()
+ import calibrazione
+ img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
+ else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
+ with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
+ calibrazione.calibra_scacchiera_densa(img, 2.0)
+
+
+@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
+def test_densa_quadrato_invalido_solleva(quadrato):
+ _carica()
+ import calibrazione
+ with pytest.raises(calibrazione.CalibrazioneError):
+ calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
+
+
+def test_archivio_mappa_cache_e_invalidazione(tmp_path):
+ _carica()
+ import calibrazione
+ _, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
+ arch = calibrazione.Archivio(tmp_path)
+ assert arch.leggi_mappa("s") is None
+ arch.salva("s", {"a": 1}, mappa)
+ prima = arch.leggi_mappa("s")
+ assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria
+ assert not list(tmp_path.glob("*.tmp*")) # niente temporanei rimasti
+ arch.salva("s", {"a": 2}, mappa) # file riscritto -> cache invalidata
+ assert arch.leggi_mappa("s") is not prima
+ arch.salva("s", {"a": 3}) # salvataggio senza mappa (planare): la vecchia sparisce
+ assert arch.leggi_mappa("s") is None and not list(tmp_path.glob("*.npz"))
+
+
+# ---- #84: rotte con la calibrazione densa
+
+def _grigio(r) -> np.ndarray:
+ return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
+
+
+def test_c2_senza_cols_rows_e_densa(client_denso):
+ r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
+ assert r.status_code == 200, r.text
+ corpo = r.json()
+ assert CHIAVI_DENSA | {"overlay_png", "saved"} == set(corpo)
+ assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
+ assert (corpo["cols"], corpo["rows"]) == (15, 11)
+ assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
+ assert corpo["saved"] is False
+ assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
+
+
+def test_c2_solo_cols_o_solo_rows_422(client_denso):
+ r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
+ assert r.status_code == 422
+
+
+def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
+ originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
+ prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
+ assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
+ assert np.array_equal(_grigio(prima), originale)
+
+ r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
+ assert r.status_code == 200, r.text
+ assert r.json()["saved"] is True
+ json_salvati, mappe = list(client_denso.config.glob("*.json")), list(client_denso.config.glob("*.npz"))
+ assert len(json_salvati) == 1 and len(mappe) == 1
+ assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
+ saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
+ assert set(saved) == CHIAVI_DENSA and saved["metodo"] == "densa"
+
+ dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
+ assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
+ import calibrazione
+ dritta = _grigio(dopo)
+ assert dritta.shape == originale.shape
+ assert not np.array_equal(dritta, originale)
+ assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
+
+ grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
+ assert grezzo.headers["x-raddrizzato"] == "no"
+ assert np.array_equal(_grigio(grezzo), originale)
+
+ # la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
+ di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
+ assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
+ assert di_nuovo.json()["residuo_max_px"] < 0.3
+
+
+def test_grab_grezzo_non_booleano_400(client_denso):
+ r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
+ assert r.status_code == 400
+
+
+def test_delete_toglie_anche_la_mappa(client_denso):
+ client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
+ assert list(client_denso.config.glob("*.npz"))
+ assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
+ assert not list(client_denso.config.glob("*.npz")) and not list(client_denso.config.glob("*.json"))
+ assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
+
+
+def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
+ client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
+ assert list(client_denso.config.glob("*.npz"))
+ planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "save": "true", **CAMPI},
+ files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
+ assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
+ assert not list(client_denso.config.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
+ assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"