Motore v2: run_graph con recipe_dir/calibrazione/overlay, /validate, test (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+146
-13
@@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
||||
def a_circle_graph() -> dict:
|
||||
"""A two-tool graph, built with vs-task's own API."""
|
||||
from visionsuite.task import Task
|
||||
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
||||
from visionsuite.tools.detect import DetectCircle
|
||||
from visionsuite.tools.dimensions import Diameter
|
||||
|
||||
task = Task(name="prova")
|
||||
found = task.add(DetectCircleTool(name="disco"))
|
||||
found = task.add(DetectCircle(name="disco"))
|
||||
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||
found.scan_size = 30.0
|
||||
|
||||
quota = task.add(DiameterTool(name="diametro"))
|
||||
quota = task.add(Diameter(name="diametro"))
|
||||
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
|
||||
quota.link("circle", found, "circle")
|
||||
return task.to_dict()
|
||||
|
||||
@@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others():
|
||||
graph = a_circle_graph()
|
||||
graph["tools"].append(
|
||||
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
||||
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
|
||||
"values": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
|
||||
)
|
||||
|
||||
outcome = run_graph(a_disc(), graph)
|
||||
@@ -81,14 +83,21 @@ class _FakeResult:
|
||||
self.ok = ok
|
||||
self.outputs = outputs or {}
|
||||
self.error = error
|
||||
self.overlay = [] # #46: v2, ogni risultato porta l'overlay
|
||||
|
||||
|
||||
class _FakeReport(list):
|
||||
"""#46: run_graph ora legge `.outcome` dal report di Session.run."""
|
||||
|
||||
outcome = "pass"
|
||||
|
||||
|
||||
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
|
||||
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
|
||||
revisione) - a NaN must not become a quote's value (#16)."""
|
||||
def fake_run(self, context=None):
|
||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
|
||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
||||
def fake_run(self, image, tag=None):
|
||||
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
|
||||
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||
|
||||
outcome = run_graph(a_disc(), a_circle_graph())
|
||||
|
||||
@@ -97,9 +106,9 @@ def test_a_nan_output_is_dropped_not_measured(monkeypatch):
|
||||
|
||||
|
||||
def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
|
||||
def fake_run(self, context=None):
|
||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
|
||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
||||
def fake_run(self, image, tag=None):
|
||||
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
|
||||
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||
|
||||
outcome = run_graph(a_disc(), a_circle_graph())
|
||||
|
||||
@@ -109,9 +118,9 @@ def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
|
||||
|
||||
def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch):
|
||||
"""bool is a subclass of int - a flag output must not become a quote."""
|
||||
def fake_run(self, context=None):
|
||||
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
|
||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
||||
def fake_run(self, image, tag=None):
|
||||
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
|
||||
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||
|
||||
outcome = run_graph(a_disc(), a_circle_graph())
|
||||
|
||||
@@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
|
||||
engine_version()
|
||||
finally:
|
||||
runner._engine_version_from_git.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #46: ricette v2 complete - overlay, calibrazione, recipe_dir con DXF.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
_VS = Path(__file__).resolve().parents[3] / "vendor" / "visionsuite"
|
||||
_SAMPLE_DXF = _VS / "packages" / "vs-dxf" / "tests" / "fixtures" / "sample.dxf"
|
||||
# Il disegno di prova: rettangolo 200x100 mm con foro r=10 mm.
|
||||
_PX_PER_MM = 4.0
|
||||
|
||||
|
||||
def _dxf_scene() -> np.ndarray:
|
||||
"""Il pezzo del disegno rasterizzato a 4 px/mm, margine 60 mm."""
|
||||
margin = 60.0
|
||||
w = int((200.0 + 2 * margin) * _PX_PER_MM)
|
||||
h = int((100.0 + 2 * margin) * _PX_PER_MM)
|
||||
img = np.full((h, w), 255, dtype=np.uint8)
|
||||
cx, cy = w // 2, h // 2
|
||||
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
|
||||
cv2.rectangle(img, (int(cx - hw), int(cy - hh)), (int(cx + hw), int(cy + hh)), 0, -1)
|
||||
cv2.circle(img, (cx, cy), int(10.0 * _PX_PER_MM), 255, -1)
|
||||
return img
|
||||
|
||||
|
||||
def _dxf_recipe() -> dict:
|
||||
"""CompareDxf con ancora a due punti, collegati a due DetectPointScan.
|
||||
|
||||
Le due scansioni trovano i bordi sinistro e destro del pezzo una riga
|
||||
sopra il lato in basso: origine del disegno = angolo in basso a sinistra,
|
||||
direzione a -> b = asse x del disegno.
|
||||
"""
|
||||
h, w = _dxf_scene().shape
|
||||
cx, cy = w // 2, h // 2
|
||||
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
|
||||
row = cy + hh - 1.0
|
||||
|
||||
def scan(tid: str, col: float) -> dict:
|
||||
return {"id": tid, "name": tid, "kind": "DetectPointScan",
|
||||
"values": {"start_row": row, "start_col": col - 30.0,
|
||||
"end_row": row, "end_col": col + 30.0,
|
||||
"width": 5.0, "select": "first"},
|
||||
"links": {}}
|
||||
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"name": "dxf",
|
||||
"meta": {"dxf": {"disegno": { # sha256 obbligatorio in meta (v2)
|
||||
"path": "disegno.dxf",
|
||||
"sha256": hashlib.sha256(_SAMPLE_DXF.read_bytes()).hexdigest()}}},
|
||||
"tools": [
|
||||
scan("t-a", cx - hw),
|
||||
scan("t-b", cx + hw),
|
||||
{"id": "t-dxf", "name": "confronto", "kind": "CompareDxf",
|
||||
"values": {"dxf": "disegno", "mm_per_px": 1.0 / _PX_PER_MM,
|
||||
"anchor": "two_points",
|
||||
"band_plus": 8.0, "band_minus": 8.0},
|
||||
"links": {"point_a": {"tool": "t-a", "property": "point"},
|
||||
"point_b": {"tool": "t-b", "property": "point"}}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_a_circle_recipe_returns_overlay_and_outcome():
|
||||
outcome = run_graph(a_disc(), a_circle_graph())
|
||||
|
||||
assert outcome.outcome in ("pass", "warning", "fail")
|
||||
assert outcome.overlay, "DetectCircle must draw something"
|
||||
assert all("tool_id" in item for item in outcome.overlay)
|
||||
|
||||
|
||||
def test_calibration_dict_gives_mm_quotes():
|
||||
graph = a_circle_graph()
|
||||
for tool in graph["tools"]:
|
||||
if tool["kind"] == "Diameter":
|
||||
tool["values"]["nominal_unit"] = "mm"
|
||||
|
||||
outcome = run_graph(a_disc(), graph, calibration={"mm_per_px": 0.5})
|
||||
|
||||
assert outcome.failures == []
|
||||
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".value"))
|
||||
assert diameter == pytest.approx(60.0, abs=2.0) # 120 px * 0.5 mm/px
|
||||
|
||||
|
||||
def test_compare_dxf_two_points_with_recipe_dir(tmp_path):
|
||||
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
|
||||
|
||||
outcome = run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
|
||||
|
||||
assert outcome.failures == []
|
||||
assert outcome.outputs["t-dxf.n_points"] > 1000
|
||||
assert outcome.outputs["t-dxf.points_in_pct"] > 95.0
|
||||
roles = {item.get("role") for item in outcome.overlay}
|
||||
assert {"reference", "found"} <= roles
|
||||
assert "t-dxf" in {item["tool_id"] for item in outcome.overlay}
|
||||
|
||||
|
||||
def test_missing_meta_file_is_a_graph_build_error(tmp_path):
|
||||
with pytest.raises(runner.GraphBuildError):
|
||||
run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
|
||||
|
||||
|
||||
def test_validate_lists_numeric_outputs(tmp_path):
|
||||
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
|
||||
graph = _dxf_recipe()
|
||||
|
||||
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
|
||||
|
||||
assert ok, errors
|
||||
keys = {o["key"] for o in outputs}
|
||||
assert {"t-dxf.points_in_pct", "t-dxf.n_points"} <= keys
|
||||
assert all(set(o) == {"key", "quantity", "unit"} for o in outputs)
|
||||
|
||||
|
||||
def test_validate_reports_a_missing_file(tmp_path):
|
||||
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
|
||||
|
||||
assert not ok and errors and outputs == []
|
||||
|
||||
Reference in New Issue
Block a user