diff --git a/.env.example b/.env.example index b01fa5b..c006966 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true VISION_RUN_TIMEOUT_S=100 # Quante esecuzioni di visione in parallelo accetta il worker. VISION_MAX_CONCURRENCY=2 +# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo). +VISION_RECIPES_ROOT=/app/uploads # --- Docker --- # DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default. diff --git a/CLAUDE.md b/CLAUDE.md index 0677f32..a399b6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,8 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120 Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`. +Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16. + Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata: ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 33ef3e1..440743a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,9 @@ services: VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-} container_name: tmflow-vision restart: unless-stopped + environment: + # #46: radice delle cartelle ricetta (volume uploads, sola lettura) + VISION_RECIPES_ROOT: /app/uploads volumes: - upload_data:/app/uploads:ro healthcheck: diff --git a/pyproject.toml b/pyproject.toml index 0cf2b38..646b3a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,8 @@ vision = [ "vs-task; python_version>='3.13'", "vs-measure; python_version>='3.13'", "vs-pm2d; python_version>='3.13'", + # #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf) + "vs-tools[locate,dxf]; python_version>='3.13'", "numpy; python_version>='3.13'", ] @@ -101,6 +103,8 @@ visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true } vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true } vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true } vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true } +vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46 +vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46 [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/vision/runner.py b/src/vision/runner.py index 5a6b29c..4c375c9 100644 --- a/src/vision/runner.py +++ b/src/vision/runner.py @@ -9,6 +9,7 @@ from __future__ import annotations import math import os import subprocess +import tempfile import time from dataclasses import dataclass, field from functools import lru_cache @@ -18,6 +19,9 @@ from typing import Any import numpy as np from visionsuite.core.image import Image from visionsuite.task import Task +from visionsuite.task.graph import RecipeError # #46: errori di ricetta v2 +from visionsuite.task.registry import load_tools +from visionsuite.task.session import Session _SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite" @@ -49,6 +53,10 @@ class RunOutcome: # Kept apart from `failures`: the tool did not raise, it just produced a # number the caller must not measure a part by. Value is the reason. invalid_outputs: dict[str, str] = field(default_factory=dict) + # #46: contorni di tutti gli strumenti (px immagine, con tool_id) e esito + # del TaskReport (informativo: il verdetto quote lo dà il backend). + overlay: list[dict] = field(default_factory=list) + outcome: str | None = None @lru_cache(maxsize=1) @@ -97,12 +105,46 @@ def engine_version() -> str: return _engine_version_from_git() +def _scale_calibration(calibration: dict | None): + """Dict {"mm_per_px"|"px_per_mm"} -> ScaleCalibration (gli strumenti di + quota convertono con `in_millimetres`, che non accetta un dict).""" + if calibration is None: + return None + from visionsuite.calibration import ScaleCalibration + + if not isinstance(calibration, dict) or not ( + calibration.get("mm_per_px") or calibration.get("px_per_mm") + ): + raise GraphBuildError( + "calibration must be {'mm_per_px': float} or {'px_per_mm': float}" + ) + key = "mm_per_px" if calibration.get("mm_per_px") else "px_per_mm" + return ScaleCalibration(**{key: float(calibration[key])}) + + +def _open_session(graph: dict, recipe_dir: Any, calibration: dict | None, + workdir: str) -> Session: + """Costruisce la Session v2; RecipeError e grafo malformato -> GraphBuildError.""" + cal = _scale_calibration(calibration) + try: + return Session.from_dict( + graph, Path(recipe_dir) if recipe_dir else Path(workdir), cal, + ) + except RecipeError as exc: + raise GraphBuildError(f"malformed graph: {exc}") from exc + except (KeyError, TypeError, AttributeError, ValueError) as exc: + raise GraphBuildError( + f"malformed graph: {type(exc).__name__}: {exc}" + ) from exc + + def run_graph( image: Any, graph: dict, calibration: dict | None = None, + recipe_dir: Any = None, ) -> RunOutcome: - """Run a serialised vs-task graph against one image. + """Run a serialised vs-task v2 graph against one image. A tool that fails does not stop the others - on a crooked piece an edge may simply not be there, and the other quotes are still wanted. Failures come @@ -112,33 +154,35 @@ def run_graph( wrapped into the `visionsuite.core.image.Image` that vs-measure's tools actually read via `.pixels`; an `Image` passed in is used as-is. - Two phases, on purpose: building the task from `graph` can fail on - nothing but the caller's input (a bad schema version, a tool with no - "kind", a graph that is not a dict at all) - that is `GraphBuildError`, - a 422 for the worker. Once the task exists, `task.run()` is the engine's - own business; an exception from there is not the caller's mistake, and - the worker must not call it one. + `recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models, + ...); without it an empty temporary folder is used, so a recipe that needs + a file fails as a malformed graph instead of reading from the CWD. + + Two phases, on purpose: building the session can fail on nothing but the + caller's input (a bad schema version, a tool with no "kind", a missing + file) - that is `GraphBuildError`, a 422 for the worker. Once it exists, + running is the engine's own business; an exception from there is not the + caller's mistake, and the worker must not call it one. """ - try: - task = Task.from_dict(graph) - except (KeyError, TypeError, AttributeError, ValueError) as exc: - raise GraphBuildError( - f"malformed graph: {type(exc).__name__}: {exc}" - ) from exc - - wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image)) - context: dict[str, Any] = {"image": wrapped_image} - if calibration is not None: - context["calibration"] = calibration - - started = time.monotonic() - report = task.run(context=context) - elapsed_ms = int((time.monotonic() - started) * 1000) + load_tools() # #46: registra gli strumenti degli entry point di vs-tools + with tempfile.TemporaryDirectory() as empty_dir: + session = _open_session(graph, recipe_dir, calibration, empty_dir) + wrapped_image = ( + image if isinstance(image, Image) else Image(pixels=np.asarray(image)) + ) + started = time.monotonic() + report = session.run(wrapped_image) + elapsed_ms = int((time.monotonic() - started) * 1000) outputs: dict[str, float] = {} failures: list[ToolFailure] = [] invalid_outputs: dict[str, str] = {} + overlay: list[dict] = [] for result in report: + # #46: overlay di ogni strumento, con il tool_id di chi lo ha prodotto + for item in result.overlay or []: + if isinstance(item, dict): + overlay.append({**item, "tool_id": result.tool_id}) if not result.ok: failures.append(ToolFailure( tool_id=result.tool_id, @@ -164,4 +208,55 @@ def run_graph( engine_version=engine_version(), duration_ms=elapsed_ms, invalid_outputs=invalid_outputs, + overlay=overlay, + outcome=report.outcome, ) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def validate_graph( + graph: dict, recipe_dir: Any = None +) -> tuple[bool, list[str], list[dict]]: + """Validate a v2 recipe without an image: (ok, errors, numeric outputs). + + Not a draft: a missing meta file is an error. `outputs` lists the numeric + outputs the tools declare (OUT properties with a numeric default, plus the + `values` of dimension tools), with quantity/unit when declared. + """ + load_tools() + if not isinstance(graph, dict): + return False, ["graph must be a JSON object"], [] + try: + task = Task.from_dict( + graph, recipe_dir=Path(recipe_dir) if recipe_dir else None + ) + except RecipeError as exc: + return False, list(exc.errors), [] + except (KeyError, TypeError, AttributeError, ValueError) as exc: + return False, [f"{type(exc).__name__}: {exc}"], [] + + outputs: list[dict] = [] + for tool in task.tools: + value_quantities = getattr(tool, "value_quantities", None) or {} + values = tuple(getattr(tool, "values", ()) or ()) + for prop in tool.outputs(): + if not (_is_number(prop.default) or prop.name in values): + continue + quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None)) + if not isinstance(quantity, str): + quantity = None + unit = None + if quantity == "length": + nominal_unit = getattr(tool, "nominal_unit", None) + unit = nominal_unit if isinstance(nominal_unit, str) else None + elif quantity == "angle": + unit = "deg" + outputs.append({ + "key": f"{tool.id}.{prop.name}", + "quantity": quantity, + "unit": unit, + }) + return True, [], outputs diff --git a/src/vision/tests/test_runner.py b/src/vision/tests/test_runner.py index 56bb778..2273ee1 100644 --- a/src/vision/tests/test_runner.py +++ b/src/vision/tests/test_runner.py @@ -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 == [] diff --git a/src/vision_worker/main.py b/src/vision_worker/main.py index d62aa6f..25311be 100644 --- a/src/vision_worker/main.py +++ b/src/vision_worker/main.py @@ -10,12 +10,19 @@ import asyncio import io import json import os +from pathlib import Path import numpy as np from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status from PIL import Image, UnidentifiedImageError -from src.vision.runner import GraphBuildError, RunOutcome, engine_version, run_graph +from src.vision.runner import ( + GraphBuildError, + RunOutcome, + engine_version, + run_graph, + validate_graph, +) app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0") @@ -31,6 +38,67 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2")) _run_slots = asyncio.Semaphore(_MAX_CONCURRENCY) +# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads, +# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice. +_RECIPES_ROOT_DEFAULT = "/app/uploads" + + +def _unprocessable(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail + ) + + +def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None: + """Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta. + + Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None. + """ + if recipe_dir is None or not recipe_dir.strip(): + return None + rel = recipe_dir.strip() + # Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo. + root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve() + if ( + Path(rel).is_absolute() + or rel.startswith(("/", "\\")) + or ".." in Path(rel.replace("\\", "/")).parts + ): + raise _unprocessable("recipe_dir must be a relative path under the recipes root") + resolved = (root / rel).resolve() + if resolved != root and root not in resolved.parents: + raise _unprocessable("recipe_dir is outside the recipes root") + return resolved + + +def _parse_graph(graph: str) -> dict: + try: + parsed = json.loads(graph) + except json.JSONDecodeError as exc: + raise _unprocessable(f"graph is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise _unprocessable( + f"graph must be a JSON object, got {type(parsed).__name__}" + ) + return parsed + + +def _parse_calibration(calibration: str | None) -> dict | None: + if calibration is None or not calibration.strip(): + return None + try: + parsed = json.loads(calibration) + except json.JSONDecodeError as exc: + raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict) or not ( + parsed.get("mm_per_px") or parsed.get("px_per_mm") + ): + raise _unprocessable( + "calibration must be {'mm_per_px': float} or {'px_per_mm': float}" + ) + return parsed + + class _ImageDecodeError(Exception): """The upload is not a decodable image - raised inside the worker thread so it survives the trip through `asyncio.to_thread`.""" @@ -44,7 +112,12 @@ async def health() -> dict: return {"status": "ok", "engine_version": engine_version()} -def _decode_and_run(content: bytes, graph: dict) -> RunOutcome: +def _decode_and_run( + content: bytes, + graph: dict, + calibration: dict | None = None, + recipe_dir: Path | None = None, +) -> RunOutcome: """The synchronous, CPU/IO-bound half of a request: PIL decode plus `run_graph`. Runs in a worker thread (see `run` below) so the event loop - and therefore `/health` - stays responsive while it executes. Once this @@ -56,27 +129,19 @@ def _decode_and_run(content: bytes, graph: dict) -> RunOutcome: frame = np.array(Image.open(io.BytesIO(content)).convert("L")) except (UnidentifiedImageError, OSError) as exc: raise _ImageDecodeError(str(exc)) from exc - return run_graph(frame, graph) + return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir) @app.post("/run") async def run( image: UploadFile = File(...), graph: str = Form(...), + recipe_dir: str | None = Form(None), + calibration: str | None = Form(None), ) -> dict: - try: - parsed_graph = json.loads(graph) - except json.JSONDecodeError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"graph is not valid JSON: {exc}", - ) from exc - - if not isinstance(parsed_graph, dict): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}", - ) + parsed_graph = _parse_graph(graph) + resolved_dir = _resolve_recipe_dir(recipe_dir) + parsed_calibration = _parse_calibration(calibration) content = await image.read() @@ -89,7 +154,13 @@ async def run( async with _run_slots: try: outcome = await asyncio.wait_for( - asyncio.to_thread(_decode_and_run, content, parsed_graph), + asyncio.to_thread( + _decode_and_run, + content, + parsed_graph, + parsed_calibration, + resolved_dir, + ), timeout=_RUN_TIMEOUT_S, ) except asyncio.TimeoutError as exc: @@ -130,4 +201,22 @@ async def run( "engine_version": outcome.engine_version, "duration_ms": outcome.duration_ms, "invalid_outputs": outcome.invalid_outputs, + # #46: contorni per l'overlay sull'immagine e esito VisionSuite. + "overlay": outcome.overlay, + "outcome": outcome.outcome, } + + +@app.post("/validate") +async def validate( + graph: str = Form(...), + recipe_dir: str | None = Form(None), +) -> dict: + """Validates a v2 recipe (not a draft: a missing meta file is an error) + and lists its numeric outputs. No image, so it is cheap: no run slot.""" + parsed_graph = _parse_graph(graph) + resolved_dir = _resolve_recipe_dir(recipe_dir) + ok, errors, outputs = await asyncio.to_thread( + validate_graph, parsed_graph, resolved_dir + ) + return {"ok": ok, "errors": errors, "outputs": outputs} diff --git a/src/vision_worker/tests/test_worker.py b/src/vision_worker/tests/test_worker.py index 3b812c2..fe7ccfe 100644 --- a/src/vision_worker/tests/test_worker.py +++ b/src/vision_worker/tests/test_worker.py @@ -45,13 +45,15 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes: def a_circle_graph() -> dict: 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() @@ -140,7 +142,7 @@ async def test_a_graph_that_is_not_an_object_is_refused(): @pytest.mark.asyncio async def test_a_tool_without_a_kind_is_refused_not_crashed(): - graph = {"schema_version": 1, "name": "p", "tools": [{"id": "a", "name": "x"}]} + graph = {"schema_version": 2, "name": "p", "tools": [{"id": "a", "name": "x"}]} transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://worker") as client: response = await client.post( @@ -151,7 +153,7 @@ async def test_a_tool_without_a_kind_is_refused_not_crashed(): assert response.status_code == 422 assert "malformed graph" in response.json()["detail"].lower() - assert "kind" in response.json()["detail"].lower() + assert "tipo sconosciuto" in response.json()["detail"].lower() # #46: messaggio v2 # --------------------------------------------------------------------------- @@ -167,7 +169,7 @@ async def test_a_run_past_its_budget_answers_504_and_health_still_answers( ): monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 0.1) - def slow_run_graph(frame, graph): + def slow_run_graph(frame, graph, **kwargs): time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread return runner.RunOutcome(engine_version="a" * 40) @@ -197,7 +199,7 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch): monkeypatch.setattr(worker_main, "_run_slots", asyncio.Semaphore(1)) monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0) - def slow_run_graph(frame, graph): + def slow_run_graph(frame, graph, **kwargs): time.sleep(0.3) return runner.RunOutcome(engine_version="a" * 40) @@ -222,3 +224,88 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch): assert second_response.status_code == 503 assert first_response.status_code == 200 + + +# --------------------------------------------------------------------------- +# #46: recipe_dir, calibration, overlay, /validate. +# --------------------------------------------------------------------------- + + +async def _post(path: str, **kwargs): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://worker") as client: + return await client.post(path, **kwargs) + + +@pytest.mark.asyncio +async def test_run_returns_overlay_and_outcome(): + response = await _post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps(a_circle_graph())}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["overlay"] and "tool_id" in body["overlay"][0] + assert body["outcome"] in ("pass", "warning", "fail") + + +@pytest.mark.asyncio +async def test_run_accepts_a_calibration(): + graph = a_circle_graph() + for tool in graph["tools"]: + if tool["kind"] == "Diameter": + tool["values"]["nominal_unit"] = "mm" + response = await _post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps(graph), + "calibration": json.dumps({"px_per_mm": 2.0})}, + ) + + body = response.json() + assert response.status_code == 200, body + assert body["failures"] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad", ["../etc", "a/../../b", "/abs/path", "C:/x/y"]) +async def test_a_recipe_dir_outside_the_root_is_refused(bad, monkeypatch, tmp_path): + monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path)) + response = await _post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad}, + ) + assert response.status_code == 422 + + response = await _post( + "/validate", data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad} + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_validate_ok_and_with_a_missing_file(monkeypatch, tmp_path): + monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path)) + response = await _post("/validate", data={"graph": json.dumps(a_circle_graph())}) + body = response.json() + assert response.status_code == 200 and body["ok"] is True + assert any(o["key"].endswith(".diameter") for o in body["outputs"]) + + (tmp_path / "r1").mkdir() + graph = { + "schema_version": 2, "name": "d", + "meta": {"dxf": {"d": {"path": "mancante.dxf"}}}, + "tools": [{"id": "t", "name": "c", "kind": "CompareDxf", + "values": {"dxf": "d", "mm_per_px": 0.25, + "band_plus": 8.0, "band_minus": 8.0}, + "links": {}}], + } + response = await _post( + "/validate", data={"graph": json.dumps(graph), "recipe_dir": "r1"} + ) + body = response.json() + assert response.status_code == 200 + assert body["ok"] is False and body["errors"] and body["outputs"] == [] diff --git a/uv.lock b/uv.lock index 2cad9e3..956e07a 100644 --- a/uv.lock +++ b/uv.lock @@ -626,6 +626,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, ] +[[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/8f/d0/bc515b7f5596bc3d55b93fc9f2c2b4926e2587af6bfae0486ef7cc7ddf43/ezdxf-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39aadab5bc7a05deddbd0db9b984c8a4bd28bd4c1ac2c5ee03f0d1b096272a88", size = 3553590, upload-time = "2026-05-14T09:25:28.042Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/207132f13a5280d186f2441d9125639fdc7ddeabf56c2d5da4d691e81ce6/ezdxf-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2ed65b191113a82133ba47daa6ebdd540b487b17394b1e327e9511dde87ad924", size = 3008276, upload-time = "2026-05-14T09:25:29.424Z" }, + { url = "https://files.pythonhosted.org/packages/82/73/b01af87269726e3719abc5ff694b843b710ef8fc345d21013e90d70e44d8/ezdxf-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c3c8f2c2b62fe5b6c9b62ab8257b697535d62b0c79acf3a5040bd95edfab451", size = 3001750, upload-time = "2026-05-14T09:25:31.146Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/284d7ca6a9da107966c7bd9d81c92245985235bc432fe0837982ef80630a/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d68d5ab9e1071319030da3af27590f34c38c8544a5ea638d9a4a800a96abb3c2", size = 5802382, upload-time = "2026-05-14T09:27:28.462Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/e4f37730ca5b5bf7de3a968a26126b404f60324d0b8ae9d59d11d6e55740/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f75a4f2924ebdda0f5b2779ff2135ba92de2596c95a8fa9b1d9ebcabea1be41", size = 5794671, upload-time = "2026-05-14T09:27:09.867Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3f/c3e7ab74f58cb69ef985fb71eda859b5c287cc506c65cbffe7e55c7cf2d4/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0eec8a2095b645420a67560e99041c0d25afefd39eab5bff8c736fdb32211a75", size = 5767366, upload-time = "2026-05-14T09:27:30.108Z" }, + { url = "https://files.pythonhosted.org/packages/66/7a/b7070bf05e75b3e709a6fe5ae5bcd3bd899e987ef84ff25cf28327519c03/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d87a85052e92b9c28e54605dbad65f779a3b61e6288e992d6ef00df7bde4bed3", size = 5830221, upload-time = "2026-05-14T09:27:12.074Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/a990289808c261c52f5b219484d136a8748773c99a6d25c84f55a0353fea/ezdxf-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:473d68c8f2b04ace513efa290a1cd0d3a4a589cdff48124f015f0e51034668b2", size = 2957599, upload-time = "2026-05-14T09:23:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/98648d221ffa047b5ba898c8e152010861dc71a66b531dcf15053075010e/ezdxf-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d723d35d08223318c17080a32a05192a1449e544dee867c9d9c6f25998fac303", size = 3563916, upload-time = "2026-05-14T09:25:32.879Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f1/84bbaa571412f840c22deeae185e54d4a67aa52f03e1728fd8425748c548/ezdxf-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b0c73b46085521bdbd4a3e7d88998da7bec5713ba89bbb502fdeffe6b42fc7e", size = 3016857, upload-time = "2026-05-14T09:25:34.607Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ff/f2eb77d22d26aa98abbe0961470871d8b85ef4e73e5527f544d25f958d6e/ezdxf-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac3e83a70c1f866dd5114192a6fab6e75cc83b75e907bc08c5d94b98cce14cc0", size = 3003336, upload-time = "2026-05-14T09:25:35.947Z" }, + { url = "https://files.pythonhosted.org/packages/65/7d/2a6ddd6beef5d9a8593078619f3920a0cd76b1db4c6a974ac37045543219/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ebe09717ab6636bf48780eabdab51e3ab2135f7963bb52452444b6d95e03d12", size = 5788485, upload-time = "2026-05-14T09:27:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44af248af0860fe8b1a39ca40e1cd2fa8ac37b22142b14b20c6a0caf1b50f932", size = 5816781, upload-time = "2026-05-14T09:27:13.96Z" }, + { url = "https://files.pythonhosted.org/packages/19/8d/669b8047cca162204c53b874183d73b5f6c4b1d5d48fa1e515a32d56f5ac/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02a097794625e9fc948e637dfe07057e98167b4fe215357a59645759fef6db3f", size = 5754006, upload-time = "2026-05-14T09:27:33.858Z" }, + { url = "https://files.pythonhosted.org/packages/5b/44/b753997173327250e43ae50f11f0c69016b1e78482e0bd3857c3495236e7/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f093bd2228f5782d483aed3bd89cdb74b672b5f90c677c4125cd236ee5649151", size = 5861860, upload-time = "2026-05-14T09:27:15.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/99/2e81cb168e0f09adfb05fb40e5f890ff87ac2c00e98079cf09837dd76330/ezdxf-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:3e518d9ed4dcbe525d57f14c25c5e592f82660a690fdec39b03b092e6cdc4771", size = 2963238, upload-time = "2026-05-14T09:23:47.924Z" }, + { 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" @@ -1079,8 +1118,8 @@ name = "numba" version = "0.67.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "llvmlite", marker = "python_full_version >= '3.13'" }, - { name = "numpy", marker = "python_full_version >= '3.13'" }, + { name = "llvmlite" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" } wheels = [ @@ -1185,7 +1224,7 @@ name = "opencv-python" version = "5.0.0.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.13'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" } wheels = [ @@ -1570,6 +1609,15 @@ 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.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pypdfium2" version = "5.8.0" @@ -1751,7 +1799,7 @@ name = "scipy" version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.13'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -1980,6 +2028,7 @@ vision = [ { name = "vs-measure", marker = "python_full_version >= '3.13'" }, { name = "vs-pm2d", marker = "python_full_version >= '3.13'" }, { name = "vs-task", marker = "python_full_version >= '3.13'" }, + { name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" }, ] vision-worker = [ { name = "fastapi", marker = "python_full_version >= '3.13'" }, @@ -1991,6 +2040,7 @@ vision-worker = [ { name = "vs-measure", marker = "python_full_version >= '3.13'" }, { name = "vs-pm2d", marker = "python_full_version >= '3.13'" }, { name = "vs-task", marker = "python_full_version >= '3.13'" }, + { name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" }, ] [package.metadata] @@ -2032,6 +2082,7 @@ requires-dist = [ { name = "vs-measure", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-measure" }, { name = "vs-pm2d", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-pm2d" }, { name = "vs-task", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-task" }, + { name = "vs-tools", extras = ["locate", "dxf"], marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-tools" }, { name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" }, ] provides-extras = ["server", "client", "dev", "vision", "vision-worker"] @@ -2157,8 +2208,14 @@ name = "visionsuite" version = "0.0.1" source = { editable = "vendor/visionsuite/packages/vs-core" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.13'" }, - { name = "opencv-python", marker = "python_full_version >= '3.13'" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, +] + +[package.optional-dependencies] +dxf = [ + { name = "ezdxf" }, ] [package.metadata] @@ -2168,42 +2225,70 @@ requires-dist = [ { name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" }, { name = "numpy", specifier = ">=1.24" }, { name = "opencv-python", specifier = ">=4.8" }, + { name = "pillow", specifier = ">=12.2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, ] provides-extras = ["dev", "fast", "dxf"] +[[package]] +name = "vs-dxf" +version = "0.1.0" +source = { editable = "vendor/visionsuite/packages/vs-dxf" } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pydantic" }, + { name = "scipy" }, + { name = "visionsuite", extra = ["dxf"] }, + { name = "vs-measure" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=1.26" }, + { name = "opencv-python", specifier = ">=4.9" }, + { name = "pydantic", specifier = ">=2.6" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, + { name = "scipy", specifier = ">=1.12" }, + { name = "visionsuite", extras = ["dxf"], editable = "vendor/visionsuite/packages/vs-core" }, + { name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" }, +] +provides-extras = ["dev"] + [[package]] name = "vs-measure" version = "0.1.0" source = { editable = "vendor/visionsuite/packages/vs-measure" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.13'" }, - { name = "scipy", marker = "python_full_version >= '3.13'" }, - { name = "visionsuite", marker = "python_full_version >= '3.13'" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "visionsuite" }, ] [package.metadata] requires-dist = [ + { name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" }, { name = "numpy", specifier = ">=1.24" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, { name = "scipy", specifier = ">=1.11" }, { name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "fast"] [[package]] name = "vs-pm2d" version = "0.1.0" source = { editable = "vendor/visionsuite/packages/vs-pm2d" } dependencies = [ - { name = "fastapi", marker = "python_full_version >= '3.13'" }, - { name = "numba", marker = "python_full_version >= '3.13'" }, - { name = "numpy", marker = "python_full_version >= '3.13'" }, - { name = "opencv-python", marker = "python_full_version >= '3.13'" }, - { name = "pillow", marker = "python_full_version >= '3.13'" }, - { name = "python-multipart", marker = "python_full_version >= '3.13'" }, - { name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" }, - { name = "visionsuite", marker = "python_full_version >= '3.13'" }, + { name = "fastapi" }, + { name = "numba" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "visionsuite" }, ] [package.metadata] @@ -2226,19 +2311,54 @@ name = "vs-task" version = "0.1.0" source = { editable = "vendor/visionsuite/packages/vs-task" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.13'" }, - { name = "visionsuite", marker = "python_full_version >= '3.13'" }, - { name = "vs-measure", marker = "python_full_version >= '3.13'" }, + { name = "numpy" }, + { name = "visionsuite" }, ] [package.metadata] requires-dist = [ + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.132" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "numpy", specifier = ">=1.24" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, + { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34" }, + { name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" }, + { name = "vs-stat", marker = "extra == 'stat'", editable = "vendor/visionsuite/packages/vs-stat" }, +] +provides-extras = ["dev", "stat", "server"] + +[[package]] +name = "vs-tools" +version = "0.1.0" +source = { editable = "vendor/visionsuite/packages/vs-tools" } +dependencies = [ + { name = "numpy" }, + { name = "visionsuite" }, + { name = "vs-measure" }, + { name = "vs-task" }, +] + +[package.optional-dependencies] +dxf = [ + { name = "vs-dxf" }, +] +locate = [ + { name = "vs-pm2d" }, +] + +[package.metadata] +requires-dist = [ + { name = "ezdxf", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "numpy", specifier = ">=1.24" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, { name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" }, + { name = "vs-dxf", marker = "extra == 'dxf'", editable = "vendor/visionsuite/packages/vs-dxf" }, { name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" }, + { name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" }, + { name = "vs-task", editable = "vendor/visionsuite/packages/vs-task" }, + { name = "vs-tools", extras = ["locate", "dxf"], marker = "extra == 'dev'", editable = "vendor/visionsuite/packages/vs-tools" }, ] -provides-extras = ["dev"] +provides-extras = ["locate", "dxf", "dev"] [[package]] name = "watchfiles" diff --git a/vendor/visionsuite b/vendor/visionsuite index f095fcc..bc0df16 160000 --- a/vendor/visionsuite +++ b/vendor/visionsuite @@ -1 +1 @@ -Subproject commit f095fcc254b73c6e543c9509aedacf6fe4b08f1b +Subproject commit bc0df1667ce54c5c24f8df5866d6c935f1553a4b