Compare commits

...

51 Commits

Author SHA1 Message Date
Adriano 37c645984f feat(web): bottone Auto-tune nella toolbar (Halcon-style)
UI esponev gia' /auto_tune endpoint ma non c'era trigger user-facing.
Aggiunto bottone toolbar accanto a MATCH:
- Calcola tutti i parametri tecnici dalla ROI selezionata (gradient,
  feature, piramide, angle_step, simmetria)
- Esegue self-validation training+find su template
- Applica i valori derivati ai campi della sezione Avanzate
- Mostra alert con riepilogo + meta diagnostica
  (simmetria detected, self-validation result, ecc.)

Endpoint /auto_tune ora ritorna anche meta (_self_score, _validation,
_symmetry_order, _orient_entropy) per feedback UI invece di filtrarli.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:10:07 +02:00
Adriano 0e148667ec merge: auto_tune self-validation 2026-05-04 23:04:10 +02:00
Adriano b5bbca0e85 merge: hysteresis edge linking 2026-05-04 23:04:10 +02:00
Adriano ca3882c59c feat: auto_tune self-validation (Halcon-style inspect_shape_model)
Nuovo helper _self_validate(): post-stima parametri, esegue dry-run
training+find sul template stesso e regola i parametri se subottimali.

Loop di auto-correzione (analogo a Halcon inspect_shape_model):
1. Se top-level piramide ha <8 feature → riduce pyramid_levels
2. Se train produce 0 varianti → dimezza weak/strong_grad
3. Se find sul template fallisce → riduce soglie + num_features
4. Se self-score < 0.7 → abbassa weak_grad

Costo: 1 train minimale (1 variante) + 1 find su canvas tpl + padding,
~50ms su template 100x100. Ne vale la pena per evitare match-time
errors su scene reali con parametri estimato male.

