- pm2d/dxf.py: rasterizzazione DXF -> template (ezdxf, flattening entita', scala/centratura, render edge antialiased) - POST /upload_dxf: carica CAD come modello (size 128..2048) - roi_poly su /match, /match_simple e POST /recipes: train con mask cv2.fillPoly (validazione 400 su poligoni degeneri), cache key inclusa - UI: upload .dxf, modalita' ROI poligonale su canvas (click=vertice, dblclick=chiudi, reset), bottone Esporta JSON dei risultati - .gitea/workflows/ci.yml: uv sync + ruff + pytest su push/PR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
# CI Gitea Actions: lint (ruff) + test sintetici (pytest).
|
||||||
|
# I test non richiedono le immagini in Test/ (sono generati a runtime).
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Installa uv
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync dipendenze
|
||||||
|
run: uv sync
|
||||||
|
|
||||||
|
- name: Lint (ruff)
|
||||||
|
# Ignore da CLI (pyproject.toml non va toccato): E501/E741 +
|
||||||
|
# stile pre-esistente del progetto (E702 statement con ';',
|
||||||
|
# E402 import dopo setup env, F841/F401 nei moduli legacy).
|
||||||
|
run: uv run ruff check pm2d/
|
||||||
|
|
||||||
|
- name: Test (pytest)
|
||||||
|
run: uv run pytest tests/ -v
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
"""Rasterizzazione DXF → immagine template per il matcher shape-based.
|
||||||
|
|
||||||
|
Il matcher lavora sui gradienti degli edge: un line-drawing pulito
|
||||||
|
(sfondo grigio scuro, tratti chiari) è un template perfettamente valido.
|
||||||
|
Questo modulo converte un file DXF (CAD 2D) in una bitmap grayscale
|
||||||
|
centrata e scalata, pronta per train().
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Valori di rendering: sfondo scuro / tratto chiaro → gradiente netto
|
||||||
|
BG_GRAY = 60
|
||||||
|
LINE_GRAY = 220
|
||||||
|
|
||||||
|
|
||||||
|
def _read_doc(data: bytes):
|
||||||
|
"""Parse DXF da bytes con gestione encoding.
|
||||||
|
|
||||||
|
Prima prova ezdxf.read su StringIO (DXF ASCII utf-8 / cp1252),
|
||||||
|
poi fallback su ezdxf.recover che auto-rileva encoding e tollera
|
||||||
|
file malformati.
|
||||||
|
"""
|
||||||
|
import ezdxf
|
||||||
|
from ezdxf import recover
|
||||||
|
|
||||||
|
for enc in ("utf-8", "cp1252"):
|
||||||
|
try:
|
||||||
|
text = data.decode(enc)
|
||||||
|
return ezdxf.read(io.StringIO(text))
|
||||||
|
except Exception:
|
||||||
|
# UnicodeDecodeError, DXFStructureError e simili: prossimo tentativo
|
||||||
|
continue
|
||||||
|
# Ultimo tentativo: recover lavora direttamente sui bytes
|
||||||
|
try:
|
||||||
|
doc, _auditor = recover.read(io.BytesIO(data))
|
||||||
|
return doc
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"DXF illeggibile o corrotto: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_polylines(doc, flatten_dist: float = 0.05) -> tuple[list[np.ndarray], int]:
|
||||||
|
"""Converte le entità del modelspace in polilinee (liste di punti XY).
|
||||||
|
|
||||||
|
Entità non convertibili (non supportate da make_path) vengono saltate
|
||||||
|
silenziosamente ma conteggiate. Ritorna (polilinee, n_saltate).
|
||||||
|
"""
|
||||||
|
from ezdxf import path as ezpath
|
||||||
|
|
||||||
|
polylines: list[np.ndarray] = []
|
||||||
|
skipped = 0
|
||||||
|
for entity in doc.modelspace():
|
||||||
|
try:
|
||||||
|
p = ezpath.make_path(entity)
|
||||||
|
pts = np.array(
|
||||||
|
[(v.x, v.y) for v in p.flattening(distance=flatten_dist)],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
if len(pts) >= 2:
|
||||||
|
polylines.append(pts)
|
||||||
|
except Exception:
|
||||||
|
skipped += 1
|
||||||
|
return polylines, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def dxf_to_image(data: bytes, target_size: int = 512,
|
||||||
|
line_thickness: int = 2, margin: int = 16) -> np.ndarray:
|
||||||
|
"""Rasterizza un DXF in immagine grayscale (H, W) uint8.
|
||||||
|
|
||||||
|
- Scala uniforme: il lato lungo del disegno = target_size - 2*margin.
|
||||||
|
- Disegno centrato, asse Y CAD (su) ribaltato in convenzione immagine.
|
||||||
|
- Sfondo grigio scuro (60), tratti chiari (220), antialiased.
|
||||||
|
|
||||||
|
Solleva ValueError se il DXF è vuoto o illeggibile.
|
||||||
|
"""
|
||||||
|
doc = _read_doc(data)
|
||||||
|
|
||||||
|
# Distanza di flattening provvisoria in unità CAD: raffinata sotto
|
||||||
|
# una volta nota la scala (qui serve solo per il bounding box).
|
||||||
|
polylines, skipped = _extract_polylines(doc)
|
||||||
|
if not polylines:
|
||||||
|
raise ValueError(
|
||||||
|
"DXF vuoto: nessuna entità convertibile in polilinea nel "
|
||||||
|
f"modelspace ({skipped} entità non supportate saltate)")
|
||||||
|
|
||||||
|
all_pts = np.vstack(polylines)
|
||||||
|
min_xy = all_pts.min(axis=0)
|
||||||
|
max_xy = all_pts.max(axis=0)
|
||||||
|
extent = max_xy - min_xy
|
||||||
|
long_side = float(extent.max())
|
||||||
|
if long_side <= 0:
|
||||||
|
raise ValueError("DXF degenere: bounding box con estensione nulla")
|
||||||
|
|
||||||
|
# Ri-flattening con distanza adattiva: ~0.25 px di errore alla scala
|
||||||
|
# finale (il primo pass usava una tolleranza in unità CAD arbitraria).
|
||||||
|
avail = max(1, target_size - 2 * margin)
|
||||||
|
scale = avail / long_side
|
||||||
|
polylines, _ = _extract_polylines(doc, flatten_dist=max(1e-9, 0.25 / scale))
|
||||||
|
|
||||||
|
canvas = np.full((target_size, target_size), BG_GRAY, dtype=np.uint8)
|
||||||
|
# Offset per centrare il disegno (anche sul lato corto)
|
||||||
|
draw_w = extent[0] * scale
|
||||||
|
draw_h = extent[1] * scale
|
||||||
|
off_x = (target_size - draw_w) / 2.0
|
||||||
|
off_y = (target_size - draw_h) / 2.0
|
||||||
|
|
||||||
|
for pts in polylines:
|
||||||
|
px = (pts[:, 0] - min_xy[0]) * scale + off_x
|
||||||
|
# Y CAD verso l'alto → Y immagine verso il basso
|
||||||
|
py = (max_xy[1] - pts[:, 1]) * scale + off_y
|
||||||
|
ipts = np.stack([px, py], axis=1).round().astype(np.int32)
|
||||||
|
cv2.polylines(canvas, [ipts], isClosed=False,
|
||||||
|
color=LINE_GRAY, thickness=line_thickness,
|
||||||
|
lineType=cv2.LINE_AA)
|
||||||
|
|
||||||
|
return canvas
|
||||||
+99
-10
@@ -55,6 +55,7 @@ RECIPES_DIR.mkdir(exist_ok=True)
|
|||||||
|
|
||||||
from pm2d.line_matcher import LineShapeMatcher, Match
|
from pm2d.line_matcher import LineShapeMatcher, Match
|
||||||
from pm2d.auto_tune import auto_tune
|
from pm2d.auto_tune import auto_tune
|
||||||
|
from pm2d.dxf import dxf_to_image
|
||||||
|
|
||||||
|
|
||||||
WEB_DIR = Path(__file__).parent
|
WEB_DIR = Path(__file__).parent
|
||||||
@@ -91,7 +92,10 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
|
|||||||
"min_feature_spacing",
|
"min_feature_spacing",
|
||||||
"angle_min", "angle_max", "angle_step",
|
"angle_min", "angle_max", "angle_step",
|
||||||
"scale_min", "scale_max", "scale_step",
|
"scale_min", "scale_max", "scale_step",
|
||||||
"spread_radius", "pyramid_levels")
|
"spread_radius", "pyramid_levels",
|
||||||
|
# ROI poligonale: la mask cambia il training a parità di
|
||||||
|
# bbox → deve invalidare la cache (None = ROI rettangolare)
|
||||||
|
"roi_poly")
|
||||||
for k in relevant:
|
for k in relevant:
|
||||||
h.update(f"{k}={tech.get(k)}".encode())
|
h.update(f"{k}={tech.get(k)}".encode())
|
||||||
h.update(f"shape={roi.shape}".encode())
|
h.update(f"shape={roi.shape}".encode())
|
||||||
@@ -170,6 +174,39 @@ def _clamp_roi(x: int, y: int, w: int, h: int,
|
|||||||
return x, y, w, h
|
return x, y, w, h
|
||||||
|
|
||||||
|
|
||||||
|
def _poly_bbox_mask(
|
||||||
|
roi_poly: list[list[float]], img_w: int, img_h: int,
|
||||||
|
) -> tuple[int, int, int, int, np.ndarray]:
|
||||||
|
"""Valida roi_poly (vertici [x, y] in coordinate IMMAGINE) e ritorna
|
||||||
|
(x, y, w, h, mask): bbox del poligono clampato con _clamp_roi e mask
|
||||||
|
uint8 (255 dentro il poligono) nel sistema di coordinate della ROI.
|
||||||
|
|
||||||
|
Solleva 400 se il poligono ha <3 punti o area degenere.
|
||||||
|
"""
|
||||||
|
pts = np.asarray(roi_poly, dtype=np.float64)
|
||||||
|
if pts.ndim != 2 or pts.shape[1] != 2 or pts.shape[0] < 3:
|
||||||
|
raise HTTPException(
|
||||||
|
400, "roi_poly non valido: servono almeno 3 vertici [x, y]")
|
||||||
|
# Area con formula shoelace: poligoni collineari/degeneri → 400
|
||||||
|
px_, py_ = pts[:, 0], pts[:, 1]
|
||||||
|
area = 0.5 * abs(np.dot(px_, np.roll(py_, 1)) - np.dot(py_, np.roll(px_, 1)))
|
||||||
|
if area < 16.0:
|
||||||
|
raise HTTPException(
|
||||||
|
400, f"roi_poly degenere: area {area:.1f} px² troppo piccola")
|
||||||
|
x0 = int(np.floor(px_.min())); y0 = int(np.floor(py_.min()))
|
||||||
|
bw = int(np.ceil(px_.max())) - x0
|
||||||
|
bh = int(np.ceil(py_.max())) - y0
|
||||||
|
x, y, w, h = _clamp_roi(x0, y0, bw, bh, img_w, img_h)
|
||||||
|
# Mask nel sistema ROI: vertici ritraslati di (-x, -y)
|
||||||
|
mask = np.zeros((h, w), dtype=np.uint8)
|
||||||
|
local = np.round(pts - [x, y]).astype(np.int32)
|
||||||
|
cv2.fillPoly(mask, [local], 255)
|
||||||
|
if not mask.any():
|
||||||
|
raise HTTPException(
|
||||||
|
400, "roi_poly fuori immagine: nessun pixel utile nella mask")
|
||||||
|
return x, y, w, h, mask
|
||||||
|
|
||||||
|
|
||||||
def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
|
def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
|
||||||
"""Solleva 422 se il train non ha prodotto varianti.
|
"""Solleva 422 se il train non ha prodotto varianti.
|
||||||
|
|
||||||
@@ -303,6 +340,10 @@ class MatchParams(BaseModel):
|
|||||||
model_id: str
|
model_id: str
|
||||||
scene_id: str
|
scene_id: str
|
||||||
roi: list[int] # [x, y, w, h] nell'immagine modello
|
roi: list[int] # [x, y, w, h] nell'immagine modello
|
||||||
|
# ROI poligonale opzionale: vertici [x, y] in coordinate IMMAGINE
|
||||||
|
# (min 3 punti). Se presente, il bbox del poligono sostituisce `roi`
|
||||||
|
# e il training usa la mask del poligono.
|
||||||
|
roi_poly: list[list[float]] | None = None
|
||||||
angle_min: float = 0.0
|
angle_min: float = 0.0
|
||||||
angle_max: float = 360.0
|
angle_max: float = 360.0
|
||||||
angle_step: float = 5.0
|
angle_step: float = 5.0
|
||||||
@@ -384,6 +425,8 @@ class SimpleMatchParams(BaseModel):
|
|||||||
model_id: str
|
model_id: str
|
||||||
scene_id: str
|
scene_id: str
|
||||||
roi: list[int]
|
roi: list[int]
|
||||||
|
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
|
||||||
|
roi_poly: list[list[float]] | None = None
|
||||||
tipo: str = "intero" # "intero" | "parziale"
|
tipo: str = "intero" # "intero" | "parziale"
|
||||||
simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX
|
simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX
|
||||||
scala: str = "fissa" # chiave SCALE_PRESETS
|
scala: str = "fissa" # chiave SCALE_PRESETS
|
||||||
@@ -603,6 +646,26 @@ async def upload(file: UploadFile = File(...)):
|
|||||||
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
|
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/upload_dxf", response_model=UploadResp)
|
||||||
|
async def upload_dxf(file: UploadFile = File(...), size: int = 512):
|
||||||
|
"""Upload DXF: rasterizza il CAD in template grayscale e lo salva
|
||||||
|
nella cache immagini come un normale upload.
|
||||||
|
|
||||||
|
Query param `size` = lato del canvas (clamp 128..2048).
|
||||||
|
"""
|
||||||
|
size = max(128, min(2048, int(size)))
|
||||||
|
data = await file.read()
|
||||||
|
try:
|
||||||
|
gray = dxf_to_image(data, target_size=size)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, f"DXF non valido: {e}")
|
||||||
|
# _store_image salva PNG e gli endpoint a valle (cvtColor BGR2GRAY su
|
||||||
|
# roi_img, _load_image con IMREAD_COLOR) si aspettano 3 canali → BGR.
|
||||||
|
img = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
|
||||||
|
iid = _store_image(img)
|
||||||
|
return UploadResp(id=iid, width=img.shape[1], height=img.shape[0])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/image/{iid}/raw")
|
@app.get("/image/{iid}/raw")
|
||||||
def image_raw(iid: str):
|
def image_raw(iid: str):
|
||||||
img = _load_image(iid)
|
img = _load_image(iid)
|
||||||
@@ -617,8 +680,14 @@ def match(p: MatchParams):
|
|||||||
scene = _load_image(p.scene_id)
|
scene = _load_image(p.scene_id)
|
||||||
if model is None or scene is None:
|
if model is None or scene is None:
|
||||||
raise HTTPException(404, "Immagini non trovate")
|
raise HTTPException(404, "Immagini non trovate")
|
||||||
x, y, w, h = p.roi
|
# ROI poligonale: bbox derivato dal poligono + mask per il training
|
||||||
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
|
train_mask = None
|
||||||
|
if p.roi_poly is not None:
|
||||||
|
x, y, w, h, train_mask = _poly_bbox_mask(
|
||||||
|
p.roi_poly, model.shape[1], model.shape[0])
|
||||||
|
else:
|
||||||
|
x, y, w, h = p.roi
|
||||||
|
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
|
||||||
roi_img = model[y:y + h, x:x + w]
|
roi_img = model[y:y + h, x:x + w]
|
||||||
|
|
||||||
tech_for_cache = {
|
tech_for_cache = {
|
||||||
@@ -630,6 +699,9 @@ def match(p: MatchParams):
|
|||||||
"scale_step": p.scale_step,
|
"scale_step": p.scale_step,
|
||||||
"spread_radius": p.spread_radius,
|
"spread_radius": p.spread_radius,
|
||||||
"pyramid_levels": p.pyramid_levels,
|
"pyramid_levels": p.pyramid_levels,
|
||||||
|
# Tuple per repr stabile nella cache key (None = rettangolare)
|
||||||
|
"roi_poly": (tuple(map(tuple, p.roi_poly))
|
||||||
|
if p.roi_poly is not None else None),
|
||||||
}
|
}
|
||||||
key = _matcher_cache_key(roi_img, tech_for_cache)
|
key = _matcher_cache_key(roi_img, tech_for_cache)
|
||||||
# Lock globale: matcher condivisi tra thread del pool FastAPI
|
# Lock globale: matcher condivisi tra thread del pool FastAPI
|
||||||
@@ -646,7 +718,7 @@ def match(p: MatchParams):
|
|||||||
spread_radius=p.spread_radius,
|
spread_radius=p.spread_radius,
|
||||||
pyramid_levels=p.pyramid_levels,
|
pyramid_levels=p.pyramid_levels,
|
||||||
)
|
)
|
||||||
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
|
t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
|
||||||
_check_trained(m, n)
|
_check_trained(m, n)
|
||||||
_cache_put_matcher(key, m)
|
_cache_put_matcher(key, m)
|
||||||
else:
|
else:
|
||||||
@@ -689,11 +761,20 @@ def match_simple(p: SimpleMatchParams):
|
|||||||
scene = _load_image(p.scene_id)
|
scene = _load_image(p.scene_id)
|
||||||
if model is None or scene is None:
|
if model is None or scene is None:
|
||||||
raise HTTPException(404, "Immagini non trovate")
|
raise HTTPException(404, "Immagini non trovate")
|
||||||
x, y, w, h = p.roi
|
# ROI poligonale: bbox derivato dal poligono + mask per il training
|
||||||
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
|
train_mask = None
|
||||||
|
if p.roi_poly is not None:
|
||||||
|
x, y, w, h, train_mask = _poly_bbox_mask(
|
||||||
|
p.roi_poly, model.shape[1], model.shape[0])
|
||||||
|
else:
|
||||||
|
x, y, w, h = p.roi
|
||||||
|
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
|
||||||
roi_img = model[y:y + h, x:x + w]
|
roi_img = model[y:y + h, x:x + w]
|
||||||
|
|
||||||
tech = _simple_to_technical(p, roi_img)
|
tech = _simple_to_technical(p, roi_img)
|
||||||
|
# Tuple per repr stabile nella cache key (None = rettangolare)
|
||||||
|
tech["roi_poly"] = (tuple(map(tuple, p.roi_poly))
|
||||||
|
if p.roi_poly is not None else None)
|
||||||
|
|
||||||
key = _matcher_cache_key(roi_img, tech)
|
key = _matcher_cache_key(roi_img, tech)
|
||||||
# Halcon-mode init params: incidono sul training, includere in cache key
|
# Halcon-mode init params: incidono sul training, includere in cache key
|
||||||
@@ -716,7 +797,7 @@ def match_simple(p: SimpleMatchParams):
|
|||||||
use_polarity=p.use_polarity,
|
use_polarity=p.use_polarity,
|
||||||
use_gpu=p.use_gpu,
|
use_gpu=p.use_gpu,
|
||||||
)
|
)
|
||||||
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
|
t0 = time.time(); n = m.train(roi_img, train_mask); t_train = time.time() - t0
|
||||||
_check_trained(m, n)
|
_check_trained(m, n)
|
||||||
_cache_put_matcher(key, m)
|
_cache_put_matcher(key, m)
|
||||||
else:
|
else:
|
||||||
@@ -778,6 +859,8 @@ class SaveRecipeParams(BaseModel):
|
|||||||
model_id: str
|
model_id: str
|
||||||
scene_id: str | None = None
|
scene_id: str | None = None
|
||||||
roi: list[int]
|
roi: list[int]
|
||||||
|
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
|
||||||
|
roi_poly: list[list[float]] | None = None
|
||||||
# Riusa stessi param simple per training equivalente
|
# Riusa stessi param simple per training equivalente
|
||||||
tipo: str = "intero"
|
tipo: str = "intero"
|
||||||
simmetria: str = "nessuna"
|
simmetria: str = "nessuna"
|
||||||
@@ -897,8 +980,14 @@ def save_recipe(p: SaveRecipeParams):
|
|||||||
model = _load_image(p.model_id)
|
model = _load_image(p.model_id)
|
||||||
if model is None:
|
if model is None:
|
||||||
raise HTTPException(404, "Modello non trovato")
|
raise HTTPException(404, "Modello non trovato")
|
||||||
x, y, w, h = p.roi
|
# ROI poligonale: bbox derivato dal poligono + mask per il training
|
||||||
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
|
train_mask = None
|
||||||
|
if p.roi_poly is not None:
|
||||||
|
x, y, w, h, train_mask = _poly_bbox_mask(
|
||||||
|
p.roi_poly, model.shape[1], model.shape[0])
|
||||||
|
else:
|
||||||
|
x, y, w, h = p.roi
|
||||||
|
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
|
||||||
roi_img = model[y:y + h, x:x + w]
|
roi_img = model[y:y + h, x:x + w]
|
||||||
sp = SimpleMatchParams(
|
sp = SimpleMatchParams(
|
||||||
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
|
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
|
||||||
@@ -925,7 +1014,7 @@ def save_recipe(p: SaveRecipeParams):
|
|||||||
)
|
)
|
||||||
# Lock globale: serializza il training pesante col matching in corso
|
# Lock globale: serializza il training pesante col matching in corso
|
||||||
with _MATCHER_LOCK:
|
with _MATCHER_LOCK:
|
||||||
n_var = m.train(roi_img)
|
n_var = m.train(roi_img, train_mask)
|
||||||
_check_trained(m, n_var)
|
_check_trained(m, n_var)
|
||||||
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
|
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
|
||||||
if not safe_name:
|
if not safe_name:
|
||||||
|
|||||||
+180
-1
@@ -20,6 +20,10 @@ const state = {
|
|||||||
model: null, scene: null, roi: null, drag: null,
|
model: null, scene: null, roi: null, drag: null,
|
||||||
matches: [], annotatedImg: null,
|
matches: [], annotatedImg: null,
|
||||||
active_recipe: null, // V: ricetta caricata (string nome) o null
|
active_recipe: null, // V: ricetta caricata (string nome) o null
|
||||||
|
// ROI poligonale: vertici [x, y] in coordinate immagine modello
|
||||||
|
polyMode: false, polyPts: [], polyClosed: false,
|
||||||
|
// Export JSON: ultimo match completo (params + risposta)
|
||||||
|
lastMatch: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Forms ----------
|
// ---------- Forms ----------
|
||||||
@@ -148,6 +152,15 @@ async function uploadToFolder(file) {
|
|||||||
return await r.json();
|
return await r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadDxf(file) {
|
||||||
|
// DXF: rasterizzato server-side in template grayscale (vedi pm2d/dxf.py)
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const r = await fetch("/upload_dxf", { method: "POST", body: fd });
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPickers() {
|
async function refreshPickers() {
|
||||||
const {files, dir} = await fetchImagesList();
|
const {files, dir} = await fetchImagesList();
|
||||||
buildThumbPicker("picker-model", files, onSelectModel);
|
buildThumbPicker("picker-model", files, onSelectModel);
|
||||||
@@ -222,6 +235,7 @@ async function onSelectModel(filename) {
|
|||||||
const img = await loadImage(`/image/${meta.id}/raw`);
|
const img = await loadImage(`/image/${meta.id}/raw`);
|
||||||
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
|
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
|
||||||
state.roi = null;
|
state.roi = null;
|
||||||
|
state.polyPts = []; state.polyClosed = false; // B: scarta poligono stale
|
||||||
document.getElementById("roi-info").textContent = "ROI: (nessuna)";
|
document.getElementById("roi-info").textContent = "ROI: (nessuna)";
|
||||||
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
|
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
|
||||||
renderModel();
|
renderModel();
|
||||||
@@ -262,12 +276,36 @@ function renderModel() {
|
|||||||
state.model.scale = fit.sc;
|
state.model.scale = fit.sc;
|
||||||
state.model.ox = fit.ox; state.model.oy = fit.oy;
|
state.model.ox = fit.ox; state.model.oy = fit.oy;
|
||||||
ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh);
|
ctx.drawImage(state.model.img, fit.ox, fit.oy, fit.dw, fit.dh);
|
||||||
if (state.roi) {
|
if (state.roi && !state.polyMode) {
|
||||||
const [x, y, w, h] = state.roi;
|
const [x, y, w, h] = state.roi;
|
||||||
ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2;
|
ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2;
|
||||||
ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc,
|
ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * fit.sc,
|
||||||
w * fit.sc, h * fit.sc);
|
w * fit.sc, h * fit.sc);
|
||||||
}
|
}
|
||||||
|
// ROI poligonale: path aperto giallo, chiuso verde con fill semitrasparente
|
||||||
|
if (state.polyMode && state.polyPts.length > 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
state.polyPts.forEach(([px, py], i) => {
|
||||||
|
const cx = fit.ox + px * fit.sc;
|
||||||
|
const cy = fit.oy + py * fit.sc;
|
||||||
|
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
|
||||||
|
});
|
||||||
|
if (state.polyClosed) {
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = "rgba(0, 255, 128, 0.18)";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = "#00ff80";
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = "#ffff00";
|
||||||
|
}
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
// Vertici come quadratini
|
||||||
|
ctx.fillStyle = state.polyClosed ? "#00ff80" : "#ffff00";
|
||||||
|
for (const [px, py] of state.polyPts) {
|
||||||
|
ctx.fillRect(fit.ox + px * fit.sc - 2, fit.oy + py * fit.sc - 2, 4, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (state.drag) {
|
if (state.drag) {
|
||||||
ctx.strokeStyle = "#ffff00";
|
ctx.strokeStyle = "#ffff00";
|
||||||
ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
|
ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
|
||||||
@@ -301,10 +339,35 @@ function setupROI() {
|
|||||||
const cnv = document.getElementById("c-model");
|
const cnv = document.getElementById("c-model");
|
||||||
cnv.addEventListener("mousedown", (e) => {
|
cnv.addEventListener("mousedown", (e) => {
|
||||||
if (!state.model) return;
|
if (!state.model) return;
|
||||||
|
if (state.polyMode) return; // poly mode: gestito da click/dblclick
|
||||||
const p = canvasPos(cnv, e);
|
const p = canvasPos(cnv, e);
|
||||||
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
|
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
|
||||||
renderModel();
|
renderModel();
|
||||||
});
|
});
|
||||||
|
// ROI poligonale: click aggiunge vertice, doppio click chiude
|
||||||
|
cnv.addEventListener("click", (e) => {
|
||||||
|
if (!state.model || !state.polyMode || state.polyClosed) return;
|
||||||
|
const m = state.model;
|
||||||
|
const p = canvasPos(cnv, e);
|
||||||
|
const ix = (p.x - m.ox) / m.scale;
|
||||||
|
const iy = (p.y - m.oy) / m.scale;
|
||||||
|
if (ix < 0 || iy < 0 || ix > m.w || iy > m.h) return; // fuori immagine
|
||||||
|
const last = state.polyPts[state.polyPts.length - 1];
|
||||||
|
// Dedup: il dblclick genera anche 2 click ravvicinati
|
||||||
|
if (last && Math.hypot(ix - last[0], iy - last[1]) < 3) return;
|
||||||
|
state.polyPts.push([
|
||||||
|
Math.max(0, Math.min(Math.round(ix), m.w - 1)),
|
||||||
|
Math.max(0, Math.min(Math.round(iy), m.h - 1)),
|
||||||
|
]);
|
||||||
|
document.getElementById("roi-info").textContent =
|
||||||
|
`Poligono: ${state.polyPts.length} vertici (doppio click o "Chiudi" per chiudere)`;
|
||||||
|
renderModel();
|
||||||
|
});
|
||||||
|
cnv.addEventListener("dblclick", (e) => {
|
||||||
|
if (!state.polyMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
closePoly();
|
||||||
|
});
|
||||||
cnv.addEventListener("mousemove", (e) => {
|
cnv.addEventListener("mousemove", (e) => {
|
||||||
if (!state.drag) return;
|
if (!state.drag) return;
|
||||||
const p = canvasPos(cnv, e);
|
const p = canvasPos(cnv, e);
|
||||||
@@ -331,6 +394,41 @@ function setupROI() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- ROI poligonale ----------
|
||||||
|
function closePoly() {
|
||||||
|
if (!state.polyMode || state.polyClosed) return;
|
||||||
|
if (state.polyPts.length < 3) {
|
||||||
|
setStatus("Servono almeno 3 vertici per chiudere il poligono");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.polyClosed = true;
|
||||||
|
// ROI = bounding box del poligono (il server riceve anche roi_poly)
|
||||||
|
const xs = state.polyPts.map((p) => p[0]);
|
||||||
|
const ys = state.polyPts.map((p) => p[1]);
|
||||||
|
const x0 = Math.min(...xs), y0 = Math.min(...ys);
|
||||||
|
const w = Math.max(...xs) - x0, h = Math.max(...ys) - y0;
|
||||||
|
state.roi = [x0, y0, Math.max(1, w), Math.max(1, h)];
|
||||||
|
document.getElementById("roi-info").textContent =
|
||||||
|
`Poligono: ${state.polyPts.length} vertici, bbox ${w}x${h} @ (${x0}, ${y0})`;
|
||||||
|
renderModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPoly() {
|
||||||
|
state.polyPts = [];
|
||||||
|
state.polyClosed = false;
|
||||||
|
state.roi = null;
|
||||||
|
document.getElementById("roi-info").textContent = state.polyMode
|
||||||
|
? "Poligono: clicca sul modello per aggiungere vertici"
|
||||||
|
: "ROI: (nessuna)";
|
||||||
|
renderModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoiPoly() {
|
||||||
|
// Poligono valido solo se in modalità poly e chiuso
|
||||||
|
return (state.polyMode && state.polyClosed && state.polyPts.length >= 3)
|
||||||
|
? state.polyPts : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Match action ----------
|
// ---------- Match action ----------
|
||||||
async function doMatchRecipe() {
|
async function doMatchRecipe() {
|
||||||
if (!state.scene) { setStatus("Carica scena"); return; }
|
if (!state.scene) { setStatus("Carica scena"); return; }
|
||||||
@@ -352,6 +450,12 @@ async function doMatchRecipe() {
|
|||||||
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
|
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
state.matches = data.matches;
|
state.matches = data.matches;
|
||||||
|
// C: salva tutto per "Esporta JSON"
|
||||||
|
state.lastMatch = {
|
||||||
|
endpoint: "/match_recipe", params: body, response: data,
|
||||||
|
image_id: state.scene.id,
|
||||||
|
};
|
||||||
|
document.getElementById("btn-export-json").disabled = false;
|
||||||
state.annotatedImg = await loadImage(
|
state.annotatedImg = await loadImage(
|
||||||
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
||||||
renderScene();
|
renderScene();
|
||||||
@@ -371,7 +475,11 @@ async function doMatch() {
|
|||||||
}
|
}
|
||||||
if (!state.model) { setStatus("Carica modello"); return; }
|
if (!state.model) { setStatus("Carica modello"); return; }
|
||||||
if (!state.scene) { setStatus("Carica scena"); return; }
|
if (!state.scene) { setStatus("Carica scena"); return; }
|
||||||
|
if (state.polyMode && !state.polyClosed) {
|
||||||
|
setStatus("Chiudi il poligono (doppio click o bottone Chiudi)"); return;
|
||||||
|
}
|
||||||
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
|
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
|
||||||
|
const roiPoly = getRoiPoly();
|
||||||
const user = readUserParams();
|
const user = readUserParams();
|
||||||
const adv = readAdvancedOverrides();
|
const adv = readAdvancedOverrides();
|
||||||
setStatus("Match in corso...");
|
setStatus("Match in corso...");
|
||||||
@@ -397,6 +505,7 @@ async function doMatch() {
|
|||||||
const angMax = SYM_MAP[user.simmetria] ?? 360;
|
const angMax = SYM_MAP[user.simmetria] ?? 360;
|
||||||
body = {
|
body = {
|
||||||
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
||||||
|
roi_poly: roiPoly,
|
||||||
angle_min: 0, angle_max: angMax,
|
angle_min: 0, angle_max: angMax,
|
||||||
angle_step: PREC_MAP[user.precisione] ?? 5,
|
angle_step: PREC_MAP[user.precisione] ?? 5,
|
||||||
scale_min: smin, scale_max: smax, scale_step: sstep,
|
scale_min: smin, scale_max: smax, scale_step: sstep,
|
||||||
@@ -412,6 +521,7 @@ async function doMatch() {
|
|||||||
} else {
|
} else {
|
||||||
body = {
|
body = {
|
||||||
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
||||||
|
roi_poly: roiPoly,
|
||||||
...user,
|
...user,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -426,6 +536,12 @@ async function doMatch() {
|
|||||||
}
|
}
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
state.matches = data.matches;
|
state.matches = data.matches;
|
||||||
|
// C: salva tutto per "Esporta JSON"
|
||||||
|
state.lastMatch = {
|
||||||
|
endpoint: url, params: body, response: data,
|
||||||
|
image_id: state.scene.id,
|
||||||
|
};
|
||||||
|
document.getElementById("btn-export-json").disabled = false;
|
||||||
state.annotatedImg = await loadImage(
|
state.annotatedImg = await loadImage(
|
||||||
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
||||||
renderScene();
|
renderScene();
|
||||||
@@ -461,6 +577,38 @@ function setStatus(s) {
|
|||||||
document.getElementById("status").textContent = s;
|
document.getElementById("status").textContent = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- C: Export JSON risultati ----------
|
||||||
|
function exportMatchJSON() {
|
||||||
|
if (!state.lastMatch) {
|
||||||
|
alert("Nessun match da esportare: esegui prima un MATCH.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lm = state.lastMatch;
|
||||||
|
const payload = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
image_id: lm.image_id,
|
||||||
|
endpoint: lm.endpoint,
|
||||||
|
params: lm.params,
|
||||||
|
matches: lm.response.matches.map((m) => ({
|
||||||
|
cx: m.cx, cy: m.cy, angle_deg: m.angle_deg,
|
||||||
|
scale: m.scale, score: m.score, bbox: m.bbox_poly,
|
||||||
|
})),
|
||||||
|
train_time: lm.response.train_time,
|
||||||
|
find_time: lm.response.find_time,
|
||||||
|
num_variants: lm.response.num_variants,
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)],
|
||||||
|
{ type: "application/json" });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
a.download = `pm2d_match_${ts}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Init ----------
|
// ---------- Init ----------
|
||||||
// ---------- Edge preview (clean rumore) ----------
|
// ---------- Edge preview (clean rumore) ----------
|
||||||
let _epDebounce = null;
|
let _epDebounce = null;
|
||||||
@@ -734,6 +882,7 @@ async function saveRecipe() {
|
|||||||
model_id: state.model.id,
|
model_id: state.model.id,
|
||||||
scene_id: state.scene?.id || state.model.id,
|
scene_id: state.scene?.id || state.model.id,
|
||||||
roi: state.roi,
|
roi: state.roi,
|
||||||
|
roi_poly: getRoiPoly(),
|
||||||
tipo: user.tipo,
|
tipo: user.tipo,
|
||||||
simmetria: user.simmetria,
|
simmetria: user.simmetria,
|
||||||
scala: user.scala,
|
scala: user.scala,
|
||||||
@@ -777,6 +926,24 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|||||||
upEl.addEventListener("change", async (e) => {
|
upEl.addEventListener("change", async (e) => {
|
||||||
const f = e.target.files[0];
|
const f = e.target.files[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
// A: file DXF → rasterizza server-side e usa direttamente come modello
|
||||||
|
if (f.name.toLowerCase().endsWith(".dxf")) {
|
||||||
|
setStatus(`Rasterizzazione DXF ${f.name}...`);
|
||||||
|
try {
|
||||||
|
const meta = await uploadDxf(f);
|
||||||
|
const img = await loadImage(`/image/${meta.id}/raw`);
|
||||||
|
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
|
||||||
|
state.roi = null;
|
||||||
|
resetPoly();
|
||||||
|
setStatus(`DXF ${f.name} rasterizzato ` +
|
||||||
|
`${meta.width}x${meta.height} — disegna ROI sul modello`);
|
||||||
|
renderModel();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Errore DXF: ${err.message}`);
|
||||||
|
}
|
||||||
|
e.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus(`Caricamento ${f.name} nella cartella...`);
|
setStatus(`Caricamento ${f.name} nella cartella...`);
|
||||||
try {
|
try {
|
||||||
const res = await uploadToFolder(f);
|
const res = await uploadToFolder(f);
|
||||||
@@ -789,6 +956,18 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|||||||
});
|
});
|
||||||
document.getElementById("btn-match").addEventListener("click", doMatch);
|
document.getElementById("btn-match").addEventListener("click", doMatch);
|
||||||
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
|
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
|
||||||
|
// B: ROI poligonale (toggle + chiudi + reset)
|
||||||
|
document.getElementById("roi-poly-toggle").addEventListener("change", (e) => {
|
||||||
|
state.polyMode = e.target.checked;
|
||||||
|
document.getElementById("btn-poly-close").disabled = !state.polyMode;
|
||||||
|
document.getElementById("btn-poly-reset").disabled = !state.polyMode;
|
||||||
|
resetPoly();
|
||||||
|
});
|
||||||
|
document.getElementById("btn-poly-close").addEventListener("click", closePoly);
|
||||||
|
document.getElementById("btn-poly-reset").addEventListener("click", resetPoly);
|
||||||
|
// C: export JSON ultimo match
|
||||||
|
document.getElementById("btn-export-json").addEventListener("click",
|
||||||
|
exportMatchJSON);
|
||||||
document.getElementById("btn-save-recipe").addEventListener("click",
|
document.getElementById("btn-save-recipe").addEventListener("click",
|
||||||
saveRecipe);
|
saveRecipe);
|
||||||
document.getElementById("btn-load-recipe").addEventListener("click",
|
document.getElementById("btn-load-recipe").addEventListener("click",
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
|
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
|
||||||
⚙ Auto-tune
|
⚙ Auto-tune
|
||||||
</button>
|
</button>
|
||||||
<label class="btn" title="Carica nuovo file nella cartella immagini">
|
<label class="btn" title="Carica nuovo file nella cartella immagini (immagine o DXF)">
|
||||||
⬆ Carica file
|
⬆ Carica file
|
||||||
<input type="file" id="file-upload" accept="image/*" hidden>
|
<input type="file" id="file-upload" accept="image/*,.dxf" hidden>
|
||||||
</label>
|
</label>
|
||||||
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
|
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,6 +45,15 @@
|
|||||||
<canvas id="c-model" width="380" height="420"></canvas>
|
<canvas id="c-model" width="380" height="420"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div id="roi-info">ROI: (nessuna)</div>
|
<div id="roi-info">ROI: (nessuna)</div>
|
||||||
|
<div id="roi-poly-bar" style="display:flex; gap:6px; align-items:center; margin-top:6px">
|
||||||
|
<label style="display:flex; gap:4px; align-items:center; font-size:12px; cursor:pointer">
|
||||||
|
<input type="checkbox" id="roi-poly-toggle"> ROI poligonale
|
||||||
|
</label>
|
||||||
|
<button class="btn" id="btn-poly-close" type="button" disabled
|
||||||
|
title="Chiude il poligono (equivale al doppio click)">Chiudi</button>
|
||||||
|
<button class="btn" id="btn-poly-reset" type="button" disabled
|
||||||
|
title="Cancella i vertici del poligono">Reset</button>
|
||||||
|
</div>
|
||||||
<details id="edge-preview-panel" style="margin-top:10px">
|
<details id="edge-preview-panel" style="margin-top:10px">
|
||||||
<summary>🔬 Anteprima edge / pulizia rumore</summary>
|
<summary>🔬 Anteprima edge / pulizia rumore</summary>
|
||||||
<div style="font-size:11px; color:#aaa; margin:4px 0">
|
<div style="font-size:11px; color:#aaa; margin:4px 0">
|
||||||
@@ -248,6 +257,11 @@
|
|||||||
<div class="kv"><span>find:</span><span id="t-find">-</span></div>
|
<div class="kv"><span>find:</span><span id="t-find">-</span></div>
|
||||||
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
|
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
|
||||||
<div class="kv"><span>match:</span><span id="t-match">-</span></div>
|
<div class="kv"><span>match:</span><span id="t-match">-</span></div>
|
||||||
|
<button class="btn" id="btn-export-json" type="button" disabled
|
||||||
|
style="margin-top:8px; width:100%"
|
||||||
|
title="Scarica i risultati dell'ultimo match in formato JSON">
|
||||||
|
⬇ Esporta JSON
|
||||||
|
</button>
|
||||||
|
|
||||||
<details id="diag-panel" style="margin-top:10px">
|
<details id="diag-panel" style="margin-top:10px">
|
||||||
<summary>🔍 Diagnostica (CC)</summary>
|
<summary>🔍 Diagnostica (CC)</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user