From 0420c4a863133f0431b3096da251d24cc869fcb3 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 12 Jun 2026 12:30:46 +0000 Subject: [PATCH 1/3] test: suite pytest sintetica (GT pose note) + deps dev pytest/ruff 11 test senza dipendenza dalle immagini Test/ (non versionate): - precisione/recall su 7 pose GT (soglie 0.2-0.5 deg, 0.3-1.0 px, margine 3-4x sulle misure Fase 2) - unit: angle_list con estremi, clamp piramide, save/load roundtrip, no collisione cache scena, mask poligonale, find non addestrato Config ruff in pyproject (E702/E402 idiomi del codebase esclusi). Co-Authored-By: Claude Fable 5 --- pyproject.toml | 11 +++ tests/__init__.py | 0 tests/conftest.py | 99 ++++++++++++++++++++++++ tests/test_matcher_unit.py | 84 ++++++++++++++++++++ tests/test_precision.py | 56 ++++++++++++++ uv.lock | 152 ++++++++++++++++++++++++++++++++++++- 6 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_matcher_unit.py create mode 100644 tests/test_precision.py diff --git a/pyproject.toml b/pyproject.toml index 3cd7625..19b1412 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "pillow>=12.2.0", "python-multipart>=0.0.26", "uvicorn[standard]>=0.34", + "ezdxf>=1.3", ] [project.scripts] @@ -19,4 +20,14 @@ 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"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c3b51c9 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_matcher_unit.py b/tests/test_matcher_unit.py new file mode 100644 index 0000000..11f0a5d --- /dev/null +++ b/tests/test_matcher_unit.py @@ -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)) diff --git a/tests/test_precision.py b/tests/test_precision.py new file mode 100644 index 0000000..e878081 --- /dev/null +++ b/tests/test_precision.py @@ -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 diff --git a/uv.lock b/uv.lock index ca7e5d3..233b524 100644 --- a/uv.lock +++ b/uv.lock @@ -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" From 91a6beb03282d0beea0b5776ca0436f32930c6ee Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 12 Jun 2026 12:30:46 +0000 Subject: [PATCH 2/3] perf: coarse step angolare auto al top-level (Halcon-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Al livello top le feature distano R/2^top dal centro: lo spread tollera una rotazione ~atan(spread/(max_side_top/2)), molto piu' ampia dello step full-res. Si valuta al top 1 variante ogni cf_auto (clamp 1..8), le intermedie vengono riprese dall'espansione ai vicini. Con step 2° e template 160px: top_eval 180 -> 30 varianti a parita' di recall (rimosso il forzato cf=1 per step <= 3 che valutava tutto). Inclusa pulizia lint: variabili/import inutilizzati. Co-Authored-By: Claude Fable 5 --- pm2d/gui.py | 1 - pm2d/line_matcher.py | 32 ++++++++++++++++++++------------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/pm2d/gui.py b/pm2d/gui.py index 2e32e57..d3a7d91 100644 --- a/pm2d/gui.py +++ b/pm2d/gui.py @@ -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 diff --git a/pm2d/line_matcher.py b/pm2d/line_matcher.py index 543e0c6..5ac9d85 100644 --- a/pm2d/line_matcher.py +++ b/pm2d/line_matcher.py @@ -38,7 +38,6 @@ _GOLDEN = (math.sqrt(5.0) - 1.0) / 2.0 # ≈ 0.618 from pm2d._jit_kernels import ( score_by_shift as _jit_score_by_shift, - score_bitmap as _jit_score_bitmap, score_bitmap_rescored as _jit_score_bitmap_rescored, score_bitmap_rescored_window as _jit_score_bitmap_rescored_window, score_bitmap_greedy as _jit_score_bitmap_greedy, @@ -326,8 +325,6 @@ class LineShapeMatcher: n_vars = len(self.variants) n_levels = len(self.variants[0].levels) var_meta = np.zeros((n_vars, 6), dtype=np.float32) # ang, scale, kh, kw, cxl, cyl - all_dx, all_dy, all_bin, all_offsets = [], [], [], [] - offset = 0 all_offsets_per_level = [[] for _ in range(n_levels)] all_dx_per_level = [[] for _ in range(n_levels)] all_dy_per_level = [[] for _ in range(n_levels)] @@ -1483,12 +1480,9 @@ class LineShapeMatcher: if nms_radius is None: nms_radius = max(8, min(self.template_size) // 2) # Pruning adattivo allo step angolare: con step piccolo (<= 3 deg) - # ci sono molte varianti vicine, gli score top-level sono ravvicinati - # e top_thresh*0.5 e' troppo aggressivo: scarta varianti valide che - # sarebbero state riprese al full-res. Stessa cosa per - # coarse_angle_factor (skip 1 ogni 2): con step fine non e' utile. - # Risultato osservato: precisione "veloce" 10° dava risultati - # migliori di "preciso" 2° proprio perche evitava il pruning. + # ci sono molte varianti vicine e gli score top-level sono + # ravvicinati: top_thresh*0.5 e' troppo aggressivo, scarta varianti + # valide che sarebbero state riprese al full-res. # Il path windowed (pyramid_propagate) assume che il picco # top-level localizzi la posizione entro il margine finestra. # Su template ALLUNGATI (es. lama 40x280, o ROI parziale lungo un @@ -1503,10 +1497,25 @@ class LineShapeMatcher: pyramid_propagate = False eff_step = self._effective_angle_step() top_factor = self.top_score_factor - cf_eff = max(1, coarse_angle_factor) if eff_step <= 3.0: top_factor = max(top_factor, 0.7) - cf_eff = 1 + # Coarse step angolare AUTO al top-level (Halcon-style): al livello + # top le feature distano R/2^top dal centro, quindi lo spread + # (raggio in px, costante per livello) tollera una rotazione + # ~atan(spread / (max_side_top/2)) — molto piu' ampia dello step + # richiesto a full-res. Si valuta al top 1 variante ogni cf_eff; + # le intermedie vengono riprese dall'espansione ai vicini. + # Es: template 160 px, 3 livelli, step 2° → tolleranza top ~11° + # → cf 6 → top-pruning ~6x piu' veloce a parita' di recall. + if self.template_size != (0, 0): + max_side_top = max(self.template_size) / (2 ** top) + else: + max_side_top = 64.0 + step_top_tol = math.degrees( + math.atan2(float(self.spread_radius), max(8.0, max_side_top / 2.0)) + ) + cf_auto = int(np.clip(round(step_top_tol / max(eff_step, 1e-6)), 1, 8)) + cf_eff = max(1, coarse_angle_factor, cf_auto) top_thresh = min_score * top_factor diag["top_thresh_used"] = float(top_thresh) @@ -1562,7 +1571,6 @@ class LineShapeMatcher: dtype=bool, ) if scene_bins.any(): - n_scene_active = int(scene_bins.sum()) # Soglia: variante deve avere >= 50% delle sue feature in bin # presenti nella scena. Sotto = score certamente < 0.5. pruned_idx_list = [] From e3114d6255f382e98daf8bb59c82e287c11c5bc2 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 12 Jun 2026 12:30:46 +0000 Subject: [PATCH 3/3] feat: input DXF + ROI poligonale + export JSON + CI Gitea - pm2d/dxf.py: rasterizzazione DXF -> template (ezdxf, flattening entita', scala/centratura, render edge antialiased) - POST /upload_dxf: carica CAD come modello (size 128..2048) - roi_poly su /match, /match_simple e POST /recipes: train con mask cv2.fillPoly (validazione 400 su poligoni degeneri), cache key inclusa - UI: upload .dxf, modalita' ROI poligonale su canvas (click=vertice, dblclick=chiudi, reset), bottone Esporta JSON dei risultati - .gitea/workflows/ci.yml: uv sync + ruff + pytest su push/PR Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci.yml | 31 +++++++ pm2d/dxf.py | 119 ++++++++++++++++++++++++ pm2d/web/server.py | 109 ++++++++++++++++++++-- pm2d/web/static/app.js | 181 ++++++++++++++++++++++++++++++++++++- pm2d/web/static/index.html | 18 +++- 5 files changed, 445 insertions(+), 13 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 pm2d/dxf.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2e2901a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/pm2d/dxf.py b/pm2d/dxf.py new file mode 100644 index 0000000..9e4f93a --- /dev/null +++ b/pm2d/dxf.py @@ -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 diff --git a/pm2d/web/server.py b/pm2d/web/server.py index 7a77872..df68a60 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -55,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 @@ -91,7 +92,10 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str: "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()) @@ -170,6 +174,39 @@ def _clamp_roi(x: int, y: int, w: int, h: int, 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. @@ -303,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 @@ -384,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 @@ -603,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) @@ -617,8 +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") - x, y, w, h = p.roi - x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) + # 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] tech_for_cache = { @@ -630,6 +699,9 @@ 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 @@ -646,7 +718,7 @@ 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: @@ -689,11 +761,20 @@ 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") - x, y, w, h = p.roi - x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) + # 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] 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 @@ -716,7 +797,7 @@ def match_simple(p: SimpleMatchParams): 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: @@ -778,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" @@ -897,8 +980,14 @@ def save_recipe(p: SaveRecipeParams): model = _load_image(p.model_id) if model is None: raise HTTPException(404, "Modello non trovato") - x, y, w, h = p.roi - x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) + # 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, @@ -925,7 +1014,7 @@ def save_recipe(p: SaveRecipeParams): ) # Lock globale: serializza il training pesante col matching in corso with _MATCHER_LOCK: - n_var = m.train(roi_img) + 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: diff --git a/pm2d/web/static/app.js b/pm2d/web/static/app.js index 540989e..20aee0f 100644 --- a/pm2d/web/static/app.js +++ b/pm2d/web/static/app.js @@ -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 ---------- @@ -148,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); @@ -222,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(); @@ -262,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; @@ -301,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); @@ -331,6 +394,41 @@ function setupROI() { }); } +// ---------- ROI poligonale ---------- +function closePoly() { + if (!state.polyMode || state.polyClosed) return; + if (state.polyPts.length < 3) { + setStatus("Servono almeno 3 vertici per chiudere il poligono"); + return; + } + state.polyClosed = true; + // ROI = bounding box del poligono (il server riceve anche roi_poly) + const xs = state.polyPts.map((p) => p[0]); + const ys = state.polyPts.map((p) => p[1]); + const x0 = Math.min(...xs), y0 = Math.min(...ys); + const w = Math.max(...xs) - x0, h = Math.max(...ys) - y0; + state.roi = [x0, y0, Math.max(1, w), Math.max(1, h)]; + document.getElementById("roi-info").textContent = + `Poligono: ${state.polyPts.length} vertici, bbox ${w}x${h} @ (${x0}, ${y0})`; + renderModel(); +} + +function resetPoly() { + state.polyPts = []; + state.polyClosed = false; + state.roi = null; + document.getElementById("roi-info").textContent = state.polyMode + ? "Poligono: clicca sul modello per aggiungere vertici" + : "ROI: (nessuna)"; + renderModel(); +} + +function getRoiPoly() { + // Poligono valido solo se in modalità poly e chiuso + return (state.polyMode && state.polyClosed && state.polyPts.length >= 3) + ? state.polyPts : null; +} + // ---------- Match action ---------- async function doMatchRecipe() { if (!state.scene) { setStatus("Carica scena"); return; } @@ -352,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(); @@ -371,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..."); @@ -397,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, @@ -412,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, }; } @@ -426,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(); @@ -461,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; @@ -734,6 +882,7 @@ 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, @@ -777,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); @@ -789,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", diff --git a/pm2d/web/static/index.html b/pm2d/web/static/index.html index 4d23bc6..db5f360 100644 --- a/pm2d/web/static/index.html +++ b/pm2d/web/static/index.html @@ -30,9 +30,9 @@ title="Analizza ROI e derivata parametri ottimali (Halcon-style)"> ⚙ Auto-tune -