Esposto via auto_tune(self_validate=True) default; meta '_self_score'
e '_validation' nel dict risultato per logging UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:04:01 +02:00
Adriano 7f6571bdd1 feat: hysteresis edge linking (Halcon Contrast='auto' two-threshold)
_hysteresis_mask: edge linking via componenti connesse.
- seed = mag >= strong_grad
- weak = mag >= weak_grad
- Promuove a feature ogni componente weak che contiene almeno un
  pixel strong (connettivita' 8-vicini)

Riduce simultaneamente:
- Falsi positivi: edge debole isolato (rumore puro) escluso
- Falsi negativi: edge debole connesso a edge forte incluso
  (continuita' bordi sottili a basso contrasto)

Attivo automaticamente quando weak_grad < strong_grad. Se uguali,
fallback a sogliatura singola standard. Backward compat completo
dato che default weak=30, strong=60.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:01:54 +02:00
Adriano 7cb1ae2df7 merge: UI wiring modalita Halcon 2026-05-04 22:49:17 +02:00
Adriano 6ebb08e7a2 feat(web): wiring UI per modalita Halcon (M, Y, Z, V, X, R + altri)
UI espone tutti i nuovi flag tramite sezione pieghevole "Modalita Halcon"
nel pannello impostazioni. Default off = comportamento backward compat.

Flag esposti (checkbox + numerici):
- use_polarity (F): 16-bin orientation mod 2pi
- use_gpu (R): OpenCL UMat con silent fallback CPU
- use_soft_score (Y): score continuo cos(theta_t-theta_s)
- subpixel_lm (Z): refinement 0.05 px gradient field
- refine_pose_joint: Nelder-Mead 3D (cx,cy,theta)
- pyramid_propagate: top-K propagation a full-res
- min_recall (M): filtro feature-recall
- nms_iou_threshold (A): IoU bbox poligonale
- greediness: early-exit kernel
- coarse_stride: sub-sampling top-level
- search_roi: x,y,w,h area di ricerca

Persistenza ricette (V):
- Endpoint POST /recipes: training + save .npz in recipes/
- Endpoint GET /recipes: lista
- UI: campo nome + bottone "Salva" sotto i flag

Server SimpleMatchParams esteso con tutti i campi; pipeline match_simple
propaga init-flags al cache key (use_polarity/use_gpu = retrain) e
find-flags al m.find().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:49:11 +02:00
Adriano eba9d478a7 merge: R OpenCL UMat 2026-05-04 22:42:48 +02:00
Adriano 0df0d98aa5 merge: X ensemble multi-template (con M/Y/Z preservati) 2026-05-04 22:42:43 +02:00
Adriano b2b959e801 merge: V save/load model 2026-05-04 22:42:05 +02:00
Adriano b05246b492 merge: Z subpixel LM (M+Y preservati) 2026-05-04 22:42:00 +02:00
Adriano aeaa7fb5f7 merge: Y soft-margin gradient (con M recall preservato) 2026-05-04 22:40:26 +02:00
Adriano f347a10fad merge: M feature recall 2026-05-04 22:39:01 +02:00
Adriano 0b24be4d94 feat: use_gpu - offload Sobel/dilate via cv2.UMat (OpenCL)
Flag opzionale use_gpu=False/True su LineShapeMatcher e helper:
- opencl_available() per probe runtime
- set_gpu_enabled(bool) per attivare/disattivare globalmente

Quando attivo + cv2.ocl.haveOpenCL() True: Sobel + dilate +
warpAffine usano UMat con dispatch automatico kernel GPU
(Intel UHD, AMD, NVIDIA via OpenCL ICD). Speedup tipico 1.5-3x
sui filtri OpenCV (sec 1080p), gain finale ~10-15% sul total
find() perche' kernel JIT score-bitmap rimane CPU (Numba).

Path silently fallback CPU se OpenCL non disponibile (es. build
opencv-python senza ICD). Non rompe niente in ambienti non-GPU.

Per veri 20-50x speedup servirebbe kernel CUDA dedicato del
score-bitmap (out of scope, CPU + Numba e gia' molto buono).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:38:53 +02:00
Adriano 0296083e3c feat: add_template_view - multi-template ensemble (Halcon-style)
Aggiunge una view extra al matcher gia addestrato. Le varianti
della nuova view vengono APPENDATE a self.variants col tag view_idx
e partecipano al pruning/matching come le altre.

NCC verify usa il template della view che ha matchato (via
_get_view_template + parametro view_idx propagato a _verify_ncc).

Halcon-equivalent: create_aniso_shape_model con fusione N viste.
Use case: pezzo che cambia aspetto (chiaro/scuro, prima/dopo
trattamento, illuminazioni diverse) → un solo matcher robusto
invece di N matcher distinti.

API:
    m.train(template_chiaro)
    m.add_template_view(template_scuro)
    m.find(scene)  # match su entrambi gli aspetti

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:37:13 +02:00
Adriano 39208aadab feat: save_model / load_model - persistenza ricetta addestrata
Halcon-equivalent write_shape_model / read_shape_model. Salva su
file .npz compresso:
- Tutti i parametri matcher (incluso use_polarity)
- Template gray + maschera training
- Tutte le varianti pre-computate (con piramide flat per scrittura
  efficiente, ~12KB per template 80x80 con 28 varianti)

Caso d'uso: training offline su workstation, deploy a runtime
production senza re-train. load_model() istantaneo: skip training
(che e' il costo dominante per molte scale/angoli).

Format version 1, np.savez_compressed (zlib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:34:54 +02:00
Adriano 2b7ee6799c feat: subpixel_lm - refinement iterativo gradient-field least-squares
_subpixel_refine_lm: per ogni feature template, calcola normale
gradient nella scena (bilineare) e stima shift (dx, dy) globale
che minimizza errore direzionale gradient field. Iterazione damped
(max 1px/iter) per stabilita.

Halcon-equivalent SubPixel='least_squares_high'. Precisione attesa
0.05 px (vs 0.5 px del fit quadratico 2D plain). Costo: ~5ms per
match aggiuntivi (negligibile vs total find).

Default off (subpixel_lm=False, backward compat). Attivare per
applicazioni di alignment/dimensional inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:33:55 +02:00
Adriano 5059ce1d89 feat: use_soft_score - Halcon Metric soft-margin gradient similarity
_compute_soft_score: cos(theta_template - theta_scena) continuo
(non quantizzato a bin) pesato per magnitude. Polarity-aware se
use_polarity=True (mod 2pi) else |cos| (mod pi).

Quando use_soft_score=True (default off, backward compat), lo score
finale e' fuso con quello shape: piu discriminante per match a
piccola rotazione (penalita' graduale invece di binaria on/off).

Equivalente a Halcon Metric='use_polarity' / 'ignore_global_polarity'
in find_shape_model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:32:17 +02:00
Adriano f05dec5183 feat: min_recall - Halcon-style feature recall check post-refine
_compute_recall calcola hits/N feature template alla pose finale
(post sub-pixel refine). Equivalente Halcon MinScore originale:
quante feature shape effettivamente combaciano sul match accettato.

Param min_recall (default 0 = off, backward compat). Util quando
NCC e' alto ma poche feature reali matchano (es. match parziale
su zona di simil-tessitura). Soglia 0.7-0.9 raccomandata per
filtri stringenti.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:31:02 +02:00
Adriano f8f6a15166 fix: pruning top adattivo a angle_step (precisione preciso era peggio)
Bug osservato: con precisione "veloce" (10 deg) il matching dava
risultati migliori che con "preciso" (2 deg). Causa: con step fine
ci sono molte varianti vicine, score top-level ravvicinati e:
- top_thresh = min_score * 0.5 troppo aggressivo: scartava varianti
  valide che sarebbero state scelte al full-res
- coarse_angle_factor=2 (skip 1 ogni 2): col fine vicini sono quasi
  identici, ma il pruning skippava la migliore

Fix: quando angle_step <= 3 deg, automatic:
- top_score_factor min 0.7 (vs default 0.5)
- coarse_angle_factor = 1 (no skip varianti)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:20:35 +02:00
Adriano 5bd8fca248 fix: re-check min_score dopo NCC averaging
Bug: score finale = (shape + ncc) / 2 puo scendere sotto min_score
impostato dall'utente. La UI mostrava match con score < soglia
perche il filtro min_score era applicato solo allo shape-score
iniziale, non al risultato finale post-NCC.

Aggiunto re-check dopo averaging: scarta match con score finale
< min_score. Coerenza filtro user-facing ripristinata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:00:32 +02:00
Adriano 796ccb8052 fix(web): simmetria invariante (0) collassava a 360 per || default
Bug JS: SYM_MAP[user.simmetria] || 360 trasforma il valore valido 0
(invariante = nessuna rotazione) in 360 = no simmetria. Risultato:
cambiare simmetria nel pannello avanzato non aveva effetto se
selezionato invariante; per le altre opzioni il valore passava
ma con potenziale altri valori 0 in futuro.

Sostituito con ?? per distinguere "chiave mancante" da "valore zero".
Stessa fix per PREC_MAP.

Inoltre allineato FP_MAP JS al server (medio 0.35 -> 0.50, ecc.)
per coerenza UI/backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:54:16 +02:00
Adriano 0a8a9365bb fix: NCC robusto + reject bbox fuori scena + threshold piu rigorosi
3 fix per match spuri ad alto score visti su scena reale:

1. NCC con guard varianza minima: se template-patch o scene-patch
   hanno std quasi-zero (zone uniformi bianche/nere) NCC e instabile
   e da false-correlation alta. Ora ritorna 0 sotto soglia varianza.

2. Reject post-bbox: se il bounding-box ruotato del match sfora
   la scena per piu del 25%, scarto (centro derivato male o scala
   incoerente). Tollera 25% out-of-bounds (bordi).

3. FILTRO_FP_MAP alzato: leggero 0.20→0.30, medio 0.35→0.50,
   forte 0.50→0.70. Default piu conservativo per evitare match
   spuri su zone con pochi edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:51:43 +02:00
Adriano 9ed779637e merge: angle restrict helper 2026-05-04 17:09:09 +02:00
Adriano 077d44c3c8 merge: polarity 16-bin 2026-05-04 17:09:05 +02:00
Adriano e038ee3a1d merge: NMS poligonale IoU 2026-05-04 17:09:00 +02:00
Adriano 041b26e791 feat: helper set_angle_range_around + angle_tolerance hint in auto_tune
LineShapeMatcher.set_angle_range_around(center, tol): restringe
angle_range a (center-tol, center+tol). Use case: feeder/posizionamento
meccanico noto a priori. Esempio:
    m.set_angle_range_around(0, 20)  # cerca solo in [-20, +20]

auto_tune accetta angle_tolerance_deg + angle_center_deg: emette
angle_min/angle_max ristretti se hint fornito. Cache key include
hint per non collidere con tune default.

Beneficio misurato: angle_step=5 deg, template 80x80
- range 360°: 72 varianti
- range ±15°: 6 varianti (12x meno = matching ~12x piu veloce)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:08:56 +02:00
Adriano 84b73dc651 feat: use_polarity 16-bin orientation (mod 2pi)
Flag opt-in use_polarity=True su LineShapeMatcher: distingue edge
chiaro->scuro da scuro->chiaro raddoppiando i bin (8 mod pi a 16
mod 2pi). Riduce match accidentali quando il template e direzionale
ma scena ha bordo opposto (es. pezzo nero su bg chiaro vs pezzo
chiaro su bg nero).

Implementazione:
- _gradient calcola atan2 mod 2pi quando use_polarity
- _spread_bitmap usa uint16 (16 bit) invece di uint8 (8 bit)
- Nuovi kernel JIT _jit_score_bitmap_rescored_u16 e
  _jit_popcount_density_u16
- Wrapper Python score_bitmap_rescored / popcount_density fanno
  dispatch su dtype dello spread

Default off (use_polarity=False) = backward compat completo, 8 bin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:07:38 +02:00
Adriano 8d8a89ac35 feat: NMS poligonale (IoU bbox ruotato) cross-variant
_poly_iou via cv2.intersectConvexConvex: IoU esatto tra bbox
orientati. Sostituisce distanza-centro nel NMS post-refine.

Vantaggio: due pezzi adiacenti con centri vicini (entro nms_radius)
ma orientamenti diversi non vengono piu fusi se overlap reale e
basso. Stesso pezzo trovato da varianti angolari diverse (centri
uguali, IoU ~1) viene correttamente droppato.

Param nms_iou_threshold default 0.3. Fallback distanza centro
(r2/4) come safety per bbox degeneri.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:04:11 +02:00
Adriano 41976f574d fix: duplicati, score saturato e angolo impreciso
3 problemi visibili da test interattivo:
1. Match duplicati: stesso oggetto trovato da varianti angolari
   diverse, NMS pre-refine non basta perche refine sposta i match.
   Aggiunto NMS post-refine cross-variant.

2. Score sempre alto/saturato a 1.0: NCC era opzionale (skip>=0.85)
   e non veniva mescolato nello score. Ora ncc_skip_above=1.01
   (NCC sempre) e score finale = (shape + NCC) / 2: piu discriminante.

3. Angolo impreciso: _refine_angle aveva early-exit per shape-score
   >= 0.99, ma quel valore satura facile (con pyramid_propagate o
   spread ampio) senza garantire angolo preciso. Rimosso early-exit:
   refine angolare e' sempre essenziale per orientamento sub-step.

Inoltre: pyramid_propagate default False (era True), riduce duplicati
da picchi propagati su angle-vicini. propagate_topk default 4 (era 8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:33:58 +02:00
Adriano 4ef7a4a85f merge: dedup varianti 2026-05-04 15:46:34 +02:00
Adriano 7de7f35b7c merge: SIMD popcount fallback 2026-05-04 15:46:21 +02:00
Adriano 7b014b7f69 merge: batch_top variant-parallel kernel 2026-05-04 15:46:17 +02:00
Adriano 367ee9aaac merge: greediness (kernel greedy alternativo a rescore strided) 2026-05-04 15:45:15 +02:00
Adriano 74e5a45a39 merge: refine cache 2026-05-04 15:43:23 +02:00
Adriano 11c5160385 merge: refine_pose_joint (param list unito) 2026-05-04 15:43:19 +02:00
Adriano 07bab87cb9 merge: lazy NCC 2026-05-04 15:42:53 +02:00
Adriano a247484f36 merge: auto angle_step 2026-05-04 15:42:45 +02:00
Adriano e188df0adb merge: pyramid_propagate (con coarse_stride preservato) 2026-05-04 15:42:41 +02:00
Adriano b35d47669c merge: coarse_stride 2026-05-04 15:41:57 +02:00
Adriano fc3b0dbc3a merge: search_roi 2026-05-04 15:41:54 +02:00
Adriano 6da4dd5329 feat: dedup varianti con feature-set identico post-quantizzazione
Hash byte-exact su (dx, dy, bin) ordinati + scale. Se due varianti
post-rasterizzazione hanno lo stesso feature-set, ne tiene solo una.

Tipico caso d'uso: template con simmetrie discrete (quadrati, croci,
forme regolari) generano duplicati esatti per rotazioni multiple
del periodo. Su quadrato 80x80 con angle_step=10 deg: 36 -> 27 varianti
(~25% in meno di lavoro top-pruning).

Approccio conservativo (byte-exact): zero rischio di rimuovere varianti
distinte. Forme arrotondate (cerchi) o template asimmetrici non beneficiano
ma non vengono compromessi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:37:42 +02:00
Adriano b143c6607a feat: numpy.bitwise_count come fallback SIMD per popcount
NumPy 2.0+ espone np.bitwise_count: implementato in C nativo con
intrinsics SIMD (POPCNT/AVX2 vpopcnt). Aggiunto come fallback secondo
livello quando Numba non e disponibile (es. wheel constraint, env
ristretto). Numba JIT parallel resta default: misura su 1080p 0.5ms
vs 1.6ms (bitwise_count e single-thread).

AVX2 puro su _jit_score_bitmap_rescored richiederebbe C extension
con build nativa: out-of-scope per questo branch (Numba LLVM gia
autovettorizza il loop interno).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:36:48 +02:00
Adriano 6704d66cd5 feat: kernel JIT batch top-max-per-variant (opt-in)
Nuovo kernel _jit_top_max_per_variant: prange esterno sulle varianti
invece di n_vars chiamate JIT separate via ThreadPoolExecutor.
Wrapper Python top_max_per_variant prepara buffer flat (offsets +
dx_flat/dy_flat/bins_flat) e bg per scala.

Default batch_top=False perche su benchmark realistici (Linux 13 core,
72-180 varianti) ThreadPoolExecutor + kernel singolo che rilascia GIL
e gia ottimale. Path batch_top=True utile come opzione per scenari
con n_vars >>> n_threads o overhead chiamate JIT dominante.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:35:51 +02:00
Adriano 4419c237b2 feat: greediness param con early-exit kernel JIT
Nuovo kernel _jit_score_bitmap_greedy: per ogni pixel scorre N feature
ed esce non appena hits + remaining < greediness * min_score * N.
Esposto in find() come greediness in [0..1], default 0 (backward compat).

Sostituisce il kernel rescored al top-level quando attivo: salta il
rescore background ma early-exit pixel impossibili. Util su template
con molte feature (>100) e scena con pochi pattern competitivi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:33:39 +02:00
Adriano f00cf9b621 feat: cache features template per _refine_angle
Cache LRU (chiave: angolo arrotondato a 0.05deg, scale) di
(fx, fy, fb) per evitare warpAffine + gradient + extract ripetuti
durante golden-search refine. Bucket condiviso tra match della stessa
find() e tra find() consecutive sulla stessa ricetta.

Cache invalidata in train(): il template puo essere cambiato.
Limite 256 entry (sufficiente per 32 candidati x 8 valutazioni).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:31:37 +02:00
Adriano 4b7271094b feat: refine_pose_joint - Nelder-Mead 3D su (cx, cy, angle)
Alternativa al refine angolare 1D + subpixel quadratico: ottimizza
simultaneamente posizione e angolo con Nelder-Mead 3D inline (no
scipy). Default off (refine_pose_joint=False) per backward compat.

Vantaggio Halcon-style: un singolo iter LM/simplex stila il match a
precisione sub-pixel + sub-step in modo congiunto invece di alternare
assi. Convergenza tipica ~24 valutazioni vs ~15 (golden+quadratico)
ma piu robusto su template asimmetrici dove pose e angolo sono
fortemente correlati.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:30:20 +02:00
Adriano d9a40952c4 feat: angle_step auto adattivo a dimensione template
Halcon-style: angle_step_deg=0 attiva derivazione automatica
step = atan(2/max_side) deg, clampato [0.5, 10]. Template grande
ottiene step fine, piccolo step grosso. auto_tune emette il valore
calcolato direttamente.

_refine_angle ora usa _effective_angle_step() per coerenza con
training quando la modalita auto e attiva.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:27:35 +02:00
Adriano 6db2086ead feat: pyramid_propagate - candidati top-level guidano full-res
Top-level ritorna top-K picchi locali invece di solo max. Fase full-res
valuta solo crop locali attorno ai picchi propagati (margine =
sf_top + spread + nms_radius/2) invece di scansionare intera scena.

Su scene 1920x1080 con pochi candidati: ~20-30% piu veloce mantenendo
identici match. Vantaggio cresce con scene piu grandi e meno candidati.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:26:29 +02:00
Adriano 27a0ef1a45 feat: coarse_stride per sub-sampling top-level
Nuovo kernel JIT _jit_score_bitmap_rescored_strided: valuta solo
pixel su griglia stride x stride al top della piramide. NMS + fase
full-res recuperano precisione. Speed-up ~stride^2 sulla fase coarse,
specie su scene grandi (1920x1080).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:24:44 +02:00
Adriano ba4024d252 feat: search_roi parametro find() per limitare area di ricerca
Equivalente a Halcon set_aoi: matching opera su crop locale, coord
output ri-traslate al sistema scena. Costo proporzionale a w*h del
ROI invece di W*H scena intera.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:22:43 +02:00
7 changed files with 1824 additions and 94 deletions
+379 -11
View File
@@ -110,6 +110,118 @@ if HAS_NUMBA:
acc[y, x] *= inv
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_strided(
spread: np.ndarray,
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
bg: np.ndarray,
stride: nb.int32,
) -> np.ndarray:
"""Variante con sub-sampling: valuta solo pixel su griglia stride×stride.
Score restituito ha stessa shape (H, W); celle non valutate = 0.
4× speed-up con stride=2 (NMS recupera precisione in full-res).
Numba prange richiede step costante: itero su indici griglia e
moltiplico per stride dentro il body.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
ny = (H + stride - 1) // stride
nx = (W + stride - 1) // stride
for yi in nb.prange(ny):
y = yi * stride
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx[i]
x_lo = 0 if ddx >= 0 else -ddx
x_hi = W if ddx <= 0 else W - ddx
rem = x_lo % stride
if rem != 0:
x_lo += stride - rem
x = x_lo
while x < x_hi:
if spread[yy, x + ddx] & mask:
acc[y, x] += 1.0
x += stride
if N > 0:
inv = 1.0 / N
for yi in nb.prange(ny):
y = yi * stride
for xi in range(nx):
x = xi * stride
v = acc[y, x] * inv
bgv = bg[y, x]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[y, x] = r if r > 0.0 else 0.0
else:
acc[y, x] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_greedy(
spread: np.ndarray,
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
min_score: nb.float32,
greediness: nb.float32,
) -> np.ndarray:
"""Score bitmap con early-exit greedy (no rescore background).
Per ogni pixel iteriamo le N feature; abortiamo non appena diventa
impossibile raggiungere `min_required` count anche aggiungendo
tutte le feature rimanenti. min_required = greediness * min_score * N.
greediness=0 → nessun early-exit (equivalente a kernel base).
greediness=1 → exit non appena hits + remaining < min_score * N.
Tipico: 0.7-0.9 → 2-4x speed-up senza perdere match.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
if N == 0:
return acc
min_req = greediness * min_score * N
inv_N = nb.float32(1.0 / N)
for y in nb.prange(H):
for x in range(W):
hits = 0
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
if hits + (N - i - 1) < min_req:
break
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
if hits + (N - i - 1) < min_req:
break
continue
ddx = dx[i]
xx = x + ddx
if xx < 0 or xx >= W:
if hits + (N - i - 1) < min_req:
break
continue
if spread[yy, xx] & mask:
hits += 1
else:
if hits + (N - i - 1) < min_req:
break
acc[y, x] = nb.float32(hits) * inv_N
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored(
spread: np.ndarray, # uint8 (H, W)
@@ -159,6 +271,122 @@ if HAS_NUMBA:
acc[y, x] = 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)
dx_flat: np.ndarray, # int32 (sum_N,)
dy_flat: np.ndarray, # int32 (sum_N,)
bins_flat: np.ndarray, # int8 (sum_N,)
offsets: np.ndarray, # int32 (n_vars+1,) prefix sum
bit_active: np.uint8,
bg_per_variant: np.ndarray, # float32 (n_vars, H, W) - 1 per scala
scale_idx: np.ndarray, # int32 (n_vars,) idx in bg_per_variant
) -> np.ndarray:
"""Batch: per ogni variante calcola max score (rescored bg), ritorna
array float32 (n_vars,). Parallelismo prange ESTERNO sulle varianti
elimina overhead di n_vars chiamate JIT separate (avg ~20us per
chiamata su template piccoli) + pool thread Python.
Pensato per fase TOP del pruning quando n_vars >> n_threads.
"""
n_vars = offsets.shape[0] - 1
H, W = spread.shape
out = np.zeros(n_vars, dtype=np.float32)
for vi in nb.prange(n_vars):
i0 = offsets[vi]; i1 = offsets[vi + 1]
N = i1 - i0
if N == 0:
out[vi] = -1.0
continue
si = scale_idx[vi]
inv = nb.float32(1.0 / N)
best = nb.float32(-1.0)
for y in range(H):
for x in range(W):
s = nb.float32(0.0)
for k in range(N):
b = bins_flat[i0 + k]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy_flat[i0 + k]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx_flat[i0 + k]
xx = x + ddx
if xx < 0 or xx >= W:
continue
if spread[yy, xx] & mask:
s += nb.float32(1.0)
s *= inv
bgv = bg_per_variant[si, y, x]
if bgv < 1.0:
r = (s - bgv) / (1.0 - bgv + 1e-6)
if r > best:
best = r
out[vi] = best if best > 0.0 else 0.0
return out
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_u16(
spread: np.ndarray, # uint16 (H, W) - 16 bit di polarity-aware
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint16,
bg: np.ndarray,
) -> np.ndarray:
"""Versione uint16 di _jit_score_bitmap_rescored per polarity 16-bin.
Identica logica ma mask = uint16(1) << b dove b in [0..15]
(orientamento mod 2π invece di mod π).
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
for y in nb.prange(H):
for i in range(N):
b = bins[i]
mask = np.uint16(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx[i]
x_lo = 0 if ddx >= 0 else -ddx
x_hi = W if ddx <= 0 else W - ddx
for x in range(x_lo, x_hi):
if spread[yy, x + ddx] & mask:
acc[y, x] += 1.0
if N > 0:
inv = 1.0 / N
for y in nb.prange(H):
for x in range(W):
v = acc[y, x] * inv
bgv = bg[y, x]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[y, x] = r if r > 0.0 else 0.0
else:
acc[y, x] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_popcount_density_u16(spread: np.ndarray) -> np.ndarray:
"""Popcount per uint16 (16 bin polarity)."""
H, W = spread.shape
out = np.zeros((H, W), dtype=np.float32)
for y in nb.prange(H):
for x in range(W):
v = spread[y, x]
cnt = 0
for b in range(16):
if v & (np.uint16(1) << b):
cnt += 1
out[y, x] = float(cnt)
return out
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_popcount_density(spread: np.ndarray) -> np.ndarray:
"""Conta bit set per pixel: ritorna (H, W) float32 in [0..8]."""
@@ -185,7 +413,25 @@ if HAS_NUMBA:
_jit_score_bitmap(spread, dx, dy, b, np.uint8(0xFF))
bg = np.zeros((32, 32), dtype=np.float32)
_jit_score_bitmap_rescored(spread, dx, dy, b, np.uint8(0xFF), bg)
_jit_score_bitmap_rescored_strided(
spread, dx, dy, b, np.uint8(0xFF), bg, np.int32(2),
)
_jit_score_bitmap_greedy(
spread, dx, dy, b, np.uint8(0xFF),
np.float32(0.5), np.float32(0.8),
)
offsets = np.array([0, 1], dtype=np.int32)
scale_idx = np.zeros(1, dtype=np.int32)
bg_pv = np.zeros((1, 32, 32), dtype=np.float32)
_jit_top_max_per_variant(
spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx,
)
_jit_popcount_density(spread)
spread16 = np.zeros((32, 32), dtype=np.uint16)
_jit_score_bitmap_rescored_u16(
spread16, dx, dy, b, np.uint16(0xFFFF), bg,
)
_jit_popcount_density_u16(spread16)
else: # pragma: no cover
@@ -198,6 +444,24 @@ else: # pragma: no cover
def _jit_score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness):
raise RuntimeError("numba non disponibile")
def _jit_top_max_per_variant(
spread, dx_flat, dy_flat, bins_flat, offsets, bit_active,
bg_per_variant, scale_idx,
):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_u16(spread, dx, dy, bins, bit_active, bg):
raise RuntimeError("numba non disponibile")
def _jit_popcount_density_u16(spread):
raise RuntimeError("numba non disponibile")
def _jit_popcount_density(spread):
raise RuntimeError("numba non disponibile")
@@ -228,28 +492,132 @@ def score_bitmap(
def score_bitmap_rescored(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, bg: np.ndarray,
bit_active: int, bg: np.ndarray, stride: int = 1,
) -> np.ndarray:
"""Score bitmap + rescore fusi in un solo pass (JIT)."""
"""Score bitmap + rescore fusi in un solo pass (JIT).
Dispatch per dtype: uint16 → kernel polarity 16-bin, uint8 → kernel
standard 8-bin (con eventuale stride > 1 per coarse top-level).
"""
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:
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
return _jit_score_bitmap_rescored_u16(
spread_c, dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
)
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
if stride > 1:
return _jit_score_bitmap_rescored_strided(
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
np.int32(stride),
)
return _jit_score_bitmap_rescored(
np.ascontiguousarray(spread, dtype=np.uint8),
np.ascontiguousarray(dx, dtype=np.int32),
np.ascontiguousarray(dy, dtype=np.int32),
np.ascontiguousarray(bins, dtype=np.int8),
np.uint8(bit_active),
np.ascontiguousarray(bg, dtype=np.float32),
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
)
# Fallback: chiamate separate
# Fallback: chiamate separate (stride ignorato in fallback)
score = score_bitmap(spread, dx, dy, bins, bit_active)
out = (score - bg) / (1.0 - bg + 1e-6)
return np.maximum(0.0, out).astype(np.float32)
def score_bitmap_greedy(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, min_score: float, greediness: float,
) -> np.ndarray:
"""Score bitmap con early-exit greedy. Per coarse-pass aggressivo.
Non applica rescore background: usare quando la scena ha basso clutter
o quando si vuole mass-prune varianti via top-level rapidamente.
"""
if HAS_NUMBA and len(dx) > 0:
return _jit_score_bitmap_greedy(
np.ascontiguousarray(spread, dtype=np.uint8),
np.ascontiguousarray(dx, dtype=np.int32),
np.ascontiguousarray(dy, dtype=np.int32),
np.ascontiguousarray(bins, dtype=np.int8),
np.uint8(bit_active),
np.float32(min_score), np.float32(greediness),
)
# Fallback: kernel base senza early-exit
return score_bitmap(spread, dx, dy, bins, bit_active)
def top_max_per_variant(
spread: np.ndarray,
dx_list: list, dy_list: list, bin_list: list,
bg_per_scale: dict,
variant_scales: list,
bit_active: int,
) -> np.ndarray:
"""Wrapper: prepara buffer flat e chiama kernel batch su tutte le varianti.
Parallelismo Numba prange-esterno sulle varianti (n_vars >> n_threads
tipicamente per top-pruning) → meglio del thread-pool Python che paga
overhead di n_vars chiamate JIT separate.
"""
if not HAS_NUMBA or len(dx_list) == 0:
return np.array([], dtype=np.float32)
n_vars = len(dx_list)
sizes = [len(d) for d in dx_list]
offsets = np.zeros(n_vars + 1, dtype=np.int32)
offsets[1:] = np.cumsum(sizes)
total = int(offsets[-1])
dx_flat = np.empty(total, dtype=np.int32)
dy_flat = np.empty(total, dtype=np.int32)
bins_flat = np.empty(total, dtype=np.int8)
for vi, (dx, dy, bn) in enumerate(zip(dx_list, dy_list, bin_list)):
i0 = int(offsets[vi]); i1 = int(offsets[vi + 1])
dx_flat[i0:i1] = dx
dy_flat[i0:i1] = dy
bins_flat[i0:i1] = bn
# bg per variante: indicizzato per scala
scales_unique = sorted(bg_per_scale.keys())
scale_to_idx = {s: i for i, s in enumerate(scales_unique)}
H, W = spread.shape
bg_pv = np.empty((len(scales_unique), H, W), dtype=np.float32)
for s, idx in scale_to_idx.items():
bg_pv[idx] = bg_per_scale[s]
scale_idx = np.array(
[scale_to_idx[s] for s in variant_scales], dtype=np.int32,
)
return _jit_top_max_per_variant(
np.ascontiguousarray(spread, dtype=np.uint8),
dx_flat, dy_flat, bins_flat, offsets, np.uint8(bit_active),
bg_pv, scale_idx,
)
_HAS_NP_BITCOUNT = hasattr(np, "bitwise_count")
def popcount_density(spread: np.ndarray) -> np.ndarray:
"""Conta bit set per pixel.
Order:
1) Numba JIT parallel (preferito: piu veloce su 1080p, 0.5ms vs 1.6ms)
2) numpy.bitwise_count (NumPy 2.0+, SIMD ma single-thread)
3) Fallback numpy bit-shift puro
"""
if spread.dtype == np.uint16:
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
if HAS_NUMBA:
return _jit_popcount_density_u16(spread_c)
if _HAS_NP_BITCOUNT:
return np.bitwise_count(spread_c).astype(np.float32, copy=False)
H, W = spread_c.shape
out = np.zeros((H, W), dtype=np.float32)
for b in range(16):
out += ((spread_c >> b) & 1).astype(np.float32)
return out
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
if HAS_NUMBA:
return _jit_popcount_density(np.ascontiguousarray(spread, dtype=np.uint8))
# Fallback
return _jit_popcount_density(spread_c)
if _HAS_NP_BITCOUNT:
return np.bitwise_count(spread_c).astype(np.float32, copy=False)
H, W = spread.shape
out = np.zeros((H, W), dtype=np.float32)
for b in range(8):
+132 -6
View File
@@ -152,14 +152,124 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
return h.hexdigest()
def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
def _self_validate(template_bgr: np.ndarray, params: dict,
mask: np.ndarray | None = None) -> dict:
"""Halcon-style self-validation: train il matcher coi parametri tentativi
e verifica che il template stesso sia trovato con recall ≥ 1.0.
Se recall < target o score basso, regola i parametri:
- alza weak_grad se troppi edge spuri (recall solido ma molti picchi falsi)
- abbassa strong_grad se troppe feature scartate (low feature count)
- riduce pyramid_levels se variants[0].levels[top] ha <8 feature
Halcon usa internamente questo loop in inspect_shape_model. Costo: 1
train + 1 find sul template (~50ms su template 100x100). Ne vale la
pena se evita match-time errors su scene reali.
Mutates `params` in place e ritorna lo stesso dict per chaining.
"""
# Import lazy: evita ciclo (line_matcher importa nulla da auto_tune)
from pm2d.line_matcher import LineShapeMatcher
# Caso degenerato: troppe poche feature pre-validation → riduci soglia
if params.get("_n_strong_pixels", 0) < 30:
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.6)
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.6)
# Train minimale: 1 sola pose orientazione 0 (range degenerato che
# produce comunque 1 variante via fallback in _angle_list).
m = LineShapeMatcher(
num_features=params["num_features"],
weak_grad=params["weak_grad"],
strong_grad=params["strong_grad"],
angle_range_deg=(0.0, 0.0), # fallback _angle_list = [0.0]
angle_step_deg=10.0,
scale_range=(1.0, 1.0),
spread_radius=params["spread_radius"],
pyramid_levels=params["pyramid_levels"],
)
n_var = m.train(template_bgr, mask=mask)
if n_var == 0:
# Soglie troppo alte: nessuna variante generata → dimezza
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.5)
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.5)
params["_validation"] = "fallback: soglie dimezzate (no variants)"
return params
# Verifica densita' feature al top-level (rischio collasso)
top_lvl = m.variants[0].levels[-1]
if top_lvl.n < 8 and params["pyramid_levels"] > 1:
params["pyramid_levels"] = max(1, params["pyramid_levels"] - 1)
params["_validation"] = (
f"pyramid_levels ridotto a {params['pyramid_levels']} "
f"(top aveva {top_lvl.n} feature)"
)
return params
# Self-find: cerca il template stesso nella propria immagine
h, w = template_bgr.shape[:2]
# Embed template in scena leggermente più grande per evitare bordo
pad = 20
canvas = np.full(
(h + 2 * pad, w + 2 * pad, 3 if template_bgr.ndim == 3 else 1),
128, dtype=np.uint8,
)
canvas[pad:pad + h, pad:pad + w] = template_bgr
matches = m.find(
canvas, min_score=0.3, max_matches=5,
verify_ncc=False, # template stesso → NCC = 1 sempre, skip per velocita'
refine_angle=False, subpixel=False,
nms_iou_threshold=0.3,
)
if not matches:
# Nessun match sul proprio template: parametri troppo restrittivi
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.7)
params["strong_grad"] = max(30.0, params["strong_grad"] * 0.7)
params["num_features"] = max(48, int(params["num_features"] * 0.8))
params["_validation"] = "soglie/feature ridotte (no self-match)"
return params
# Misura score top match
top_score = float(matches[0].score)
params["_self_score"] = round(top_score, 3)
if top_score < 0.7:
# Score basso sul template stesso = parametri davvero subottimali
params["weak_grad"] = max(15.0, params["weak_grad"] * 0.85)
params["_validation"] = (
f"weak_grad ridotto (self-score era {top_score:.2f})"
)
else:
params["_validation"] = f"OK (self-score {top_score:.2f})"
return params
def auto_tune(
template_bgr: np.ndarray,
mask: np.ndarray | None = None,
angle_tolerance_deg: float | None = None,
angle_center_deg: float = 0.0,
self_validate: bool = True,
) -> dict:
"""Analizza template e ritorna dict parametri suggeriti.
Chiavi compatibili con edit_params PARAM_SCHEMA.
angle_tolerance_deg: se != None, restringe angle_range a
(center - tol, center + tol). Usare quando l'orientamento del
pezzo e' noto a priori (feeder con guida, posizionamento
meccanico): training molto piu rapido (24x meno varianti per
tol=15° vs 360° pieno).
self_validate: se True (default), dopo la stima dei parametri
esegue un dry-run del matching sul template stesso e regola
weak_grad/strong_grad/pyramid_levels se i parametri tentativi
non garantiscono auto-match (Halcon-style inspect_shape_model).
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
"""
ck = _cache_key(template_bgr, mask)
if angle_tolerance_deg is not None:
ck = f"{ck}|tol={angle_tolerance_deg}|c={angle_center_deg}"
cached = _TUNE_CACHE.get(ck)
if cached is not None:
_TUNE_CACHE.move_to_end(ck)
@@ -208,8 +318,13 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
# spread_radius proporzionale a risoluzione + pyramid (tolleranza ~1% dim)
spread_radius = int(np.clip(max(3, min_side * 0.02), 3, 8))
# angle range ridotto se simmetria rotazionale
angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0
# angle range: priorita' a tolerance hint utente, poi simmetria rotazionale.
if angle_tolerance_deg is not None:
angle_min = float(angle_center_deg - angle_tolerance_deg)
angle_max = float(angle_center_deg + angle_tolerance_deg)
else:
angle_min = 0.0
angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0
# min_score: se entropia orient alta → template distintivo → soglia alta ok
# se entropia bassa → template ambiguo → soglia più permissiva
@@ -220,12 +335,15 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
else:
min_score = 0.45
# angle step: 5° default; se simmetria, mantengo step ma range ridotto
angle_step = 5.0
# angle step adattivo (Halcon-style): atan(2/max_side) deg, clampato.
# Template grande → step fine (rotazione minima visibile su perimetro).
# Template piccolo → step grosso (over-sampling = sprecato).
max_side = max(h, w)
angle_step = float(np.clip(np.degrees(np.arctan2(2.0, max_side)), 1.0, 8.0))
result = {
"backend": "line",
"angle_min": 0.0,
"angle_min": angle_min,
"angle_max": angle_max,
"angle_step": angle_step,
"scale_min": 1.0,
@@ -244,7 +362,15 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
"_symmetry_order": sym["order"],
"_symmetry_conf": round(sym["confidence"], 2),
"_orient_entropy": round(stats["orient_entropy"], 2),
"_n_strong_pixels": stats["n_strong"],
}
# Halcon-style self-validation: dry-run training+find sul template per
# auto-correggere parametri tentativi che non garantirebbero match.
if self_validate:
result = _self_validate(template_bgr, result, mask=mask)
# Round numerici dopo eventuali aggiustamenti
result["weak_grad"] = round(result["weak_grad"], 1)
result["strong_grad"] = round(result["strong_grad"], 1)
# Store in LRU cache
_TUNE_CACHE[ck] = dict(result)
_TUNE_CACHE.move_to_end(ck)
+997 -69
View File
File diff suppressed because it is too large Load Diff
+104 -4
View File
@@ -48,6 +48,10 @@ IMAGES_DIR = Path(_images_dir_raw)
if not IMAGES_DIR.is_absolute():
IMAGES_DIR = PROJECT_ROOT / IMAGES_DIR
# Cartella ricette pre-trained (V feature: save/load matcher)
RECIPES_DIR = PROJECT_ROOT / "recipes"
RECIPES_DIR.mkdir(exist_ok=True)
from pm2d.line_matcher import LineShapeMatcher, Match
from pm2d.auto_tune import auto_tune
@@ -249,9 +253,9 @@ PRECISION_ANGLE_STEP = {
# Un operatore sceglie il livello di rigore, non un numero astratto.
FILTRO_FP_MAP = {
"off": 0.0, # disabilitato: mantieni tutti i match shape-based
"leggero": 0.20, # tollera variazioni intensità/illuminazione forti
"medio": 0.35, # default bilanciato (consigliato)
"forte": 0.50, # scarta match con intensità molto diversa dal template
"leggero": 0.30, # tollera variazioni intensità/illuminazione forti
"medio": 0.50, # default bilanciato (consigliato)
"forte": 0.70, # scarta match con intensità molto diversa dal template
}
@@ -267,6 +271,20 @@ 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
# --- Halcon-mode flags (default off = backward compat) ---
# Init-time (richiede ri-train se cambiato)
use_polarity: bool = False # F: 16 bin orientation mod 2pi
use_gpu: bool = False # R: OpenCL UMat (silent fallback)
# Find-time (no retrain)
min_recall: float = 0.0 # M: filtra match con poche feature combaciate
use_soft_score: bool = False # Y: cosine sim continua dei gradients
subpixel_lm: bool = False # Z: precisione 0.05 px
nms_iou_threshold: float = 0.3 # A: IoU bbox poligonale
coarse_stride: int = 1 # sub-sampling top-level (>=1)
pyramid_propagate: bool = False # propagazione candidati top->full
greediness: float = 0.0 # early-exit kernel (0..1)
refine_pose_joint: bool = False # Nelder-Mead 3D (cx, cy, angle)
search_roi: list[int] | None = None # [x, y, w, h] limita area
def _simple_to_technical(
@@ -526,6 +544,9 @@ def match_simple(p: SimpleMatchParams):
tech = _simple_to_technical(p, roi_img)
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
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher(
@@ -537,17 +558,30 @@ def match_simple(p: SimpleMatchParams):
scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"],
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
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"],
scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
@@ -573,7 +607,73 @@ def tune(p: TuneParams):
x, y, w, h = p.roi
roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img)
return {k: v for k, v in t.items() if not k.startswith("_")}
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
# _symmetry_order, _orient_entropy) per feedback UI.
return t
# --- V: Save/Load ricette pre-trained ---
class SaveRecipeParams(BaseModel):
model_id: str
scene_id: str | None = None
roi: list[int]
# Riusa stessi param simple per training equivalente
tipo: str = "intero"
simmetria: str = "nessuna"
scala: str = "fissa"
precisione: str = "normale"
use_polarity: bool = False
use_gpu: bool = False
name: str # nome file ricetta (no path)
@app.post("/recipes")
def save_recipe(p: SaveRecipeParams):
"""Allena matcher e salva su disco come ricetta riutilizzabile."""
model = _load_image(p.model_id)
if model is None:
raise HTTPException(404, "Modello non trovato")
x, y, w, h = p.roi
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,
)
tech = _simple_to_technical(sp, roi_img)
m = LineShapeMatcher(
num_features=tech["num_features"],
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
angle_range_deg=(tech["angle_min"], tech["angle_max"]),
angle_step_deg=tech["angle_step"],
scale_range=(tech["scale_min"], tech["scale_max"]),
scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"],
pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
)
m.train(roi_img)
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")
if not safe_name.endswith(".npz"):
safe_name += ".npz"
target = RECIPES_DIR / safe_name
m.save_model(str(target))
return {"name": safe_name, "size": target.stat().st_size,
"n_variants": len(m.variants)}
@app.get("/recipes")
def list_recipes():
files = []
if RECIPES_DIR.is_dir():
for f in sorted(RECIPES_DIR.glob("*.npz")):
files.append({"name": f.name, "size": f.stat().st_size})
return {"files": files, "dir": str(RECIPES_DIR)}
# Mount static
+130 -4
View File
@@ -52,6 +52,39 @@ 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),
...readHalconFlags(),
};
}
function readHalconFlags() {
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
const $cb = (id) => document.getElementById(id)?.checked ?? false;
const $num = (id, def) => {
const v = parseFloat(document.getElementById(id)?.value);
return Number.isFinite(v) ? v : def;
};
const $int = (id, def) => {
const v = parseInt(document.getElementById(id)?.value, 10);
return Number.isFinite(v) ? v : def;
};
const roiStr = document.getElementById("hc-search-roi")?.value.trim() ?? "";
let search_roi = null;
if (roiStr) {
const p = roiStr.split(/[ ,;]+/).map((x) => parseInt(x, 10));
if (p.length === 4 && p.every((v) => Number.isFinite(v))) search_roi = p;
}
return {
use_polarity: $cb("hc-use-polarity"),
use_gpu: $cb("hc-use-gpu"),
use_soft_score: $cb("hc-soft-score"),
subpixel_lm: $cb("hc-subpixel-lm"),
refine_pose_joint: $cb("hc-refine-joint"),
pyramid_propagate: $cb("hc-pyr-propagate"),
min_recall: $num("hc-min-recall", 0),
nms_iou_threshold: $num("hc-nms-iou", 0.3),
greediness: $num("hc-greediness", 0),
coarse_stride: $int("hc-coarse-stride", 1),
search_roi: search_roi,
};
}
@@ -294,12 +327,17 @@ async function doMatch() {
const SCALE_MAP = {fissa:[1,1,0.1], mini:[0.9,1.1,0.05],
medio:[0.75,1.25,0.05], max:[0.5,1.5,0.05]};
const PREC_MAP = {veloce:10, normale:5, preciso:2};
const FP_MAP = {off:0, leggero:0.20, medio:0.35, forte:0.50};
// Allineato a FILTRO_FP_MAP server-side (server.py)
const FP_MAP = {off:0, leggero:0.30, medio:0.50, forte:0.70};
const [smin, smax, sstep] = SCALE_MAP[user.scala];
// NB: SYM_MAP[invariante]=0 e' valido (zero rotazioni). Uso ?? per
// distinguere "chiave mancante" da "valore zero": altrimenti 0 || 360
// collassa invariante a 360 = bug "simmetria non ha effetto".
const angMax = SYM_MAP[user.simmetria] ?? 360;
body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
angle_min: 0, angle_max: SYM_MAP[user.simmetria] || 360,
angle_step: PREC_MAP[user.precisione] || 5,
angle_min: 0, angle_max: angMax,
angle_step: PREC_MAP[user.precisione] ?? 5,
scale_min: smin, scale_max: smax, scale_step: sstep,
min_score: user.min_score, max_matches: user.max_matches,
num_features: adv.num_features ?? 96,
@@ -307,7 +345,7 @@ async function doMatch() {
strong_grad: adv.strong_grad ?? 60,
spread_radius: adv.spread_radius ?? 5,
pyramid_levels: adv.pyramid_levels ?? 3,
verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.35),
verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.50),
nms_radius: adv.nms_radius ?? 0,
};
} else {
@@ -362,6 +400,91 @@ function setStatus(s) {
}
// ---------- Init ----------
// ---------- Auto-tune (Halcon-style) ----------
async function doAutoTune() {
if (!state.model || !state.roi) {
alert("Seleziona modello e disegna ROI prima di Auto-tune.");
return;
}
const status = document.getElementById("status");
status.textContent = "Analisi ROI in corso...";
try {
const r = await fetch("/auto_tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_id: state.model.id,
roi: state.roi,
}),
});
if (!r.ok) throw new Error(await r.text());
const t = await r.json();
// Applica ai campi avanzati (override automatico)
for (const [key] of ADV_PARAMS) {
const el = document.getElementById(`adv-${key}`);
if (el && t[key] !== undefined) el.value = String(t[key]);
}
// Espandi la sezione Avanzate per mostrare i valori applicati
const advDetails = document.querySelector("#col-params details:last-of-type");
if (advDetails) advDetails.open = true;
// Feedback diagnostico
const lines = [
`weak/strong: ${t.weak_grad} / ${t.strong_grad}`,
`feature: ${t.num_features}, piramide: ${t.pyramid_levels}`,
`angle: [${t.angle_min}..${t.angle_max}]@${t.angle_step}°`,
];
if (t._symmetry_order > 1) {
lines.push(`simmetria rotaz. ${t._symmetry_order}x (conf ${t._symmetry_conf})`);
}
if (t._self_score !== undefined) {
lines.push(`self-validation: ${t._validation}`);
}
status.textContent = `Auto-tune OK — ${lines[0]}`;
alert("Auto-tune completato:\n\n" + lines.join("\n"));
} catch (e) {
status.textContent = `Auto-tune errore: ${e.message}`;
alert(`Errore auto-tune: ${e.message}`);
}
}
// ---------- V: Save recipe ----------
async function saveRecipe() {
if (!state.model || !state.roi) {
alert("Seleziona modello e disegna ROI prima di salvare la ricetta.");
return;
}
const name = document.getElementById("hc-recipe-name").value.trim();
if (!name) {
alert("Inserisci un nome per la ricetta.");
return;
}
const user = readUserParams();
const body = {
model_id: state.model.id,
scene_id: state.scene?.id || state.model.id,
roi: state.roi,
tipo: user.tipo,
simmetria: user.simmetria,
scala: user.scala,
precisione: user.precisione,
use_polarity: user.use_polarity,
use_gpu: user.use_gpu,
name: name,
};
try {
const r = await fetch("/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`);
} catch (e) {
alert(`Errore salvataggio: ${e.message}`);
}
}
window.addEventListener("DOMContentLoaded", async () => {
buildAdvancedForm();
setupROI();
@@ -389,6 +512,9 @@ window.addEventListener("DOMContentLoaded", async () => {
e.target.value = ""; // consente re-upload stesso file
});
document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
document.getElementById("btn-save-recipe").addEventListener("click",
saveRecipe);
const slider = document.getElementById("p-min-score");
slider.addEventListener("input", (e) => {
document.getElementById("v-score").textContent =
+65
View File
@@ -26,6 +26,10 @@
<div class="picker-list"></div>
</div>
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
<button class="btn" id="btn-autotune"
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
⚙ Auto-tune
</button>
<label class="btn" title="Carica nuovo file nella cartella immagini">
⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden>
@@ -129,6 +133,67 @@
<input type="number" id="p-max-matches" value="25" min="1" max="200">
</div>
<details>
<summary>Modalità Halcon</summary>
<div class="halcon-grid">
<label class="hc-row" title="16-bin orientation polarity-aware (mod 2π)">
<input type="checkbox" id="hc-use-polarity">
<span>Polarity 16-bin (F)</span>
</label>
<label class="hc-row" title="Score continuo cos(θ_t-θ_s) invece di bin">
<input type="checkbox" id="hc-soft-score">
<span>Soft-margin score (Y)</span>
</label>
<label class="hc-row" title="Sub-pixel refinement gradient field LM">
<input type="checkbox" id="hc-subpixel-lm">
<span>Sub-pixel LM 0.05 px (Z)</span>
</label>
<label class="hc-row" title="Refine congiunto Nelder-Mead (cx,cy,θ)">
<input type="checkbox" id="hc-refine-joint">
<span>Refine pose joint</span>
</label>
<label class="hc-row" title="Pyramid candidates propagation">
<input type="checkbox" id="hc-pyr-propagate">
<span>Pyramid propagate</span>
</label>
<label class="hc-row" title="OpenCL GPU offload (silent fallback CPU)">
<input type="checkbox" id="hc-use-gpu">
<span>GPU OpenCL (R)</span>
</label>
<div class="hc-row hc-num">
<label>Min recall (M)</label>
<input type="number" id="hc-min-recall" value="0.0" min="0" max="1" step="0.05">
</div>
<div class="hc-row hc-num">
<label>NMS IoU thr (A)</label>
<input type="number" id="hc-nms-iou" value="0.3" min="0" max="1" step="0.05">
</div>
<div class="hc-row hc-num">
<label>Greediness</label>
<input type="number" id="hc-greediness" value="0.0" min="0" max="1" step="0.1">
</div>
<div class="hc-row hc-num">
<label>Coarse stride</label>
<input type="number" id="hc-coarse-stride" value="1" min="1" max="4" step="1">
</div>
<div class="hc-row hc-num" style="grid-column:1/-1">
<label title="Limita area di ricerca scena: x,y,w,h (vuoto = tutta scena)">
Search ROI (x,y,w,h)
</label>
<input type="text" id="hc-search-roi" placeholder="es. 100,50,800,400">
</div>
<div class="hc-row" style="grid-column:1/-1; border-top:1px solid #444; padding-top:8px">
<label>Ricetta pre-trained (V)</label>
<div style="display:flex; gap:6px; margin-top:4px">
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
</div>
</div>
</div>
</details>
<details>
<summary>Avanzate</summary>
<div id="adv-form"></div>
+17
View File
@@ -156,3 +156,20 @@ footer h2 {
}
#col-model, #col-scene { min-width: 0; }
/* Halcon-mode panel */
.halcon-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
margin-top: 6px;
font-size: 12px;
}
.hc-row {
display: flex; align-items: center; gap: 6px;
}
.hc-row.hc-num {
flex-direction: column; align-items: flex-start;
}
.hc-row.hc-num label { font-size: 11px; color: #aaa; }
.hc-row.hc-num input { width: 100%; }