Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd9455d27 | |||
| e3114d6255 | |||
| 91a6beb032 | |||
| 0420c4a863 | |||
| caabb05023 | |||
| 2f15a37358 | |||
| 4356a47d06 | |||
| 9458173ad0 | |||
| cc811fdc94 | |||
| 452810b67a | |||
| 8c46a6ca9b | |||
| d335f866a3 | |||
| 88f80a2cad | |||
| d52d0d0489 | |||
| 9451a418a6 | |||
| 2c9160e4be | |||
| 6d6dcc3b7a | |||
| ee1c4a8f92 | |||
| 5002515b41 | |||
| 8029a1e12b | |||
| d37833076e | |||
| e1ed9206a3 | |||
| e84ae199ac | |||
| 5f0c4542d3 | |||
| 29c034fb05 | |||
| 6fb1efcab8 |
@@ -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
|
||||
@@ -10,3 +10,10 @@ __pycache__/
|
||||
models/
|
||||
# Ricette pre-trained (generate da utente, non versionare)
|
||||
recipes/*.npz
|
||||
# Immagini di test locali (richieste da benchmarks/test_suite.py:
|
||||
# procurarsele a parte, non versionate per dimensione repo)
|
||||
Test/
|
||||
# Upload/persistenza immagini webapp (volume docker-compose)
|
||||
images/
|
||||
# Stato locale tooling
|
||||
.omc/
|
||||
|
||||
@@ -2,6 +2,36 @@
|
||||
|
||||
Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale.
|
||||
|
||||
## Fase 2 COMPLETATA (precisione rotazione + robustezza + perf)
|
||||
|
||||
Root cause della rotazione imprecisa: lo score satura a 1.0 sulla spread
|
||||
bitmap dilatata (raggio 4-5) → il refine non vedeva gradiente né in angolo
|
||||
né in posizione, e `cv2.minMaxLoc` sul plateau saturo spostava il centro
|
||||
sull'angolo della finestra (errore sistematico 3·√2 ≈ 4.24 px).
|
||||
|
||||
| Fix | Dettaglio |
|
||||
|---|---|
|
||||
| Refine su bitmap fine | `_refine_angle` ottimizza su spread raggio 1 (`spread_fine`, cached); score finale ricalcolato su spread coarse per mantenere semantica soglie |
|
||||
| Picco sub-pixel nel refine | centroide plateau / fit quadratico al posto di minMaxLoc (bias top-left) |
|
||||
| LM least-squares pos+angolo | `_subpixel_refine_lm` riscritto: snap edge ±2px lungo normale + LSQ 3x3 (dx, dy, dθ), ON di default |
|
||||
| Round feature offsets | troncamento `astype(int32)` → `np.round` (bias ~0.25 px) |
|
||||
| Centro rotazione coerente | `_prepare_padded_template`: rotazione attorno al centro reale del template nel padding (bias ≤0.5 px dipendente dall'angolo) |
|
||||
| `_angle_list` include estremo | range parziali ±tol ora testano anche +tol |
|
||||
| `_refine_pose_joint` rimosso | Nelder-Mead su funzione a gradini satura: terminava subito; param ora alias di refine_angle |
|
||||
| pyramid_propagate di default | kernel windowed (feature campionano l'intera scena: prima il crop troncava le feature → score 0); picchi = massimi locali (non top-K pixel); disattivato automaticamente per template elongati (>2:1) dove il picco top-level non localizza |
|
||||
| Piramide 3 livelli default | con clamp automatico sulla dimensione template (min 12 px al top) |
|
||||
| Cache scena: hash completo | prima hashava solo i primi 64KB → collisioni tra scene con stessa banda superiore → risultati della scena sbagliata |
|
||||
| Web server | lock matcher (race con threadpool FastAPI), LRU `_IMG_CACHE`, clamp ROI ovunque (400/422 invece di 500), `filtro_fp=off` disabilita davvero NCC, `_draw_matches` su crop locale |
|
||||
| GUI/legacy | centro overlay `(W-1)/2`→`W/2`, spread_radius default 5→4, EdgeShapeMatcher: angle list endpoint + cap candidati + save template_gray |
|
||||
|
||||
Misure (GT sintetica 7 pose, scena 900x700, VPS 2 core):
|
||||
- Errore angolare mediano: **2.3° → 0.05°** (step 5°); a step 2° era 4.4° → **0.03°**
|
||||
- Errore posizione mediano: **4.24 px → 0.04 px**
|
||||
- find GT scene: 4.7s → 1.7s; scena reale 646x482: 1.14s → 0.81s
|
||||
- Benchmark suite 16 scenari: 96.5s → 84.2s, match count ≥ baseline
|
||||
(eccezioni: dado_full -1 = match borderline su parte diversa;
|
||||
lama_part_preciso 25→18 con baseline al cap max_matches)
|
||||
|
||||
## Fase 1 COMPLETATA (branch `speedFase1`)
|
||||
|
||||
| ID | Voce | Status | Note |
|
||||
@@ -84,9 +114,14 @@ Benchmark suite 16 scenari (4 immagini × full/part × fast/preciso):
|
||||
## Target performance produzione
|
||||
|
||||
Obiettivi da documento tecnico Vision Suite (Fase Beta):
|
||||
- [ ] **Precisione posizionale mediana**: <0.5 px → **raggiunto con subpixel (attualmente ~0.1-0.3 px atteso)**
|
||||
- [ ] **Precisione angolare mediana**: <1.0° → **raggiunto con refinement (~0.5°)**
|
||||
- [ ] **Latency mediana**: <50 ms su 1920×1080 → **attuale ~1.7s su 830×822 (serve GPU o ulteriore CPU)**
|
||||
- [x] **Precisione posizionale mediana**: <0.5 px → **0.04 px misurato su GT sintetica (Fase 2)**
|
||||
- [x] **Precisione angolare mediana**: <1.0° → **0.05° misurato su GT sintetica (Fase 2)**
|
||||
- [ ] **Latency mediana**: <50 ms su 1920×1080 → **~0.8s su 646×482 con 2 core; da misurare su hardware produzione**
|
||||
- [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico**
|
||||
|
||||
Prossimo blocker per target: **latency**. Via più promettente: GPU (CuPy) o coarse-to-fine angolare.
|
||||
Prossimo blocker per target: **latency**. Nota: i kernel hot sono gia'
|
||||
Numba JIT (≈ velocita' C, prange parallelo): un port C++ dei kernel vale
|
||||
solo il margine SIMD esplicito (~2-4x con AVX2 su AND+popcount byte-wise).
|
||||
Prima di scriverlo conviene esaurire le vie algoritmiche rimaste:
|
||||
riduzione varianti al top-level (auto angle step per livello, stile
|
||||
Halcon), greediness di default, e GPU (CuPy/OpenCL) per scene 1080p.
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 530 KiB |
|
Before Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 546 KiB |
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 130 KiB |
@@ -36,6 +36,11 @@ CONFIGS = [
|
||||
def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str,
|
||||
cfg_name: str, cfg: dict) -> dict:
|
||||
scene = cv2.imread(str(TEST_DIR / img_path))
|
||||
if scene is None:
|
||||
# cv2.imread ritorna None silenzioso: senza check il crash arriva
|
||||
# dopo, sullo slice, con un errore criptico.
|
||||
raise FileNotFoundError(
|
||||
f"Immagine di test non trovata o non leggibile: {TEST_DIR / img_path}")
|
||||
y0, y1, x0, x1 = roi_box
|
||||
roi = scene[y0:y1, x0:x1].copy()
|
||||
m = LineShapeMatcher(
|
||||
|
||||
@@ -271,6 +271,108 @@ if HAS_NUMBA:
|
||||
acc[y, x] = 0.0
|
||||
return acc
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_score_bitmap_rescored_window(
|
||||
spread: np.ndarray, # uint8 (H, W) - scena INTERA
|
||||
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: np.uint8,
|
||||
bg: np.ndarray, # float32 (H, W) - scena intera
|
||||
y0: nb.int64, x0: nb.int64,
|
||||
wh: nb.int64, ww: nb.int64,
|
||||
) -> np.ndarray:
|
||||
"""Score rescored valutato SOLO nella finestra (y0, x0, wh, ww).
|
||||
|
||||
Le feature campionano lo spread dell'intera scena (bounds-checked
|
||||
sui bordi scena): a differenza di chiamare il kernel su un crop,
|
||||
le feature che escono dalla finestra NON contano come miss.
|
||||
Usato dal path pyramid_propagate: costo ∝ area finestra.
|
||||
"""
|
||||
H, W = spread.shape
|
||||
N = dx.shape[0]
|
||||
acc = np.zeros((wh, ww), dtype=np.float32)
|
||||
for yi in nb.prange(wh):
|
||||
y = y0 + yi
|
||||
for i in range(N):
|
||||
b = bins[i]
|
||||
mask = np.uint8(1) << b
|
||||
if (bit_active & mask) == 0:
|
||||
continue
|
||||
yy = y + dy[i]
|
||||
if yy < 0 or yy >= H:
|
||||
continue
|
||||
ddx = dx[i]
|
||||
xi_lo = 0
|
||||
xi_hi = ww
|
||||
lo = -(x0 + ddx)
|
||||
if lo > xi_lo:
|
||||
xi_lo = lo
|
||||
hi = W - (x0 + ddx)
|
||||
if hi < xi_hi:
|
||||
xi_hi = hi
|
||||
for xi in range(xi_lo, xi_hi):
|
||||
if spread[yy, x0 + xi + ddx] & mask:
|
||||
acc[yi, xi] += 1.0
|
||||
if N > 0:
|
||||
inv = 1.0 / N
|
||||
for yi in nb.prange(wh):
|
||||
for xi in range(ww):
|
||||
v = acc[yi, xi] * inv
|
||||
bgv = bg[y0 + yi, x0 + xi]
|
||||
if bgv < 1.0:
|
||||
r = (v - bgv) / (1.0 - bgv + 1e-6)
|
||||
acc[yi, xi] = r if r > 0.0 else 0.0
|
||||
else:
|
||||
acc[yi, xi] = 0.0
|
||||
return acc
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_score_bitmap_rescored_window_u16(
|
||||
spread: np.ndarray, # uint16 (H, W)
|
||||
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: np.uint16,
|
||||
bg: np.ndarray,
|
||||
y0: nb.int64, x0: nb.int64,
|
||||
wh: nb.int64, ww: nb.int64,
|
||||
) -> np.ndarray:
|
||||
"""Versione uint16 (polarity 16-bin) del kernel windowed."""
|
||||
H, W = spread.shape
|
||||
N = dx.shape[0]
|
||||
acc = np.zeros((wh, ww), dtype=np.float32)
|
||||
for yi in nb.prange(wh):
|
||||
y = y0 + yi
|
||||
for i in range(N):
|
||||
b = bins[i]
|
||||
mask = np.uint16(1) << b
|
||||
if (bit_active & mask) == 0:
|
||||
continue
|
||||
yy = y + dy[i]
|
||||
if yy < 0 or yy >= H:
|
||||
continue
|
||||
ddx = dx[i]
|
||||
xi_lo = 0
|
||||
xi_hi = ww
|
||||
lo = -(x0 + ddx)
|
||||
if lo > xi_lo:
|
||||
xi_lo = lo
|
||||
hi = W - (x0 + ddx)
|
||||
if hi < xi_hi:
|
||||
xi_hi = hi
|
||||
for xi in range(xi_lo, xi_hi):
|
||||
if spread[yy, x0 + xi + ddx] & mask:
|
||||
acc[yi, xi] += 1.0
|
||||
if N > 0:
|
||||
inv = 1.0 / N
|
||||
for yi in nb.prange(wh):
|
||||
for xi in range(ww):
|
||||
v = acc[yi, xi] * inv
|
||||
bgv = bg[y0 + yi, x0 + xi]
|
||||
if bgv < 1.0:
|
||||
r = (v - bgv) / (1.0 - bgv + 1e-6)
|
||||
acc[yi, xi] = r if r > 0.0 else 0.0
|
||||
else:
|
||||
acc[yi, xi] = 0.0
|
||||
return acc
|
||||
|
||||
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
|
||||
def _jit_top_max_per_variant(
|
||||
spread: np.ndarray, # uint8 (H, W)
|
||||
@@ -426,6 +528,9 @@ if HAS_NUMBA:
|
||||
_jit_top_max_per_variant(
|
||||
spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx,
|
||||
)
|
||||
_jit_score_bitmap_rescored_window(
|
||||
spread, dx, dy, b, np.uint8(0xFF), bg, 4, 4, 8, 8,
|
||||
)
|
||||
_jit_popcount_density(spread)
|
||||
spread16 = np.zeros((32, 32), dtype=np.uint16)
|
||||
_jit_score_bitmap_rescored_u16(
|
||||
@@ -447,6 +552,12 @@ else: # pragma: no cover
|
||||
def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_score_bitmap_rescored_window(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_score_bitmap_rescored_window_u16(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness):
|
||||
raise RuntimeError("numba non disponibile")
|
||||
|
||||
@@ -524,6 +635,39 @@ def score_bitmap_rescored(
|
||||
return np.maximum(0.0, out).astype(np.float32)
|
||||
|
||||
|
||||
def score_bitmap_rescored_window(
|
||||
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: int, bg: np.ndarray,
|
||||
y0: int, x0: int, wh: int, ww: int,
|
||||
) -> np.ndarray:
|
||||
"""Score rescored solo nella finestra (y0, x0, wh, ww) della scena.
|
||||
|
||||
Le feature campionano l'INTERA scena: feature fuori finestra ma dentro
|
||||
scena contano correttamente (chiamare il kernel su un crop le tratta
|
||||
come miss e azzera lo score — il bug che rendeva inutilizzabile il
|
||||
path pyramid_propagate). Fallback no-numba: kernel pieno + slice.
|
||||
"""
|
||||
if HAS_NUMBA and len(dx) > 0:
|
||||
dx_c = np.ascontiguousarray(dx, dtype=np.int32)
|
||||
dy_c = np.ascontiguousarray(dy, dtype=np.int32)
|
||||
bins_c = np.ascontiguousarray(bins, dtype=np.int8)
|
||||
bg_c = np.ascontiguousarray(bg, dtype=np.float32)
|
||||
if spread.dtype == np.uint16:
|
||||
return _jit_score_bitmap_rescored_window_u16(
|
||||
np.ascontiguousarray(spread, dtype=np.uint16),
|
||||
dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
|
||||
int(y0), int(x0), int(wh), int(ww),
|
||||
)
|
||||
return _jit_score_bitmap_rescored_window(
|
||||
np.ascontiguousarray(spread, dtype=np.uint8),
|
||||
dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
|
||||
int(y0), int(x0), int(wh), int(ww),
|
||||
)
|
||||
# Fallback (lento, solo senza numba): score full-frame + slice finestra
|
||||
full = score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg)
|
||||
return full[y0:y0 + wh, x0:x0 + ww]
|
||||
|
||||
|
||||
def score_bitmap_greedy(
|
||||
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
|
||||
bit_active: int, min_score: float, greediness: float,
|
||||
|
||||
@@ -61,6 +61,8 @@ def detect_rotational_symmetry(
|
||||
|
||||
center = (w / 2.0, h / 2.0)
|
||||
ref = mag
|
||||
# ref è costante nel loop sugli angoli: centra una volta sola
|
||||
rm = ref - ref.mean()
|
||||
|
||||
correlations: list[tuple[float, float]] = []
|
||||
for ang in np.arange(step_deg, 360.0, step_deg):
|
||||
@@ -68,7 +70,6 @@ def detect_rotational_symmetry(
|
||||
rot = cv2.warpAffine(
|
||||
mag, M, (w, h), borderValue=0.0,
|
||||
)
|
||||
rm = ref - ref.mean()
|
||||
rs = rot - rot.mean()
|
||||
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
|
||||
c = float((rm * rs).sum() / denom)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Benchmark suite per LineShapeMatcher.
|
||||
|
||||
Usage:
|
||||
python -m pm2d.bench [--quick]
|
||||
|
||||
Misura tempi find() su 3 template-tipo × 3 scene-tipo × N config:
|
||||
- Template: rettangolo 80×80, L-shape 120×120, cerchio 150×150
|
||||
- Scene: pulita 800×600, cluttered 1080×1920, multi-pezzo 1080×1920
|
||||
- Config: baseline, polarity, gpu, pyramid_propagate, greediness=0.7
|
||||
|
||||
Per ogni config stampa: ms/find, ms per fase (profile), n. match.
|
||||
Output tabellare per detectare regressioni in CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from pm2d.line_matcher import LineShapeMatcher, opencl_available
|
||||
|
||||
|
||||
# ---------- Sintetizzatori template/scena ----------
|
||||
|
||||
def _tpl_rect() -> np.ndarray:
|
||||
t = np.zeros((80, 80, 3), np.uint8)
|
||||
cv2.rectangle(t, (15, 15), (65, 65), (255, 255, 255), 3)
|
||||
return t
|
||||
|
||||
|
||||
def _tpl_lshape() -> np.ndarray:
|
||||
t = np.zeros((120, 120, 3), np.uint8)
|
||||
cv2.rectangle(t, (20, 20), (50, 100), (255, 255, 255), -1)
|
||||
cv2.rectangle(t, (20, 70), (100, 100), (255, 255, 255), -1)
|
||||
return t
|
||||
|
||||
|
||||
def _tpl_circle() -> np.ndarray:
|
||||
t = np.zeros((150, 150, 3), np.uint8)
|
||||
cv2.circle(t, (75, 75), 60, (255, 255, 255), 4)
|
||||
return t
|
||||
|
||||
|
||||
def _scene_clean(W: int, H: int, n_pieces: int = 1) -> np.ndarray:
|
||||
np.random.seed(0)
|
||||
s = np.zeros((H, W, 3), np.uint8)
|
||||
for _ in range(n_pieces):
|
||||
cx = np.random.randint(80, W - 80)
|
||||
cy = np.random.randint(80, H - 80)
|
||||
cv2.rectangle(s, (cx - 25, cy - 25), (cx + 25, cy + 25), (255, 255, 255), 3)
|
||||
return s
|
||||
|
||||
|
||||
def _scene_cluttered(W: int, H: int) -> np.ndarray:
|
||||
np.random.seed(0)
|
||||
s = np.random.randint(50, 200, (H, W, 3), np.uint8)
|
||||
cv2.rectangle(s, (300, 200), (350, 250), (255, 255, 255), 3)
|
||||
cv2.rectangle(s, (1500, 800), (1550, 850), (255, 255, 255), 3)
|
||||
return s
|
||||
|
||||
|
||||
# ---------- Single benchmark ----------
|
||||
|
||||
def _bench_config(template, scene, config_name: str,
|
||||
init_kw: dict, find_kw: dict,
|
||||
n_iter: int = 5) -> dict:
|
||||
m = LineShapeMatcher(**init_kw)
|
||||
t0 = time.perf_counter()
|
||||
n_var = m.train(template)
|
||||
t_train = time.perf_counter() - t0
|
||||
|
||||
# Warmup (Numba JIT)
|
||||
m.find(scene, **find_kw)
|
||||
m.find(scene, **find_kw)
|
||||
|
||||
# Run
|
||||
times_ms = []
|
||||
for _ in range(n_iter):
|
||||
t0 = time.perf_counter()
|
||||
matches = m.find(scene, **find_kw)
|
||||
times_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||
|
||||
# Profile (1 iter)
|
||||
m.find(scene, profile=True, **find_kw)
|
||||
prof = m.get_last_profile() or {}
|
||||
|
||||
return {
|
||||
"config": config_name,
|
||||
"n_variants": n_var,
|
||||
"t_train_s": round(t_train, 3),
|
||||
"ms_avg": round(float(np.mean(times_ms)), 1),
|
||||
"ms_min": round(float(np.min(times_ms)), 1),
|
||||
"ms_max": round(float(np.max(times_ms)), 1),
|
||||
"n_matches": len(matches),
|
||||
"profile_ms": {k: round(v, 1) for k, v in prof.items()},
|
||||
}
|
||||
|
||||
|
||||
# ---------- Suite ----------
|
||||
|
||||
CONFIGS = [
|
||||
("baseline",
|
||||
{"angle_step_deg": 10, "pyramid_levels": 2},
|
||||
{"min_score": 0.4, "verify_threshold": 0.2}),
|
||||
("polarity",
|
||||
{"angle_step_deg": 10, "pyramid_levels": 2, "use_polarity": True},
|
||||
{"min_score": 0.4, "verify_threshold": 0.2}),
|
||||
("propagate",
|
||||
{"angle_step_deg": 10, "pyramid_levels": 3},
|
||||
{"min_score": 0.4, "verify_threshold": 0.2,
|
||||
"pyramid_propagate": True, "propagate_topk": 4}),
|
||||
("greedy_07",
|
||||
{"angle_step_deg": 10, "pyramid_levels": 2},
|
||||
{"min_score": 0.4, "verify_threshold": 0.2, "greediness": 0.7}),
|
||||
("stride2",
|
||||
{"angle_step_deg": 10, "pyramid_levels": 2},
|
||||
{"min_score": 0.4, "verify_threshold": 0.2, "coarse_stride": 2}),
|
||||
]
|
||||
|
||||
if opencl_available():
|
||||
CONFIGS.append(
|
||||
("gpu_umat",
|
||||
{"angle_step_deg": 10, "pyramid_levels": 2, "use_gpu": True},
|
||||
{"min_score": 0.4, "verify_threshold": 0.2})
|
||||
)
|
||||
|
||||
|
||||
SCENARIOS = [
|
||||
("rect_80 vs scene_800x600", _tpl_rect, lambda: _scene_clean(800, 600, 1)),
|
||||
("lshape_120 vs scene_1080x1920_clutter",
|
||||
_tpl_lshape, lambda: _scene_cluttered(1920, 1080)),
|
||||
("circle_150 vs scene_clean_3pieces",
|
||||
_tpl_circle, lambda: _scene_clean(1920, 1080, 3)),
|
||||
]
|
||||
|
||||
|
||||
def run(quick: bool = False) -> int:
|
||||
n_iter = 2 if quick else 5
|
||||
print(f"=== PM2D Benchmark Suite ({len(SCENARIOS)} scenarios x "
|
||||
f"{len(CONFIGS)} configs, n_iter={n_iter}) ===\n")
|
||||
rows = []
|
||||
for sc_name, tpl_fn, scn_fn in SCENARIOS:
|
||||
template = tpl_fn()
|
||||
scene = scn_fn()
|
||||
print(f"--- Scenario: {sc_name} (tpl={template.shape}, "
|
||||
f"scn={scene.shape}) ---")
|
||||
for cfg_name, init_kw, find_kw in CONFIGS:
|
||||
r = _bench_config(template, scene, cfg_name, init_kw, find_kw,
|
||||
n_iter=n_iter)
|
||||
r["scenario"] = sc_name
|
||||
rows.append(r)
|
||||
prof_str = " ".join(
|
||||
f"{k}={v:.1f}" for k, v in r["profile_ms"].items()
|
||||
)
|
||||
print(f" {cfg_name:14s} {r['ms_avg']:6.1f}ms "
|
||||
f"(min {r['ms_min']:.1f} max {r['ms_max']:.1f}) "
|
||||
f"vars={r['n_variants']:3d} "
|
||||
f"matches={r['n_matches']:2d}")
|
||||
if prof_str:
|
||||
print(f" profile: {prof_str}")
|
||||
print()
|
||||
print("=== Done ===")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="PM2D benchmark suite")
|
||||
p.add_argument("--quick", action="store_true",
|
||||
help="2 iterazioni per config invece di 5 (smoke test)")
|
||||
args = p.parse_args(argv)
|
||||
return run(quick=args.quick)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
@@ -12,7 +12,6 @@ Tutta la logica algoritmica vive in pm2d.matcher.EdgeShapeMatcher.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tkinter import Tk, filedialog
|
||||
import tkinter as tk
|
||||
@@ -196,8 +195,10 @@ def _warp_template_edges_to_scene(
|
||||
edge = cv2.Canny(template_gray, canny_low, canny_high)
|
||||
# Matrice affine: scala + rotazione attorno al centro template, poi traslazione
|
||||
Ht, Wt = h, w
|
||||
cx_t = (Wt - 1) / 2.0
|
||||
cy_t = (Ht - 1) / 2.0
|
||||
# Centro coerente con la convenzione train (center = w / 2.0, no -1):
|
||||
# (Wt-1)/2 introduceva uno shift di 0.5px per template di lato pari.
|
||||
cx_t = Wt / 2.0
|
||||
cy_t = Ht / 2.0
|
||||
M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
|
||||
# Traslazione per portare centro template a (cx, cy) della scena
|
||||
M[0, 2] += cx - cx_t
|
||||
@@ -492,7 +493,9 @@ def run(
|
||||
num_features: int = 96,
|
||||
weak_grad: float = 30.0,
|
||||
strong_grad: float = 60.0,
|
||||
spread_radius: int = 5,
|
||||
# 4 allineato col default del matcher: raggio 5 peggiora la precisione
|
||||
# di rotazione (spread troppo largo appiattisce il picco angolare).
|
||||
spread_radius: int = 4,
|
||||
pyramid_levels: int = 3,
|
||||
min_score: float = 0.55,
|
||||
max_matches: int = 25,
|
||||
|
||||
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
|
||||
a0, a1 = self.angle_range_deg
|
||||
if self.angle_step_deg <= 0 or a0 >= a1:
|
||||
return [float(a0)]
|
||||
n = int(np.floor((a1 - a0) / self.angle_step_deg))
|
||||
return [float(a0 + i * self.angle_step_deg) for i in range(n)]
|
||||
# n+1 valori per includere l'estremo superiore del range: con il
|
||||
# solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one).
|
||||
n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1
|
||||
angles = [float(a0 + i * self.angle_step_deg) for i in range(n)]
|
||||
if a1 - a0 >= 360.0:
|
||||
# Range che copre il giro completo: a0+360° è la stessa pose di
|
||||
# a0, escludi il duplicato (variante inutile in train/find).
|
||||
eps = 1e-6
|
||||
angles = [a for a in angles if a < a0 + 360.0 - eps]
|
||||
return angles
|
||||
|
||||
def train(self, template_bgr: np.ndarray) -> int:
|
||||
"""Genera varianti per tutte le combinazioni (angolo, scala)."""
|
||||
@@ -222,6 +230,14 @@ class EdgeShapeMatcher:
|
||||
for y, x in zip(ys, xs):
|
||||
candidates.append((float(res[y, x]), int(x), int(y), ti))
|
||||
|
||||
# Cap candidati top-level: senza limite np.where con soglia bassa
|
||||
# può generare migliaia di candidati, ognuno con un matchTemplate
|
||||
# full-res nel refinement. Tieni solo i migliori per score.
|
||||
max_candidates = max(1, max_matches * 10)
|
||||
if len(candidates) > max_candidates:
|
||||
candidates.sort(key=lambda c: -c[0])
|
||||
candidates = candidates[:max_candidates]
|
||||
|
||||
# Refinement a risoluzione piena: per ogni candidato top, finestra locale
|
||||
refined: list[tuple[float, int, int, int]] = []
|
||||
margin = sf + 4
|
||||
@@ -294,6 +310,10 @@ class EdgeShapeMatcher:
|
||||
)
|
||||
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)}
|
||||
arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)})
|
||||
# Persisti anche il grayscale originale: senza, l'overlay edge
|
||||
# spariva dopo load() (template_gray restava None).
|
||||
if self.template_gray is not None:
|
||||
arrays["template_gray"] = self.template_gray
|
||||
np.savez_compressed(path, params=params, meta=meta, **arrays)
|
||||
|
||||
@classmethod
|
||||
@@ -312,6 +332,10 @@ class EdgeShapeMatcher:
|
||||
top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
|
||||
)
|
||||
m.template_size = (int(p[8]), int(p[9]))
|
||||
# Retrocompatibilità: modelli salvati prima non hanno template_gray
|
||||
# (resta None: overlay edge non disponibile ma find() funziona).
|
||||
if "template_gray" in z.files:
|
||||
m.template_gray = z["template_gray"]
|
||||
meta = z["meta"]
|
||||
for i in range(len(meta)):
|
||||
m.templates.append(
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
@@ -54,6 +55,7 @@ RECIPES_DIR.mkdir(exist_ok=True)
|
||||
|
||||
from pm2d.line_matcher import LineShapeMatcher, Match
|
||||
from pm2d.auto_tune import auto_tune
|
||||
from pm2d.dxf import dxf_to_image
|
||||
|
||||
|
||||
WEB_DIR = Path(__file__).parent
|
||||
@@ -64,23 +66,36 @@ STATIC_DIR.mkdir(exist_ok=True)
|
||||
CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache"
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Cache in-memory (soft, ricaricata da disco se mancante)
|
||||
_IMG_CACHE: dict[str, np.ndarray] = {}
|
||||
# Cache in-memory (soft, ricaricata da disco se mancante).
|
||||
# LRU con capacità limitata: senza eviction le immagini si accumulavano
|
||||
# senza limite (leak di memoria su server long-running).
|
||||
_IMG_CACHE: OrderedDict[str, np.ndarray] = OrderedDict()
|
||||
_IMG_CACHE_SIZE = 64
|
||||
|
||||
# Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher
|
||||
# LRU con capacità limitata
|
||||
_MATCHER_CACHE: OrderedDict = OrderedDict()
|
||||
_MATCHER_CACHE_SIZE = 8
|
||||
|
||||
# Lock globale matcher: gli endpoint girano nel threadpool FastAPI ma i
|
||||
# matcher condivisi (_MATCHER_CACHE, _RECIPE_MATCHERS) mutano stato interno
|
||||
# durante train()/find(). Serializzare il matching è la soluzione semplice
|
||||
# e corretta (un lock per-ricetta sarebbe over-engineering).
|
||||
_MATCHER_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
|
||||
h = hashlib.md5()
|
||||
h.update(roi.tobytes())
|
||||
# Solo parametri che influenzano il training
|
||||
relevant = ("num_features", "weak_grad", "strong_grad",
|
||||
"min_feature_spacing",
|
||||
"angle_min", "angle_max", "angle_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:
|
||||
h.update(f"{k}={tech.get(k)}".encode())
|
||||
h.update(f"shape={roi.shape}".encode())
|
||||
@@ -101,23 +116,32 @@ def _cache_put_matcher(key: str, matcher) -> None:
|
||||
_MATCHER_CACHE.popitem(last=False)
|
||||
|
||||
|
||||
def _img_cache_put(key: str, value: np.ndarray) -> None:
|
||||
"""Inserisce in _IMG_CACHE con eviction LRU (cap _IMG_CACHE_SIZE)."""
|
||||
_IMG_CACHE[key] = value
|
||||
_IMG_CACHE.move_to_end(key)
|
||||
while len(_IMG_CACHE) > _IMG_CACHE_SIZE:
|
||||
_IMG_CACHE.popitem(last=False)
|
||||
|
||||
|
||||
def _store_image(img: np.ndarray) -> str:
|
||||
iid = uuid.uuid4().hex[:12]
|
||||
cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img)
|
||||
_IMG_CACHE[iid] = img
|
||||
_img_cache_put(iid, img)
|
||||
return iid
|
||||
|
||||
|
||||
def _load_image(iid: str) -> np.ndarray | None:
|
||||
cached = _IMG_CACHE.get(iid)
|
||||
if cached is not None:
|
||||
_IMG_CACHE.move_to_end(iid) # LRU touch
|
||||
return cached
|
||||
p = CACHE_DIR / f"{iid}.png"
|
||||
if not p.exists():
|
||||
return None
|
||||
img = cv2.imread(str(p))
|
||||
if img is not None:
|
||||
_IMG_CACHE[iid] = img
|
||||
_img_cache_put(iid, img)
|
||||
return img
|
||||
|
||||
app = FastAPI(title="PM2D Webapp", version="1.0.0")
|
||||
@@ -130,129 +154,170 @@ def _encode_png(img: np.ndarray) -> bytes:
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
def _clamp_roi(x: int, y: int, w: int, h: int,
|
||||
img_w: int, img_h: int) -> tuple[int, int, int, int]:
|
||||
"""Clampa la ROI dentro i limiti immagine.
|
||||
|
||||
Una ROI fuori immagine causava slice vuote → crash 500 negli endpoint
|
||||
che non clampavano. Solleva 400 se la ROI risultante è degenere
|
||||
(lato < 16 px: sotto questa soglia il train non estrae abbastanza
|
||||
edge feature e produce 0 varianti → find() esplode con 500).
|
||||
"""
|
||||
x = max(0, min(int(x), img_w - 1))
|
||||
y = max(0, min(int(y), img_h - 1))
|
||||
w = min(int(w), img_w - x)
|
||||
h = min(int(h), img_h - y)
|
||||
if w < 16 or h < 16:
|
||||
raise HTTPException(
|
||||
400, f"ROI fuori immagine o degenere: [{x}, {y}, {w}, {h}] "
|
||||
f"su immagine {img_w}x{img_h} (lato minimo 16 px)")
|
||||
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:
|
||||
"""Solleva 422 se il train non ha prodotto varianti.
|
||||
|
||||
Succede con ROI senza contrasto (sfondo uniforme) o troppo piccola:
|
||||
senza questo check il find() successivo esplode con RuntimeError → 500.
|
||||
"""
|
||||
if n_variants <= 0 or not m.variants:
|
||||
raise HTTPException(
|
||||
422, "La ROI non contiene abbastanza edge feature per il "
|
||||
"training (zona troppo uniforme o piccola): scegliere "
|
||||
"una regione con contorni netti")
|
||||
|
||||
|
||||
def _draw_matches(scene: np.ndarray, matches: list[Match],
|
||||
template_gray: np.ndarray | None,
|
||||
matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
|
||||
"""Disegna match annotati sulla scena.
|
||||
"""Disegna SOLO UCS (richiesta utente) per ogni match trovato.
|
||||
|
||||
Se matcher e' passato, usa la stessa pipeline di edge filtering
|
||||
(hysteresis weak/strong_grad) e selezione feature usata in training,
|
||||
cosi' l'overlay nel match riflette ESATTAMENTE quello che l'utente
|
||||
ha visto nel preview "Anteprima edge". Inoltre disegna UCS
|
||||
(asse X rosso, Y verde) sul centro pose del match.
|
||||
|
||||
Senza matcher: fallback Canny (legacy).
|
||||
UCS = sistema di coordinate (X rosso, Y verde) posizionato sul
|
||||
baricentro feature del modello, ruotato secondo l'angolo del match.
|
||||
Niente edge, niente cerchietti feature, niente bbox: i match sulla
|
||||
scena reale devono essere puliti, gli edge filtrati si vedono solo
|
||||
nell'anteprima modello.
|
||||
"""
|
||||
out = scene.copy()
|
||||
H, W = scene.shape[:2]
|
||||
palette = [
|
||||
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0),
|
||||
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255),
|
||||
]
|
||||
bin_colors = [
|
||||
(255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0),
|
||||
(0, 255, 255), (0, 128, 255), (0, 0, 255), (255, 0, 255),
|
||||
(255, 100, 100), (255, 180, 100), (255, 230, 100), (180, 255, 100),
|
||||
(100, 255, 200), (100, 180, 255), (180, 100, 255), (255, 100, 200),
|
||||
]
|
||||
# Lunghezza assi UCS: stessa formula dell'anteprima modello
|
||||
# (0.15 * max lato template) scalata per m.scale → coerenza dimensionale.
|
||||
if matcher is not None and matcher.template_size != (0, 0):
|
||||
L_base = int(0.15 * max(matcher.template_size))
|
||||
else:
|
||||
L_base = 30
|
||||
H_scene, W_scene = scene.shape[:2]
|
||||
|
||||
for i, m in enumerate(matches):
|
||||
color = palette[i % len(palette)]
|
||||
# Posizione UCS: baricentro feature warpate (default = cx, cy se non disponibile).
|
||||
# Mantiene coerenza con anteprima modello che mostra UCS sul baricentro.
|
||||
ucs_x, ucs_y = float(m.cx), float(m.cy)
|
||||
# UCS posizionato esattamente sul CENTRO POSE del match (m.cx, m.cy):
|
||||
# equivale al centro template traslato alla scena, ruotato con
|
||||
# m.angle_deg. Coerente con UCS dell'anteprima modello che ora
|
||||
# e' anche sul centro ROI (vedi preview_edges).
|
||||
ax = np.deg2rad(m.angle_deg)
|
||||
ca, sa = np.cos(ax), np.sin(ax)
|
||||
cx, cy = int(round(m.cx)), int(round(m.cy))
|
||||
# Overlay edge del modello orientato (richiesta utente):
|
||||
# warpa template alla pose, applica hysteresis identica al matcher,
|
||||
# disegna pixel edge come overlay verde tenue. Maschera col
|
||||
# _train_mask warpato + erode per rimuovere edge sui BORDI del
|
||||
# rettangolo template (transizione bordo nero → scena = falso edge
|
||||
# che appariva come "ROI" attorno a ogni match).
|
||||
if template_gray is not None and matcher is not None:
|
||||
t = template_gray
|
||||
th, tw = t.shape
|
||||
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
|
||||
# Centro template coerente col training: in train si usa
|
||||
# `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
|
||||
# introduceva uno shift di 0.5px per template di lato pari.
|
||||
cx_t = tw / 2.0; cy_t = th / 2.0
|
||||
# Lavora su un CROP locale della scena di lato = diagonale del
|
||||
# template ruotato+scalato (+margine), come _verify_ncc: warp
|
||||
# + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno
|
||||
# (costosissimo su scene grandi con molti match).
|
||||
diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8
|
||||
x0 = int(round(m.cx)) - diag // 2
|
||||
y0 = int(round(m.cy)) - diag // 2
|
||||
gx0 = max(0, x0); gy0 = max(0, y0)
|
||||
gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag)
|
||||
cw, ch_ = gx1 - gx0, gy1 - gy0
|
||||
if cw >= 3 and ch_ >= 3:
|
||||
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
|
||||
M[0, 2] += m.cx - cx_t
|
||||
M[1, 2] += m.cy - cy_t
|
||||
# Background edge filtrati: warpa template + hysteresis
|
||||
# Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop
|
||||
M[0, 2] += (m.cx - gx0) - cx_t
|
||||
M[1, 2] += (m.cy - gy0) - cy_t
|
||||
warped_gray = cv2.warpAffine(
|
||||
t, M, (W, H), flags=cv2.INTER_LINEAR, borderValue=0)
|
||||
t, M, (cw, ch_),
|
||||
flags=cv2.INTER_LINEAR, borderValue=0)
|
||||
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno
|
||||
mask_src = (matcher._train_mask if matcher._train_mask is not None
|
||||
else np.full((th, tw), 255, dtype=np.uint8))
|
||||
warped_mask = cv2.warpAffine(
|
||||
mask_src, M, (cw, ch_),
|
||||
flags=cv2.INTER_NEAREST, borderValue=0)
|
||||
# Erode minimo (3x3) per togliere SOLO artefatti border-padding
|
||||
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
|
||||
# grande spostava visualmente l'edge verso l'interno e creava
|
||||
# apparente "traslazione fissa" rispetto al bordo del pezzo.
|
||||
kernel_er = np.ones((3, 3), np.uint8)
|
||||
warped_mask = cv2.erode(warped_mask, kernel_er)
|
||||
mag, _ = matcher._gradient(warped_gray)
|
||||
if matcher.weak_grad < matcher.strong_grad:
|
||||
edge_mask = matcher._hysteresis_mask(mag)
|
||||
else:
|
||||
edge_mask = mag >= matcher.strong_grad
|
||||
edge_mask = edge_mask & (warped_mask > 0)
|
||||
if edge_mask.any():
|
||||
bg_overlay = np.zeros_like(out)
|
||||
dark = tuple(int(c * 0.35) for c in color)
|
||||
bg_overlay[edge_mask] = dark
|
||||
out = cv2.addWeighted(out, 1.0, bg_overlay, 0.7, 0)
|
||||
# Feature reali del matcher: usa quelle pre-computate della
|
||||
# variante che ha generato il match. Stesse identiche feature
|
||||
# mostrate nell'anteprima modello (ruotate/scalate alla pose).
|
||||
vi = getattr(m, "variant_idx", -1)
|
||||
fx_scene = fy_scene = fb_arr = None
|
||||
if 0 <= vi < len(matcher.variants):
|
||||
lvl0 = matcher.variants[vi].levels[0]
|
||||
# dx/dy sono offsets relativi al CENTRO del template warpato
|
||||
# nelle coordinate del kernel template (gia' pre-ruotate
|
||||
# all'angolo della variante grezza). Per coerenza con la
|
||||
# pose finale m.angle_deg (post-refine), ri-rotazione del
|
||||
# delta angolare (m.angle_deg - var.angle_deg).
|
||||
var = matcher.variants[vi]
|
||||
dang = np.deg2rad(m.angle_deg - var.angle_deg)
|
||||
ca, sa = np.cos(dang), np.sin(dang)
|
||||
dxr = lvl0.dx * ca + lvl0.dy * sa
|
||||
dyr = -lvl0.dx * sa + lvl0.dy * ca
|
||||
fx_scene = m.cx + dxr
|
||||
fy_scene = m.cy + dyr
|
||||
fb_arr = lvl0.bin
|
||||
else:
|
||||
# Fallback: estrai feature dal warpato (perde precisione)
|
||||
_, bins_w = matcher._gradient(warped_gray)
|
||||
fx, fy, fb = matcher._extract_features(mag, bins_w, None)
|
||||
fx_scene = fx.astype(np.float32)
|
||||
fy_scene = fy.astype(np.float32)
|
||||
fb_arr = fb
|
||||
# Disegna feature
|
||||
for k in range(len(fx_scene)):
|
||||
px = int(round(float(fx_scene[k])))
|
||||
py = int(round(float(fy_scene[k])))
|
||||
if 0 <= px < W and 0 <= py < H:
|
||||
bcol = bin_colors[int(fb_arr[k]) % len(bin_colors)]
|
||||
cv2.circle(out, (px, py), 2, bcol, -1, cv2.LINE_AA)
|
||||
# UCS sul baricentro feature (in scene coords)
|
||||
if len(fx_scene) > 0:
|
||||
ucs_x = float(np.mean(fx_scene))
|
||||
ucs_y = float(np.mean(fy_scene))
|
||||
elif template_gray is not None:
|
||||
# Senza matcher: legacy Canny
|
||||
t = template_gray
|
||||
th, tw = t.shape
|
||||
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
|
||||
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
|
||||
M[0, 2] += m.cx - cx_t
|
||||
M[1, 2] += m.cy - cy_t
|
||||
edge = cv2.Canny(t, 50, 150)
|
||||
warped = cv2.warpAffine(edge, M, (W, H),
|
||||
flags=cv2.INTER_NEAREST, borderValue=0)
|
||||
mask = warped > 0
|
||||
if mask.any():
|
||||
overlay = np.zeros_like(out)
|
||||
overlay[mask] = color
|
||||
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8)
|
||||
# bbox poly e linea-marker rimossi (richiesta utente "togli la ROI"):
|
||||
# UCS + edge filtrati gia' identificano pose e orientamento.
|
||||
cx, cy = int(round(ucs_x)), int(round(ucs_y))
|
||||
# UCS sul centro pose match (richiesta utente: come nell'anteprima
|
||||
# modello). Asse X rosso destra, Y verde basso (image y-down).
|
||||
# Lunghezza derivata dalla diagonale bbox per scala-invariante.
|
||||
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
|
||||
if L < 10:
|
||||
L = 30 # fallback se bbox degenere
|
||||
ax = np.deg2rad(m.angle_deg)
|
||||
# X axis ruotato (rosso)
|
||||
x_end = (int(cx + L * np.cos(ax)), int(cy - L * np.sin(ax)))
|
||||
# Edge ritraslati nel sistema scena: blend solo sul crop
|
||||
# (addWeighted lascia invariati i pixel con overlay nullo,
|
||||
# quindi l'output visivo è identico al full-frame).
|
||||
sub = out[gy0:gy1, gx0:gx1]
|
||||
edge_overlay = np.zeros_like(sub)
|
||||
# Ciano (cambiato da verde): non collide col verde dell'asse
|
||||
# Y dell'UCS che altrimenti scompariva nell'overlay edge.
|
||||
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR)
|
||||
out[gy0:gy1, gx0:gx1] = cv2.addWeighted(
|
||||
sub, 1.0, edge_overlay, 0.6, 0)
|
||||
L = max(20, int(L_base * m.scale))
|
||||
# X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin)
|
||||
x_end = (int(cx + L * ca), int(cy - L * sa))
|
||||
# Y axis = rotazione di (0, 1) con cv2 matrix → (sin, cos)
|
||||
# A m.angle_deg=0 deve puntare GIU' (image y-down convenzione modello)
|
||||
y_end = (int(cx + L * sa), int(cy + L * ca))
|
||||
cv2.arrowedLine(out, (cx, cy), x_end,
|
||||
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
|
||||
cv2.putText(out, "X", (x_end[0] + 4, x_end[1] + 5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||
# Y axis perpendicolare (verde, +90° in image coords = giu' visivo)
|
||||
y_end = (int(cx + L * np.cos(ax + np.pi / 2)),
|
||||
int(cy - L * np.sin(ax + np.pi / 2)))
|
||||
cv2.arrowedLine(out, (cx, cy), y_end,
|
||||
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
|
||||
cv2.putText(out, "Y", (y_end[0] + 4, y_end[1] + 12),
|
||||
@@ -260,9 +325,6 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
|
||||
# Origine UCS: cerchio bianco con bordo nero
|
||||
cv2.circle(out, (cx, cy), 4, (0, 0, 0), -1, cv2.LINE_AA)
|
||||
cv2.circle(out, (cx, cy), 3, (255, 255, 255), -1, cv2.LINE_AA)
|
||||
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
|
||||
cv2.putText(out, label, (cx + 12, cy - 12),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
|
||||
return out
|
||||
|
||||
|
||||
@@ -278,6 +340,10 @@ class MatchParams(BaseModel):
|
||||
model_id: str
|
||||
scene_id: str
|
||||
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_max: float = 360.0
|
||||
angle_step: float = 5.0
|
||||
@@ -290,7 +356,9 @@ class MatchParams(BaseModel):
|
||||
num_features: int = 96
|
||||
weak_grad: float = 30.0
|
||||
strong_grad: float = 60.0
|
||||
spread_radius: int = 5
|
||||
# 4 allineato col default del matcher: raggio 5 peggiora la precisione
|
||||
# di rotazione (spread troppo largo appiattisce il picco angolare).
|
||||
spread_radius: int = 4
|
||||
pyramid_levels: int = 3
|
||||
verify_threshold: float = 0.4
|
||||
|
||||
@@ -357,6 +425,8 @@ class SimpleMatchParams(BaseModel):
|
||||
model_id: str
|
||||
scene_id: str
|
||||
roi: list[int]
|
||||
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
|
||||
roi_poly: list[list[float]] | None = None
|
||||
tipo: str = "intero" # "intero" | "parziale"
|
||||
simmetria: str = "nessuna" # chiave SYMMETRY_TO_ANGLE_MAX
|
||||
scala: str = "fissa" # chiave SCALE_PRESETS
|
||||
@@ -365,6 +435,15 @@ class SimpleMatchParams(BaseModel):
|
||||
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
|
||||
min_score: float = 0.65
|
||||
max_matches: int = 25
|
||||
# --- Override edge da pannello "Anteprima edge" (None = auto_tune) ---
|
||||
# Quando settati, sovrascrivono i valori derivati da auto_tune e
|
||||
# vengono usati identici sia nel training del matcher sia nel find.
|
||||
# Salvati nella ricetta cosi' la stessa pulizia rumore e' replicata
|
||||
# quando la ricetta viene caricata.
|
||||
edge_weak_grad: float | None = None
|
||||
edge_strong_grad: float | None = None
|
||||
edge_num_features: int | None = None
|
||||
edge_min_feature_spacing: int | None = None
|
||||
# --- Halcon-mode flags (default off = backward compat) ---
|
||||
# Init-time (richiede ri-train se cambiato)
|
||||
use_polarity: bool = False # F: 16 bin orientation mod 2pi
|
||||
@@ -413,10 +492,24 @@ def _simple_to_technical(
|
||||
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
|
||||
ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0)
|
||||
|
||||
# Override edge dal pannello "Anteprima edge" se utente li ha settati.
|
||||
# Questi sostituiscono i valori auto_tune nel training del matcher,
|
||||
# garantendo che la selezione edge identica a quella del preview
|
||||
# venga usata sia in training sia in find.
|
||||
weak_g = (p.edge_weak_grad if p.edge_weak_grad is not None
|
||||
else tune["weak_grad"])
|
||||
strong_g = (p.edge_strong_grad if p.edge_strong_grad is not None
|
||||
else tune["strong_grad"])
|
||||
n_feat = (p.edge_num_features if p.edge_num_features is not None
|
||||
else nf)
|
||||
min_sp = (p.edge_min_feature_spacing if p.edge_min_feature_spacing is not None
|
||||
else 3)
|
||||
|
||||
return {
|
||||
"num_features": nf,
|
||||
"weak_grad": tune["weak_grad"],
|
||||
"strong_grad": tune["strong_grad"],
|
||||
"num_features": n_feat,
|
||||
"weak_grad": weak_g,
|
||||
"strong_grad": strong_g,
|
||||
"min_feature_spacing": min_sp,
|
||||
"spread_radius": spread,
|
||||
"pyramid_levels": pyr,
|
||||
"angle_min": 0.0,
|
||||
@@ -428,7 +521,13 @@ def _simple_to_technical(
|
||||
"min_score": p.min_score,
|
||||
"max_matches": p.max_matches,
|
||||
"nms_radius": 0,
|
||||
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35),
|
||||
# Fallback = livello "medio" della mappa (no valore hardcoded
|
||||
# che divergerebbe se la mappa cambia).
|
||||
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, FILTRO_FP_MAP["medio"]),
|
||||
# "off" deve disabilitare DAVVERO il verify NCC: passare solo
|
||||
# verify_threshold=0.0 lascerebbe attivo il calcolo NCC (che può
|
||||
# comunque scartare match con score negativo / patch uniformi).
|
||||
"verify_ncc": p.filtro_fp != "off",
|
||||
"scale_penalty": p.penalita_scala,
|
||||
}
|
||||
|
||||
@@ -547,6 +646,26 @@ async def upload(file: UploadFile = File(...)):
|
||||
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")
|
||||
def image_raw(iid: str):
|
||||
img = _load_image(iid)
|
||||
@@ -561,10 +680,14 @@ def match(p: MatchParams):
|
||||
scene = _load_image(p.scene_id)
|
||||
if model is None or scene is None:
|
||||
raise HTTPException(404, "Immagini non trovate")
|
||||
# ROI poligonale: bbox derivato dal poligono + mask per il training
|
||||
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 = max(0, x); y = max(0, y)
|
||||
w = max(1, min(w, model.shape[1] - x))
|
||||
h = max(1, min(h, model.shape[0] - y))
|
||||
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]
|
||||
|
||||
tech_for_cache = {
|
||||
@@ -576,8 +699,13 @@ def match(p: MatchParams):
|
||||
"scale_step": p.scale_step,
|
||||
"spread_radius": p.spread_radius,
|
||||
"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)
|
||||
# Lock globale: matcher condivisi tra thread del pool FastAPI
|
||||
with _MATCHER_LOCK:
|
||||
m = _cache_get_matcher(key)
|
||||
if m is None:
|
||||
m = LineShapeMatcher(
|
||||
@@ -590,7 +718,8 @@ def match(p: MatchParams):
|
||||
spread_radius=p.spread_radius,
|
||||
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)
|
||||
_cache_put_matcher(key, m)
|
||||
else:
|
||||
n = len(m.variants); t_train = 0.0
|
||||
@@ -599,6 +728,8 @@ def match(p: MatchParams):
|
||||
matches = m.find(
|
||||
scene, min_score=p.min_score, max_matches=p.max_matches,
|
||||
nms_radius=nms, verify_threshold=p.verify_threshold,
|
||||
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
|
||||
verify_ncc=p.verify_threshold > 0.0,
|
||||
)
|
||||
t_find = time.time() - t0
|
||||
|
||||
@@ -630,18 +761,27 @@ def match_simple(p: SimpleMatchParams):
|
||||
scene = _load_image(p.scene_id)
|
||||
if model is None or scene is None:
|
||||
raise HTTPException(404, "Immagini non trovate")
|
||||
# ROI poligonale: bbox derivato dal poligono + mask per il training
|
||||
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 = max(0, x); y = max(0, y)
|
||||
w = max(1, min(w, model.shape[1] - x))
|
||||
h = max(1, min(h, model.shape[0] - y))
|
||||
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]
|
||||
|
||||
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)
|
||||
# Halcon-mode init params: incidono sul training, includere in cache key
|
||||
halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}"
|
||||
key = key + halcon_init_key
|
||||
# Lock globale: matcher condivisi tra thread del pool FastAPI
|
||||
with _MATCHER_LOCK:
|
||||
m = _cache_get_matcher(key)
|
||||
if m is None:
|
||||
m = LineShapeMatcher(
|
||||
@@ -652,11 +792,13 @@ def match_simple(p: SimpleMatchParams):
|
||||
scale_range=(tech["scale_min"], tech["scale_max"]),
|
||||
scale_step=tech["scale_step"],
|
||||
spread_radius=tech["spread_radius"],
|
||||
min_feature_spacing=tech.get("min_feature_spacing", 3),
|
||||
pyramid_levels=tech["pyramid_levels"],
|
||||
use_polarity=p.use_polarity,
|
||||
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)
|
||||
_cache_put_matcher(key, m)
|
||||
else:
|
||||
n = len(m.variants); t_train = 0.0
|
||||
@@ -666,6 +808,8 @@ def match_simple(p: SimpleMatchParams):
|
||||
matches = m.find(
|
||||
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
|
||||
nms_radius=nms, verify_threshold=tech["verify_threshold"],
|
||||
# filtro_fp="off" → verify NCC davvero disabilitato
|
||||
verify_ncc=tech.get("verify_ncc", True),
|
||||
scale_penalty=tech.get("scale_penalty", 0.0),
|
||||
# Halcon-mode flags
|
||||
min_recall=p.min_recall,
|
||||
@@ -701,6 +845,7 @@ def tune(p: TuneParams):
|
||||
if model is None:
|
||||
raise HTTPException(404, "Immagine non trovata")
|
||||
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]
|
||||
t = auto_tune(roi_img)
|
||||
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
|
||||
@@ -714,6 +859,8 @@ class SaveRecipeParams(BaseModel):
|
||||
model_id: str
|
||||
scene_id: str | None = None
|
||||
roi: list[int]
|
||||
# ROI poligonale opzionale (vedi MatchParams.roi_poly)
|
||||
roi_poly: list[list[float]] | None = None
|
||||
# Riusa stessi param simple per training equivalente
|
||||
tipo: str = "intero"
|
||||
simmetria: str = "nessuna"
|
||||
@@ -721,6 +868,11 @@ class SaveRecipeParams(BaseModel):
|
||||
precisione: str = "normale"
|
||||
use_polarity: bool = False
|
||||
use_gpu: bool = False
|
||||
# Override edge dal pannello "Anteprima edge" (None = auto_tune)
|
||||
edge_weak_grad: float | None = None
|
||||
edge_strong_grad: float | None = None
|
||||
edge_num_features: int | None = None
|
||||
edge_min_feature_spacing: int | None = None
|
||||
name: str # nome file ricetta (no path)
|
||||
|
||||
|
||||
@@ -787,26 +939,23 @@ def preview_edges(p: EdgePreviewParams):
|
||||
b = int(fb[i])
|
||||
col = bin_colors[b % len(bin_colors)]
|
||||
cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA)
|
||||
# UCS sul baricentro feature (richiesta utente): assi X rosso, Y verde
|
||||
bary_cx = bary_cy = None
|
||||
if len(fx) > 0:
|
||||
bary_cx = float(np.mean(fx))
|
||||
bary_cy = float(np.mean(fy))
|
||||
bx, by = int(round(bary_cx)), int(round(bary_cy))
|
||||
axis_len = max(20, int(0.15 * max(out.shape[:2])))
|
||||
# X axis (rosso, verso destra)
|
||||
# UCS sul CENTRO ROI (coerente con _draw_matches che usa centro pose).
|
||||
# In questo modo l'UCS visualizzato nel modello = UCS del match (modulo
|
||||
# rotazione/traslazione data dalla pose del pezzo trovato).
|
||||
rh, rw = roi_img.shape[:2]
|
||||
bx, by = (rw - 1) // 2, (rh - 1) // 2
|
||||
axis_len = max(20, int(0.15 * max(rw, rh)))
|
||||
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
|
||||
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
|
||||
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||
# Y axis (verde, verso il basso = convenzione image y-down)
|
||||
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
|
||||
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
|
||||
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||
# Origine: cerchio bianco con bordo nero
|
||||
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
|
||||
cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA)
|
||||
bary_cx, bary_cy = float(bx), float(by)
|
||||
img_id = _store_image(out)
|
||||
n_edge_strong = int((mag >= m.strong_grad).sum())
|
||||
n_edge_total = int(edge_mask.sum() / 255)
|
||||
@@ -831,13 +980,24 @@ def save_recipe(p: SaveRecipeParams):
|
||||
model = _load_image(p.model_id)
|
||||
if model is None:
|
||||
raise HTTPException(404, "Modello non trovato")
|
||||
# ROI poligonale: bbox derivato dal poligono + mask per il training
|
||||
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]
|
||||
sp = SimpleMatchParams(
|
||||
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
|
||||
tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
|
||||
precisione=p.precisione,
|
||||
use_polarity=p.use_polarity, use_gpu=p.use_gpu,
|
||||
edge_weak_grad=p.edge_weak_grad,
|
||||
edge_strong_grad=p.edge_strong_grad,
|
||||
edge_num_features=p.edge_num_features,
|
||||
edge_min_feature_spacing=p.edge_min_feature_spacing,
|
||||
)
|
||||
tech = _simple_to_technical(sp, roi_img)
|
||||
m = LineShapeMatcher(
|
||||
@@ -852,7 +1012,10 @@ def save_recipe(p: SaveRecipeParams):
|
||||
use_polarity=p.use_polarity,
|
||||
use_gpu=p.use_gpu,
|
||||
)
|
||||
m.train(roi_img)
|
||||
# Lock globale: serializza il training pesante col matching in corso
|
||||
with _MATCHER_LOCK:
|
||||
n_var = m.train(roi_img, train_mask)
|
||||
_check_trained(m, n_var)
|
||||
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
|
||||
if not safe_name:
|
||||
raise HTTPException(400, "Nome ricetta non valido")
|
||||
@@ -878,6 +1041,14 @@ _RECIPE_MATCHERS: OrderedDict = OrderedDict()
|
||||
_RECIPE_MATCHERS_SIZE = 4
|
||||
|
||||
|
||||
def _recipe_matchers_put(name: str, matcher: LineShapeMatcher) -> None:
|
||||
"""Inserisce in _RECIPE_MATCHERS con eviction LRU (cap _RECIPE_MATCHERS_SIZE)."""
|
||||
_RECIPE_MATCHERS[name] = matcher
|
||||
_RECIPE_MATCHERS.move_to_end(name)
|
||||
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
|
||||
_RECIPE_MATCHERS.popitem(last=False)
|
||||
|
||||
|
||||
@app.post("/recipes/{name}/load")
|
||||
def load_recipe(name: str):
|
||||
"""Carica ricetta .npz e popola cache matcher in memoria.
|
||||
@@ -892,10 +1063,8 @@ def load_recipe(name: str):
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
|
||||
m = LineShapeMatcher.load_model(str(path))
|
||||
_RECIPE_MATCHERS[safe_name] = m
|
||||
_RECIPE_MATCHERS.move_to_end(safe_name)
|
||||
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
|
||||
_RECIPE_MATCHERS.popitem(last=False)
|
||||
with _MATCHER_LOCK:
|
||||
_recipe_matchers_put(safe_name, m)
|
||||
return {
|
||||
"name": safe_name,
|
||||
"n_variants": len(m.variants),
|
||||
@@ -919,7 +1088,9 @@ class RecipeMatchParams(BaseModel):
|
||||
greediness: float = 0.0
|
||||
refine_pose_joint: bool = False
|
||||
search_roi: list[int] | None = None
|
||||
verify_threshold: float = 0.5
|
||||
# Allineato a MatchParams.verify_threshold (0.4): valori divergenti
|
||||
# davano risultati diversi tra /match e /match_recipe a parità di scena.
|
||||
verify_threshold: float = 0.4
|
||||
scale_penalty: float = 0.0
|
||||
|
||||
|
||||
@@ -927,23 +1098,30 @@ class RecipeMatchParams(BaseModel):
|
||||
def match_recipe(p: RecipeMatchParams):
|
||||
"""Match con ricetta pre-trained: zero training, solo find."""
|
||||
safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz"
|
||||
m = _RECIPE_MATCHERS.get(safe_name)
|
||||
if m is None:
|
||||
# Auto-load on demand
|
||||
path = RECIPES_DIR / safe_name
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
|
||||
m = LineShapeMatcher.load_model(str(path))
|
||||
_RECIPE_MATCHERS[safe_name] = m
|
||||
scene = _load_image(p.scene_id)
|
||||
if scene is None:
|
||||
raise HTTPException(404, "Scena non trovata")
|
||||
search_roi_t = tuple(p.search_roi) if p.search_roi else None
|
||||
# Lock globale: matcher condivisi tra thread del pool FastAPI
|
||||
with _MATCHER_LOCK:
|
||||
m = _RECIPE_MATCHERS.get(safe_name)
|
||||
if m is not None:
|
||||
_RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch
|
||||
else:
|
||||
# Auto-load on demand: stessa eviction LRU di load_recipe
|
||||
# (senza cap la cache cresceva senza limite)
|
||||
path = RECIPES_DIR / safe_name
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
|
||||
m = LineShapeMatcher.load_model(str(path))
|
||||
_recipe_matchers_put(safe_name, m)
|
||||
t0 = time.time()
|
||||
matches = m.find(
|
||||
scene,
|
||||
min_score=p.min_score, max_matches=p.max_matches,
|
||||
verify_threshold=p.verify_threshold,
|
||||
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
|
||||
verify_ncc=p.verify_threshold > 0.0,
|
||||
scale_penalty=p.scale_penalty,
|
||||
min_recall=p.min_recall,
|
||||
use_soft_score=p.use_soft_score,
|
||||
|
||||
@@ -20,6 +20,10 @@ const state = {
|
||||
model: null, scene: null, roi: null, drag: null,
|
||||
matches: [], annotatedImg: 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 ----------
|
||||
@@ -53,10 +57,34 @@ function readUserParams() {
|
||||
document.getElementById("p-penalita-scala").value),
|
||||
min_score: parseFloat(document.getElementById("p-min-score").value),
|
||||
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
|
||||
...readEdgeOverrides(),
|
||||
...readHalconFlags(),
|
||||
};
|
||||
}
|
||||
|
||||
function readEdgeOverrides() {
|
||||
// Override edge dal pannello "Anteprima edge". Settati = utente li ha
|
||||
// toccati (anche se uguali al default attuale). Vengono propagati a
|
||||
// _simple_to_technical e usati identici sia in training sia in find.
|
||||
// Inoltre salvati nella ricetta cosi' si replicano al load.
|
||||
const _v = (id, parser) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
const v = parser(el.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
};
|
||||
// Sempre passa i valori correnti degli slider: e' la richiesta utente
|
||||
// che i param di pulizia rumore vengano usati anche nel find/ricetta.
|
||||
const polCb = document.getElementById("hc-use-polarity");
|
||||
return {
|
||||
edge_weak_grad: _v("ep-weak", parseFloat),
|
||||
edge_strong_grad: _v("ep-strong", parseFloat),
|
||||
edge_num_features: _v("ep-nf", parseInt),
|
||||
edge_min_feature_spacing: _v("ep-sp", parseInt),
|
||||
use_polarity: polCb?.checked || document.getElementById("ep-pol")?.checked,
|
||||
};
|
||||
}
|
||||
|
||||
function readHalconFlags() {
|
||||
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
|
||||
const $cb = (id) => document.getElementById(id)?.checked ?? false;
|
||||
@@ -124,6 +152,15 @@ async function uploadToFolder(file) {
|
||||
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() {
|
||||
const {files, dir} = await fetchImagesList();
|
||||
buildThumbPicker("picker-model", files, onSelectModel);
|
||||
@@ -198,6 +235,7 @@ async function onSelectModel(filename) {
|
||||
const img = await loadImage(`/image/${meta.id}/raw`);
|
||||
state.model = { id: meta.id, w: meta.width, h: meta.height, img };
|
||||
state.roi = null;
|
||||
state.polyPts = []; state.polyClosed = false; // B: scarta poligono stale
|
||||
document.getElementById("roi-info").textContent = "ROI: (nessuna)";
|
||||
setStatus(`Modello: ${filename} ${meta.width}x${meta.height} — trascina ROI`);
|
||||
renderModel();
|
||||
@@ -238,12 +276,36 @@ function renderModel() {
|
||||
state.model.scale = fit.sc;
|
||||
state.model.ox = fit.ox; state.model.oy = fit.oy;
|
||||
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;
|
||||
ctx.strokeStyle = "#00ff80"; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(fit.ox + x * fit.sc, fit.oy + y * 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) {
|
||||
ctx.strokeStyle = "#ffff00";
|
||||
ctx.setLineDash([4, 2]); ctx.lineWidth = 2;
|
||||
@@ -277,10 +339,35 @@ function setupROI() {
|
||||
const cnv = document.getElementById("c-model");
|
||||
cnv.addEventListener("mousedown", (e) => {
|
||||
if (!state.model) return;
|
||||
if (state.polyMode) return; // poly mode: gestito da click/dblclick
|
||||
const p = canvasPos(cnv, e);
|
||||
state.drag = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
|
||||
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) => {
|
||||
if (!state.drag) return;
|
||||
const p = canvasPos(cnv, e);
|
||||
@@ -307,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 ----------
|
||||
async function doMatchRecipe() {
|
||||
if (!state.scene) { setStatus("Carica scena"); return; }
|
||||
@@ -328,6 +450,12 @@ async function doMatchRecipe() {
|
||||
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
|
||||
const data = await r.json();
|
||||
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(
|
||||
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
||||
renderScene();
|
||||
@@ -347,7 +475,11 @@ async function doMatch() {
|
||||
}
|
||||
if (!state.model) { setStatus("Carica modello"); 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; }
|
||||
const roiPoly = getRoiPoly();
|
||||
const user = readUserParams();
|
||||
const adv = readAdvancedOverrides();
|
||||
setStatus("Match in corso...");
|
||||
@@ -373,6 +505,7 @@ async function doMatch() {
|
||||
const angMax = SYM_MAP[user.simmetria] ?? 360;
|
||||
body = {
|
||||
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
||||
roi_poly: roiPoly,
|
||||
angle_min: 0, angle_max: angMax,
|
||||
angle_step: PREC_MAP[user.precisione] ?? 5,
|
||||
scale_min: smin, scale_max: smax, scale_step: sstep,
|
||||
@@ -388,6 +521,7 @@ async function doMatch() {
|
||||
} else {
|
||||
body = {
|
||||
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
|
||||
roi_poly: roiPoly,
|
||||
...user,
|
||||
};
|
||||
}
|
||||
@@ -402,6 +536,12 @@ async function doMatch() {
|
||||
}
|
||||
const data = await r.json();
|
||||
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(
|
||||
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
|
||||
renderScene();
|
||||
@@ -437,6 +577,38 @@ function setStatus(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 ----------
|
||||
// ---------- Edge preview (clean rumore) ----------
|
||||
let _epDebounce = null;
|
||||
@@ -710,12 +882,17 @@ async function saveRecipe() {
|
||||
model_id: state.model.id,
|
||||
scene_id: state.scene?.id || state.model.id,
|
||||
roi: state.roi,
|
||||
roi_poly: getRoiPoly(),
|
||||
tipo: user.tipo,
|
||||
simmetria: user.simmetria,
|
||||
scala: user.scala,
|
||||
precisione: user.precisione,
|
||||
use_polarity: user.use_polarity,
|
||||
use_gpu: user.use_gpu,
|
||||
edge_weak_grad: user.edge_weak_grad,
|
||||
edge_strong_grad: user.edge_strong_grad,
|
||||
edge_num_features: user.edge_num_features,
|
||||
edge_min_feature_spacing: user.edge_min_feature_spacing,
|
||||
name: name,
|
||||
};
|
||||
try {
|
||||
@@ -749,6 +926,24 @@ window.addEventListener("DOMContentLoaded", async () => {
|
||||
upEl.addEventListener("change", async (e) => {
|
||||
const f = e.target.files[0];
|
||||
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...`);
|
||||
try {
|
||||
const res = await uploadToFolder(f);
|
||||
@@ -761,6 +956,18 @@ window.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
document.getElementById("btn-match").addEventListener("click", doMatch);
|
||||
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",
|
||||
saveRecipe);
|
||||
document.getElementById("btn-load-recipe").addEventListener("click",
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
|
||||
⚙ Auto-tune
|
||||
</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
|
||||
<input type="file" id="file-upload" accept="image/*" hidden>
|
||||
<input type="file" id="file-upload" accept="image/*,.dxf" hidden>
|
||||
</label>
|
||||
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
|
||||
</div>
|
||||
@@ -45,6 +45,15 @@
|
||||
<canvas id="c-model" width="380" height="420"></canvas>
|
||||
</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">
|
||||
<summary>🔬 Anteprima edge / pulizia rumore</summary>
|
||||
<div style="font-size:11px; color:#aaa; margin:4px 0">
|
||||
@@ -102,8 +111,8 @@
|
||||
<div class="field">
|
||||
<label>Simmetria</label>
|
||||
<select id="p-simmetria">
|
||||
<option value="nessuna" selected>Nessuna (0..360°)</option>
|
||||
<option value="invariante">Invariante (cerchi — no rotazione)</option>
|
||||
<option value="nessuna">Nessuna (0..360°)</option>
|
||||
<option value="bilaterale">Bilaterale (speculare 180°)</option>
|
||||
<option value="rot_3">Rotazionale 3× (120°)</option>
|
||||
<option value="rot_4">Rotazionale 4× (90°)</option>
|
||||
@@ -248,6 +257,11 @@
|
||||
<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>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">
|
||||
<summary>🔍 Diagnostica (CC)</summary>
|
||||
|
||||
@@ -10,12 +10,24 @@ dependencies = [
|
||||
"pillow>=12.2.0",
|
||||
"python-multipart>=0.0.26",
|
||||
"uvicorn[standard]>=0.34",
|
||||
"ezdxf>=1.3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pm2d-eval = "pm2d.eval:main"
|
||||
pm2d-bench = "pm2d.bench:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F"]
|
||||
# E702 (a; b) ed E402 (import dopo codice) sono idiomi voluti del codebase
|
||||
ignore = ["E501", "E741", "E702", "E731", "E402"]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Fixture condivise: template e scene sintetiche con ground-truth nota.
|
||||
|
||||
Tutti i test sono sintetici (nessuna dipendenza dalle immagini Test/,
|
||||
non versionate): generano scene con pose note e verificano recall e
|
||||
precisione del matcher. Runtime totale atteso: ~2-4 min su 2 core.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def make_template(tw: int = 160, th: int = 120) -> np.ndarray:
|
||||
"""Forma a L asimmetrica con foro circolare, contrasto netto.
|
||||
|
||||
Asimmetrica per evitare ambiguita' rotazionali nei confronti GT.
|
||||
"""
|
||||
img = np.full((th, tw), 60, np.uint8)
|
||||
cv2.rectangle(img, (20, 20), (60, th - 20), 200, -1)
|
||||
cv2.rectangle(img, (20, th - 55), (tw - 25, th - 20), 200, -1)
|
||||
cv2.circle(img, (tw - 45, 40), 16, 200, -1)
|
||||
return cv2.GaussianBlur(img, (3, 3), 0)
|
||||
|
||||
|
||||
# Pose ground-truth: (cx, cy, angle_deg) - angoli volutamente lontani
|
||||
# dalla griglia di step 5/2 gradi per misurare il refine.
|
||||
GT_POSES: list[tuple[float, float, float]] = [
|
||||
(150.0, 150.0, 0.0),
|
||||
(450.0, 140.0, 7.3),
|
||||
(740.0, 170.0, 33.7),
|
||||
(160.0, 420.0, 91.2),
|
||||
(460.0, 430.0, 158.4),
|
||||
(750.0, 480.0, 246.9),
|
||||
(300.0, 590.0, 312.6),
|
||||
]
|
||||
|
||||
|
||||
def make_scene(
|
||||
template: np.ndarray,
|
||||
poses: list[tuple[float, float, float]],
|
||||
W: int = 900, H: int = 700,
|
||||
noise: float = 4.0, seed: int = 7,
|
||||
) -> np.ndarray:
|
||||
"""Incolla il template warpato alle pose date su sfondo rumoroso.
|
||||
|
||||
Convenzione di rotazione identica al matcher (cv2.getRotationMatrix2D
|
||||
attorno al centro template, poi traslazione del centro su (cx, cy)).
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
scene = np.full((H, W), 60, np.float32)
|
||||
th, tw = template.shape
|
||||
for (cx, cy, ang) in poses:
|
||||
M = cv2.getRotationMatrix2D((tw / 2.0, th / 2.0), ang, 1.0)
|
||||
M[0, 2] += cx - tw / 2.0
|
||||
M[1, 2] += cy - th / 2.0
|
||||
warped = cv2.warpAffine(template.astype(np.float32), M, (W, H),
|
||||
flags=cv2.INTER_LINEAR, borderValue=-1)
|
||||
scene = np.where(warped >= 0, warped, scene)
|
||||
scene += rng.normal(0, noise, scene.shape)
|
||||
return np.clip(scene, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def ang_diff(a: float, b: float) -> float:
|
||||
"""Differenza angolare firmata in (-180, 180]."""
|
||||
d = (a - b) % 360.0
|
||||
return d - 360.0 if d > 180.0 else d
|
||||
|
||||
|
||||
def match_errors(matches, poses, radius: float = 20.0):
|
||||
"""Associa match a pose GT per distanza; ritorna (err_ang, err_pos, n_miss)."""
|
||||
errs_a: list[float] = []
|
||||
errs_p: list[float] = []
|
||||
miss = 0
|
||||
for (cx, cy, ang) in poses:
|
||||
cands = [
|
||||
(math.hypot(m.cx - cx, m.cy - cy), m)
|
||||
for m in matches
|
||||
if math.hypot(m.cx - cx, m.cy - cy) < radius
|
||||
]
|
||||
if not cands:
|
||||
miss += 1
|
||||
continue
|
||||
d, m = min(cands, key=lambda t: t[0])
|
||||
errs_a.append(abs(ang_diff(m.angle_deg, ang)))
|
||||
errs_p.append(d)
|
||||
return errs_a, errs_p, miss
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def template() -> np.ndarray:
|
||||
return make_template()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def scene(template) -> np.ndarray:
|
||||
return make_scene(template, GT_POSES)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit test rapidi su componenti del matcher (no matching pesante)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
import pytest
|
||||
|
||||
from pm2d import LineShapeMatcher
|
||||
from tests.conftest import GT_POSES, make_scene, match_errors
|
||||
|
||||
|
||||
def test_angle_list_includes_range_end():
|
||||
# Range parziale ±15: l'estremo +15 deve essere testato (era escluso).
|
||||
m = LineShapeMatcher(angle_range_deg=(-15.0, 15.0), angle_step_deg=5.0)
|
||||
angles = m._angle_list()
|
||||
assert -15.0 in angles and 15.0 in angles
|
||||
assert len(angles) == 7
|
||||
|
||||
|
||||
def test_angle_list_full_circle_no_duplicate():
|
||||
# (0, 360): 360 coincide con 0 → escluso, niente variante duplicata.
|
||||
m = LineShapeMatcher(angle_range_deg=(0.0, 360.0), angle_step_deg=5.0)
|
||||
angles = m._angle_list()
|
||||
assert len(angles) == 72
|
||||
assert 360.0 not in angles
|
||||
|
||||
|
||||
def test_pyramid_clamp_small_template():
|
||||
# Template 40px di lato minimo: al top /4 le feature collassano →
|
||||
# i livelli vengono clampati (40/2=20 >= 12, 40/4=10 < 12 → 2 livelli).
|
||||
m = LineShapeMatcher(pyramid_levels=4, angle_range_deg=(0.0, 10.0),
|
||||
angle_step_deg=5.0)
|
||||
tpl = np.full((40, 200), 60, np.uint8)
|
||||
cv2.rectangle(tpl, (30, 8), (170, 32), 200, -1)
|
||||
m.train(tpl)
|
||||
assert m.pyramid_levels == 2
|
||||
|
||||
|
||||
def test_save_load_roundtrip(tmp_path, template, scene):
|
||||
m = LineShapeMatcher(angle_step_deg=10.0)
|
||||
m.train(template)
|
||||
path = str(tmp_path / "model.npz")
|
||||
m.save_model(path)
|
||||
m2 = LineShapeMatcher.load_model(path)
|
||||
assert len(m2.variants) == len(m.variants)
|
||||
matches = m2.find(scene, min_score=0.5, max_matches=10)
|
||||
_, _, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0
|
||||
|
||||
|
||||
def test_scene_cache_no_collision(template):
|
||||
# Due scene IDENTICHE nella banda superiore ma diverse sotto: la cache
|
||||
# (che prima hashava solo i primi 64KB) non deve restituire i risultati
|
||||
# della scena sbagliata.
|
||||
poses_a = [GT_POSES[0], (450.0, 560.0, 33.7)]
|
||||
poses_b = [GT_POSES[0], (700.0, 560.0, 91.2)]
|
||||
scene_a = make_scene(template, poses_a)
|
||||
scene_b = make_scene(template, poses_b)
|
||||
# Stessa banda superiore (le pose extra sono in basso, y >= 430)
|
||||
assert np.array_equal(scene_a[:80], scene_b[:80])
|
||||
m = LineShapeMatcher(angle_step_deg=10.0)
|
||||
m.train(template)
|
||||
ma = m.find(scene_a, min_score=0.5, max_matches=5)
|
||||
mb = m.find(scene_b, min_score=0.5, max_matches=5)
|
||||
_, _, miss_a = match_errors(ma, poses_a)
|
||||
_, _, miss_b = match_errors(mb, poses_b)
|
||||
assert miss_a == 0 and miss_b == 0
|
||||
|
||||
|
||||
def test_train_mask_polygonal(template, scene):
|
||||
# ROI poligonale: mask che copre solo la L verticale del template.
|
||||
mask = np.zeros_like(template)
|
||||
cv2.rectangle(mask, (10, 10), (70, template.shape[0] - 10), 255, -1)
|
||||
m = LineShapeMatcher(angle_step_deg=10.0)
|
||||
n = m.train(template, mask=mask)
|
||||
assert n > 0
|
||||
matches = m.find(scene, min_score=0.5, max_matches=10)
|
||||
assert len(matches) >= 1
|
||||
|
||||
|
||||
def test_untrained_find_raises():
|
||||
m = LineShapeMatcher()
|
||||
with pytest.raises(RuntimeError):
|
||||
m.find(np.zeros((100, 100), np.uint8))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Test di non-regressione su precisione e recall (GT sintetica).
|
||||
|
||||
Soglie derivate dalle misure di Fase 2 (errore mediano ~0.05 deg /
|
||||
~0.08 px) con margine 3-4x per assorbire rumore tra run/macchine.
|
||||
Una regressione del refine (es. score saturo, minMaxLoc sul plateau)
|
||||
riporterebbe gli errori a 2-4 deg / 4 px e fa fallire i test con
|
||||
margine enorme.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pm2d import LineShapeMatcher
|
||||
from tests.conftest import GT_POSES, match_errors
|
||||
|
||||
|
||||
def _find(template, scene, step, **kw):
|
||||
m = LineShapeMatcher(angle_step_deg=step, num_features=96)
|
||||
m.train(template)
|
||||
return m.find(scene, min_score=0.5, max_matches=10, **kw)
|
||||
|
||||
|
||||
def test_recall_and_precision_step5(template, scene):
|
||||
matches = _find(template, scene, 5.0)
|
||||
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0, f"{miss} pose GT non trovate"
|
||||
assert float(np.median(errs_a)) < 0.2, f"err angolo mediano {np.median(errs_a):.3f} deg"
|
||||
assert float(np.max(errs_a)) < 0.5, f"err angolo max {np.max(errs_a):.3f} deg"
|
||||
assert float(np.median(errs_p)) < 0.3, f"err posizione mediano {np.median(errs_p):.3f} px"
|
||||
assert float(np.max(errs_p)) < 1.0, f"err posizione max {np.max(errs_p):.3f} px"
|
||||
|
||||
|
||||
def test_recall_and_precision_step2(template, scene):
|
||||
# Step fine: storicamente il caso peggiore (plateau con piu' varianti
|
||||
# dentro la tolleranza spread → scelta variante arbitraria).
|
||||
matches = _find(template, scene, 2.0)
|
||||
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0, f"{miss} pose GT non trovate"
|
||||
assert float(np.median(errs_a)) < 0.2
|
||||
assert float(np.max(errs_a)) < 0.5
|
||||
assert float(np.median(errs_p)) < 0.3
|
||||
|
||||
|
||||
def test_no_false_positives(template, scene):
|
||||
# max_matches alto: non devono comparire match spuri oltre le 7 pose.
|
||||
matches = _find(template, scene, 5.0)
|
||||
assert len(matches) <= len(GT_POSES) + 1, (
|
||||
f"{len(matches)} match per {len(GT_POSES)} oggetti reali"
|
||||
)
|
||||
|
||||
|
||||
def test_full_scan_path_equivalent(template, scene):
|
||||
# Il path full-scan (propagate off) deve trovare le stesse pose.
|
||||
matches = _find(template, scene, 5.0, pyramid_propagate=False)
|
||||
_, _, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0
|
||||
@@ -62,6 +62,29 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ezdxf"
|
||||
version = "1.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fonttools" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pyparsing" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.1"
|
||||
@@ -78,6 +101,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
version = "4.63.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -146,6 +202,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llvmlite"
|
||||
version = "0.47.0"
|
||||
@@ -258,6 +323,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
@@ -316,6 +390,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
@@ -387,6 +470,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
@@ -441,11 +558,37 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shape-model-2d"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "ezdxf" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "numba" },
|
||||
{ name = "numpy" },
|
||||
@@ -458,10 +601,13 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "ezdxf", specifier = ">=1.3" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "numba", specifier = ">=0.65.0" },
|
||||
{ name = "numpy", specifier = ">=1.24" },
|
||||
@@ -472,7 +618,11 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "httpx", specifier = ">=0.28.1" }]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pytest", specifier = ">=8.0" },
|
||||
{ name = "ruff", specifier = ">=0.8" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
|
||||