767bede43b
Sei task TDD: grafo sul task, runner condiviso, worker separato, esecuzione con salvataggio misure, prova del confine server/VisionSuite, immagini di riferimento. Primo dei quattro piani. Si collauda interamente via API, senza camera. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
1754 lines
64 KiB
Markdown
1754 lines
64 KiB
Markdown
# Fondamenta della visione lato server — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Un task `camera_measure` porta con sé un grafo di visione, viene eseguito sul server contro un'immagine fornita, e produce `measurements` indistinguibili da quelle prese col calibro.
|
|
|
|
**Architecture:** VisionSuite entra come sottomodulo git. Un *runner* condiviso traduce grafo + immagine in uscite numeriche; un *worker* separato lo espone dietro una API interna, così il container del server FastAPI non importa mai VisionSuite e resta leggero. Il server smista, applica il verdetto con la logica che già esiste, e salva.
|
|
|
|
**Tech Stack:** Python 3.11, uv, FastAPI, SQLAlchemy 2.0 async, Alembic, pytest + pytest-asyncio, VisionSuite (`vs-core`, `vs-task`, `vs-measure`, `vs-pm2d`).
|
|
|
|
**Spec:** [`docs/superpowers/specs/2026-08-16-visionsuite-integration-design.md`](../specs/2026-08-16-visionsuite-integration-design.md)
|
|
|
|
**Fuori da questo piano:** l'agente di stazione e i device dal vivo (piano 2), l'esecuzione sulla stazione (piano 3), il DXF compare (piano 4), e l'interfaccia di authoring nel Maker (piano 1b). Qui si arriva a un sistema completo e collaudabile **via API**: l'immagine si fornisce caricandola.
|
|
|
|
## Global Constraints
|
|
|
|
- Python **3.11**; dipendenze con **uv**, mai `pip install` diretto e mai `requirements.txt`.
|
|
- **Il server non importa mai VisionSuite.** Il Task 5 aggiunge la prova che lo verifica; se una modifica la fa fallire, la modifica è sbagliata, non la prova.
|
|
- Migrazioni Alembic in **batch mode** (`op.batch_alter_table`) per la compatibilità SQLite dei test, e verificate in resa MySQL con `uv run alembic -c src/backend/migrations/alembic.ini upgrade X:Y --sql` prima del deploy.
|
|
- Il flag `-c src/backend/migrations/alembic.ini` è **obbligatorio** in ogni comando alembic.
|
|
- I test girano su SQLite in memoria (`sqlite+aiosqlite://`, `StaticPool`): nessun MySQL richiesto.
|
|
- Nomi di variabili, funzioni e commenti in **inglese**; le stringhe di interfaccia in IT/EN via i18n.
|
|
- Type hints ovunque, `async`/`await` per ogni operazione di database.
|
|
- Ogni valore prodotto dalla visione porta scritta la **versione del motore** che l'ha prodotto.
|
|
- **Due scostamenti voluti rispetto alla spec**, entrambi migliorativi e da non "correggere" tornando alla lettera del documento: la spec descrive una migrazione 011 sola con cinque cose dentro, il piano la divide in **011, 012 e 013** perché ogni migrazione viaggi col task che la rende necessaria e sia rivedibile da sola; e dove la spec dice `device_id`, le tabelle usano `device_code` (stringa), perché il registro dei device arriva col piano 2 e una misura presa oggi deve restare leggibile anche se quel device domani sparisce.
|
|
- I nomi di importazione di VisionSuite non sono uniformi ed è voluto a monte: `vs-core` si importa come `visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare`.
|
|
|
|
---
|
|
|
|
### Task 1: Grafo sul task e tipo corretto
|
|
|
|
Il primo mattone: il task sa portare un grafo, la subtask sa a quale uscita del grafo corrisponde, e il refuso `xf_compare` sparisce finché nessuna riga lo usa.
|
|
|
|
**Files:**
|
|
- Create: `src/backend/migrations/versions/011_vision_graph.py`
|
|
- Modify: `src/backend/models/orm/task.py:18` (costante `TASK_TYPES`), `src/backend/models/orm/task.py:49` (colonne di `RecipeTask`), `src/backend/models/orm/task.py:75+` (colonne di `RecipeSubtask`)
|
|
- Modify: `src/backend/models/api/task.py:61,73,89`
|
|
- Test: `src/backend/tests/test_vision_graph.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: niente, è il primo task.
|
|
- Produces: `RecipeTask.vision_json: dict | None`, `RecipeSubtask.vision_output: str | None`, e il valore `"dxf_compare"` nell'enum `task_type` al posto di `"xf_compare"`. Il formato di `vision_output` è la stringa `"<tool_id>.<nome_uscita>"`, che i Task 2 e 4 usano per mappare le uscite sulle quote.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Crea `src/backend/tests/test_vision_graph.py`:
|
|
|
|
```python
|
|
"""Il grafo di visione vive sul task, e la quota sa quale uscita la riempie."""
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from src.backend.models.orm.recipe import RecipeVersion
|
|
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
|
|
from src.backend.tests.conftest import create_test_recipe
|
|
|
|
|
|
async def recipe_parts(db_session, recipe_id: int):
|
|
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone.
|
|
|
|
Versione corrente, il suo unico task, la sua prima quota. È l'idioma che
|
|
`test_out_of_tolerance.py` usa già, ripetuto perché ogni file di prova si
|
|
legge da solo.
|
|
"""
|
|
version = (await db_session.execute(
|
|
select(RecipeVersion).where(
|
|
RecipeVersion.recipe_id == recipe_id,
|
|
RecipeVersion.is_current == True, # noqa: E712
|
|
)
|
|
)).scalar_one()
|
|
task = (await db_session.execute(
|
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
|
)).scalars().first()
|
|
subtask = (await db_session.execute(
|
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
|
)).scalars().first()
|
|
return version, task, subtask
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_carries_a_vision_graph(db_session, maker_user):
|
|
"""Il grafo si salva e si rilegge identico: il server non lo interpreta."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-1")
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
graph = {
|
|
"schema_version": 1,
|
|
"name": "diametro del foro",
|
|
"tools": [{"id": "t1", "kind": "DetectCircle", "name": "foro"}],
|
|
}
|
|
task.vision_json = graph
|
|
task.task_type = "camera_measure"
|
|
await db_session.flush()
|
|
db_session.expunge_all()
|
|
|
|
reloaded = (await db_session.execute(
|
|
select(RecipeTask).where(RecipeTask.id == task.id)
|
|
)).scalar_one()
|
|
assert reloaded.vision_json == graph
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subtask_names_the_output_that_fills_it(db_session, maker_user):
|
|
"""La quota dichiara quale uscita del grafo le compete."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-2")
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
subtask.vision_output = "t1.diameter"
|
|
await db_session.flush()
|
|
db_session.expunge_all()
|
|
|
|
reloaded = (await db_session.execute(
|
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask.id)
|
|
)).scalar_one()
|
|
assert reloaded.vision_output == "t1.diameter"
|
|
|
|
|
|
def test_the_typo_is_gone():
|
|
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
|
|
assert "dxf_compare" in TASK_TYPES
|
|
assert "xf_compare" not in TASK_TYPES
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `uv run pytest src/backend/tests/test_vision_graph.py -v`
|
|
Expected: FAIL — `AttributeError: 'RecipeTask' object has no attribute 'vision_json'` e `assert 'dxf_compare' in TASK_TYPES`.
|
|
|
|
- [ ] **Step 3: Write the migration**
|
|
|
|
Crea `src/backend/migrations/versions/011_vision_graph.py`:
|
|
|
|
```python
|
|
"""the vision graph lives on the task, and the quote names its output
|
|
|
|
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
|
|
renaming costs one statement here; from the first saved task onwards it would
|
|
be a data migration and the typo would already be in the public API.
|
|
|
|
Revision ID: 011_vision_graph
|
|
Revises: 010_meas_authorisation
|
|
Create Date: 2026-08-16
|
|
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '011_vision_graph'
|
|
down_revision: Union[str, None] = '010_meas_authorisation'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
|
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
|
|
|
|
|
def upgrade() -> None:
|
|
with op.batch_alter_table('recipe_tasks') as batch:
|
|
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
|
|
batch.alter_column(
|
|
'task_type',
|
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
|
existing_nullable=False,
|
|
existing_server_default='note',
|
|
)
|
|
|
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
|
batch.add_column(
|
|
sa.Column('vision_output', sa.String(length=120), nullable=True)
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
|
batch.drop_column('vision_output')
|
|
|
|
with op.batch_alter_table('recipe_tasks') as batch:
|
|
batch.alter_column(
|
|
'task_type',
|
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
|
existing_nullable=False,
|
|
existing_server_default='note',
|
|
)
|
|
batch.drop_column('vision_json')
|
|
```
|
|
|
|
- [ ] **Step 4: Update the ORM**
|
|
|
|
In `src/backend/models/orm/task.py`, sostituisci la costante a riga 18:
|
|
|
|
```python
|
|
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
|
```
|
|
|
|
Aggiungi in `RecipeTask`, subito dopo `annotations_json`:
|
|
|
|
```python
|
|
# The vision graph, serialised by vs-task. Opaque here on purpose: the
|
|
# server routes it and never interprets it, which is what keeps VisionSuite
|
|
# out of this container.
|
|
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
```
|
|
|
|
Aggiungi in `RecipeSubtask`, dopo `image_path`:
|
|
|
|
```python
|
|
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
|
|
# A link is an id, not a pointer - the same rule vs-task follows, and for
|
|
# the same reason: a pointer does not survive being written to disk.
|
|
vision_output: Mapped[Optional[str]] = mapped_column(
|
|
String(120), nullable=True
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 5: Update the Pydantic schemas**
|
|
|
|
In `src/backend/models/api/task.py`, il tipo letterale `TaskType` deve elencare `dxf_compare` al posto di `xf_compare`, e i tre schemi (create, update, response) devono esporre i due campi nuovi:
|
|
|
|
```python
|
|
TaskType = Literal["note", "measure", "drawing", "dxf_compare", "camera_measure"]
|
|
```
|
|
|
|
Nello schema di creazione e in quello di aggiornamento aggiungi:
|
|
|
|
```python
|
|
vision_json: Optional[dict] = None
|
|
```
|
|
|
|
Nello schema di risposta del task aggiungi lo stesso campo, e in quelli della subtask:
|
|
|
|
```python
|
|
vision_output: Optional[str] = None
|
|
```
|
|
|
|
- [ ] **Step 6: Run the tests to verify they pass**
|
|
|
|
Run: `uv run pytest src/backend/tests/test_vision_graph.py src/backend/tests/test_task_type.py -v`
|
|
Expected: PASS. `test_task_type.py` esisteva già e deve restare verde: se cita `xf_compare`, aggiornalo — è il solo posto dove il vecchio nome è lecito cambiarlo.
|
|
|
|
- [ ] **Step 7: Run the whole backend suite**
|
|
|
|
Run: `uv run pytest src/backend/tests/ -q`
|
|
Expected: 212 test più i tre nuovi, zero fallimenti.
|
|
|
|
- [ ] **Step 8: Verify the MySQL rendering**
|
|
|
|
Run: `uv run alembic -c src/backend/migrations/alembic.ini upgrade 010_meas_authorisation:011_vision_graph --sql`
|
|
Expected: un `ALTER TABLE recipe_tasks ADD COLUMN vision_json JSON`, un `MODIFY` dell'enum con `dxf_compare`, un `ADD COLUMN vision_output`. Nessun `CREATE TABLE _alembic_tmp_*`: se compare, il batch mode sta ricostruendo la tabella anche su MySQL e va corretto.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add src/backend/migrations/versions/011_vision_graph.py \
|
|
src/backend/models/orm/task.py \
|
|
src/backend/models/api/task.py \
|
|
src/backend/tests/test_vision_graph.py
|
|
git commit -m "feat(vision): il grafo vive sul task, la quota nomina la sua uscita"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Il runner, come libreria pura
|
|
|
|
Il pezzo che traduce grafo più immagine in numeri. È l'unica implementazione dell'esecuzione e i due host la condividono, quindi non sa nulla né di HTTP né di database.
|
|
|
|
**Files:**
|
|
- Create: `vendor/visionsuite` (sottomodulo git)
|
|
- Create: `src/vision/__init__.py`, `src/vision/runner.py`
|
|
- Create: `src/vision/tests/__init__.py`, `src/vision/tests/test_runner.py`
|
|
- Modify: `pyproject.toml` (nuovo extra `vision`, sezione `[tool.uv.sources]`)
|
|
- Modify: `.gitmodules` (creato dal comando di sottomodulo)
|
|
|
|
**Interfaces:**
|
|
- Consumes: il formato di `vision_output` definito nel Task 1 (`"<tool_id>.<nome_uscita>"`).
|
|
- Produces:
|
|
- `RunOutcome` — dataclass con `outputs: dict[str, float]` (chiavi `"<tool_id>.<uscita>"`), `failures: list[ToolFailure]`, `engine_version: str`, `duration_ms: int`
|
|
- `ToolFailure` — dataclass con `tool_id: str`, `tool_name: str`, `error: str`
|
|
- `run_graph(image, graph: dict, calibration: dict | None = None) -> RunOutcome`
|
|
- `engine_version() -> str`
|
|
|
|
- [ ] **Step 1: Add the submodule and pin it**
|
|
|
|
```bash
|
|
git submodule add ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git vendor/visionsuite
|
|
git -C vendor/visionsuite log -1 --format=%H
|
|
```
|
|
|
|
Annota il commit stampato: è la versione del motore, e il Task 5 ne verifica la coerenza.
|
|
|
|
- [ ] **Step 2: Declare the dependency**
|
|
|
|
In `pyproject.toml`, aggiungi l'extra e le sorgenti. Il runner **non** dipende da `vs-camera`: non tocca hardware, riceve immagini già acquisite.
|
|
|
|
```toml
|
|
[project.optional-dependencies]
|
|
vision = ["visionsuite", "vs-task", "vs-measure", "vs-pm2d"]
|
|
|
|
[tool.uv.sources]
|
|
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 }
|
|
```
|
|
|
|
Poi: `uv sync --extra vision --extra dev`
|
|
|
|
- [ ] **Step 3: Write the failing test**
|
|
|
|
Crea `src/vision/tests/test_runner.py`:
|
|
|
|
```python
|
|
"""Il runner: grafo più immagine, numeri fuori. Niente HTTP, niente database."""
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from src.vision.runner import RunOutcome, engine_version, run_graph
|
|
|
|
|
|
def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
|
"""Un disco chiaro su fondo scuro: un pezzo finto ma misurabile."""
|
|
image = np.zeros((size, size), dtype=np.uint8)
|
|
yy, xx = np.ogrid[:size, :size]
|
|
centre = size // 2
|
|
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
|
return image
|
|
|
|
|
|
def a_circle_graph() -> dict:
|
|
"""Un grafo di due strumenti, costruito con l'API di vs-task."""
|
|
from visionsuite.task import Task
|
|
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
|
|
|
task = Task(name="prova")
|
|
found = task.add(DetectCircleTool(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.link("circle", found, "circle")
|
|
return task.to_dict()
|
|
|
|
|
|
def test_the_graph_produces_named_outputs():
|
|
outcome = run_graph(a_disc(), a_circle_graph())
|
|
|
|
assert isinstance(outcome, RunOutcome)
|
|
assert outcome.failures == []
|
|
# Le chiavi sono «id dello strumento punto nome dell'uscita»: è la stessa
|
|
# forma che la subtask salva in vision_output.
|
|
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
|
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".diameter"))
|
|
assert diameter == pytest.approx(120.0, abs=4.0)
|
|
|
|
|
|
def test_a_broken_tool_does_not_sink_the_others():
|
|
"""vs-task non ferma l'esecuzione su un errore: il runner lo riporta."""
|
|
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}}
|
|
)
|
|
|
|
outcome = run_graph(a_disc(), graph)
|
|
|
|
assert [f.tool_id for f in outcome.failures] == ["ghost"]
|
|
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
|
|
|
|
|
def test_the_engine_says_which_version_it_is():
|
|
version = engine_version()
|
|
assert len(version) == 40 # un SHA di git, per intero
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it fails**
|
|
|
|
Run: `uv run pytest src/vision/tests/test_runner.py -v`
|
|
Expected: FAIL con `ModuleNotFoundError: No module named 'src.vision.runner'`.
|
|
|
|
- [ ] **Step 5: Write the runner**
|
|
|
|
Crea `src/vision/__init__.py` vuoto e `src/vision/runner.py`:
|
|
|
|
```python
|
|
"""Graph plus image in, numbers out.
|
|
|
|
The one implementation of vision execution: the worker beside the server and
|
|
the station agent both call this, and they must agree to the digit. No HTTP, no
|
|
database, no filesystem beyond reading the pinned submodule's commit.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from visionsuite.task import Task
|
|
|
|
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
|
|
|
|
|
|
@dataclass
|
|
class ToolFailure:
|
|
tool_id: str
|
|
tool_name: str
|
|
error: str
|
|
|
|
|
|
@dataclass
|
|
class RunOutcome:
|
|
outputs: dict[str, float] = field(default_factory=dict)
|
|
failures: list[ToolFailure] = field(default_factory=list)
|
|
engine_version: str = ""
|
|
duration_ms: int = 0
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def engine_version() -> str:
|
|
"""The pinned VisionSuite commit: stamped on every number produced."""
|
|
return subprocess.check_output(
|
|
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
|
|
text=True,
|
|
).strip()
|
|
|
|
|
|
def run_graph(
|
|
image: Any,
|
|
graph: dict,
|
|
calibration: dict | None = None,
|
|
) -> RunOutcome:
|
|
"""Run a serialised vs-task 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
|
|
back named, they are not swallowed.
|
|
"""
|
|
task = Task.from_dict(graph)
|
|
context: dict[str, Any] = {"image": image}
|
|
if calibration is not None:
|
|
context["calibration"] = calibration
|
|
|
|
started = time.monotonic()
|
|
report = task.run(context=context)
|
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
|
|
outputs: dict[str, float] = {}
|
|
failures: list[ToolFailure] = []
|
|
for result in report:
|
|
if not result.ok:
|
|
failures.append(ToolFailure(
|
|
tool_id=result.tool_id,
|
|
tool_name=result.tool_name,
|
|
error=result.error or "unknown failure",
|
|
))
|
|
continue
|
|
for name, value in result.outputs.items():
|
|
if isinstance(value, (int, float)):
|
|
outputs[f"{result.tool_id}.{name}"] = float(value)
|
|
|
|
return RunOutcome(
|
|
outputs=outputs,
|
|
failures=failures,
|
|
engine_version=engine_version(),
|
|
duration_ms=elapsed_ms,
|
|
)
|
|
```
|
|
|
|
Solo le uscite numeriche entrano in `outputs`: gli strumenti restituiscono anche primitive geometriche — un `Circle`, una `Line` — che servono ai legami interni al grafo ma non sono quote e non hanno nulla da fare in una `measurement`.
|
|
|
|
- [ ] **Step 6: Run the tests to verify they pass**
|
|
|
|
Run: `uv run pytest src/vision/tests/test_runner.py -v`
|
|
Expected: PASS, tutti e tre.
|
|
|
|
Se `test_the_graph_produces_named_outputs` fallisce sul valore e non sulla chiave, il problema è la soglia di `DetectCircleTool` sull'immagine sintetica, non il runner: alza `scan_size` o ammorbidisci la tolleranza del `pytest.approx`, e annota nel commit che il valore è tarato su un'immagine finta.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add .gitmodules vendor/visionsuite pyproject.toml uv.lock \
|
|
src/vision/__init__.py src/vision/runner.py \
|
|
src/vision/tests/__init__.py src/vision/tests/test_runner.py
|
|
git commit -m "feat(vision): il runner, unica implementazione dell'esecuzione"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Il worker che espone il runner
|
|
|
|
Il container pesante. Esiste perché l'immagine dell'API non diventi da cinque gigabyte, e perché un'elaborazione che va in crash non porti giù le richieste degli altri tablet.
|
|
|
|
**Files:**
|
|
- Create: `src/vision_worker/__init__.py`, `src/vision_worker/main.py`
|
|
- Create: `src/vision_worker/tests/__init__.py`, `src/vision_worker/tests/test_worker.py`
|
|
- Create: `Dockerfile.vision`
|
|
- Modify: `docker-compose.dev.yml` (servizio `vision`), `docker-compose.yml` (idem, senza etichette Traefik: è interno)
|
|
- Modify: `.env.example` (`VISION_WORKER_URL`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `run_graph`, `RunOutcome`, `engine_version` dal Task 2.
|
|
- Produces: `POST /run` — multipart con `image` (file) e `graph` (stringa JSON), risposta
|
|
`{"outputs": {...}, "failures": [{"tool_id","tool_name","error"}], "engine_version": "...", "duration_ms": 0}`;
|
|
e `GET /health` → `{"status": "ok", "engine_version": "..."}`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Crea `src/vision_worker/tests/test_worker.py`:
|
|
|
|
```python
|
|
"""Il worker è un guscio sottile: riceve immagine e grafo, risponde numeri."""
|
|
import io
|
|
import json
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from PIL import Image
|
|
|
|
from src.vision_worker.main import app
|
|
|
|
|
|
def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
|
|
image = np.zeros((size, size), dtype=np.uint8)
|
|
yy, xx = np.ogrid[:size, :size]
|
|
centre = size // 2
|
|
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
|
buffer = io.BytesIO()
|
|
Image.fromarray(image).save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
def a_circle_graph() -> dict:
|
|
from visionsuite.task import Task
|
|
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
|
|
|
task = Task(name="prova")
|
|
found = task.add(DetectCircleTool(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.link("circle", found, "circle")
|
|
return task.to_dict()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_says_which_engine_it_carries():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
|
response = await client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
assert len(response.json()["engine_version"]) == 40
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_returns_the_named_outputs():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
|
response = await client.post(
|
|
"/run",
|
|
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
|
data={"graph": json.dumps(a_circle_graph())},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["failures"] == []
|
|
assert any(key.endswith(".diameter") for key in body["outputs"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_malformed_graph_is_refused_not_crashed():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
|
response = await client.post(
|
|
"/run",
|
|
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
|
data={"graph": json.dumps({"schema_version": 99, "tools": []})},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "99" in response.json()["detail"]
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `uv run pytest src/vision_worker/tests/test_worker.py -v`
|
|
Expected: FAIL con `ModuleNotFoundError: No module named 'src.vision_worker.main'`.
|
|
|
|
- [ ] **Step 3: Write the worker**
|
|
|
|
Crea `src/vision_worker/__init__.py` vuoto e `src/vision_worker/main.py`:
|
|
|
|
```python
|
|
"""The heavy container: the runner behind an internal API.
|
|
|
|
Separate from the FastAPI server on purpose. The API image stays light, a
|
|
VisionSuite upgrade does not restart production traffic, and an execution that
|
|
crashes does not take the other tablets' requests down with it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import numpy as np
|
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
|
from PIL import Image
|
|
|
|
from src.vision.runner import engine_version, run_graph
|
|
|
|
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict:
|
|
return {"status": "ok", "engine_version": engine_version()}
|
|
|
|
|
|
@app.post("/run")
|
|
async def run(
|
|
image: UploadFile = File(...),
|
|
graph: str = Form(...),
|
|
) -> 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
|
|
|
|
frame = np.array(Image.open(image.file).convert("L"))
|
|
|
|
try:
|
|
outcome = run_graph(frame, parsed_graph)
|
|
except ValueError as exc:
|
|
# from_dict refuses a schema version it does not handle; that is a bad
|
|
# request, not a server fault.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
return {
|
|
"outputs": outcome.outputs,
|
|
"failures": [
|
|
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
|
|
for f in outcome.failures
|
|
],
|
|
"engine_version": outcome.engine_version,
|
|
"duration_ms": outcome.duration_ms,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `uv run pytest src/vision_worker/tests/test_worker.py -v`
|
|
Expected: PASS, tutti e tre.
|
|
|
|
- [ ] **Step 5: Write the Dockerfile**
|
|
|
|
Crea `Dockerfile.vision`:
|
|
|
|
```dockerfile
|
|
FROM python:3.11-slim
|
|
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
git libgl1 libglib2.0-0 \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
|
|
WORKDIR /app
|
|
COPY pyproject.toml uv.lock ./
|
|
COPY vendor/ ./vendor/
|
|
RUN uv sync --extra vision --frozen --no-dev
|
|
|
|
COPY src/vision/ ./src/vision/
|
|
COPY src/vision_worker/ ./src/vision_worker/
|
|
|
|
EXPOSE 8100
|
|
|
|
# Two workers, not four: each holds the vision stack in memory.
|
|
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
|
|
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
|
|
```
|
|
|
|
`git` è installato perché `engine_version()` legge il commit del sottomodulo con `git rev-parse`. Se in futuro l'immagine dovesse restare senza git, la versione va scritta in un file al momento del build — ma allora va scritta anche nell'agente, e le due strade devono continuare a coincidere.
|
|
|
|
- [ ] **Step 6: Add the service to both compose files**
|
|
|
|
In `docker-compose.dev.yml` e in `docker-compose.yml`, accanto a `server`:
|
|
|
|
```yaml
|
|
vision:
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile.vision
|
|
container_name: tmflow-vision
|
|
restart: unless-stopped
|
|
networks:
|
|
- tmflow-net
|
|
```
|
|
|
|
Nessuna porta pubblicata e nessuna etichetta Traefik: il worker è interno e lo raggiunge solo il server, all'indirizzo `http://vision:8100`.
|
|
|
|
In `docker-compose.yml` il servizio `server` prende inoltre:
|
|
|
|
```yaml
|
|
environment:
|
|
DB_HOST: mysql
|
|
UPLOAD_DIR: uploads
|
|
VISION_WORKER_URL: http://vision:8100
|
|
```
|
|
|
|
e lo stesso in `docker-compose.dev.yml`. In `.env.example`, sotto la sezione del server:
|
|
|
|
```
|
|
# --- Vision ---
|
|
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
|
VISION_WORKER_URL=http://vision:8100
|
|
```
|
|
|
|
- [ ] **Step 7: Verify the worker builds and answers**
|
|
|
|
```bash
|
|
docker compose -f docker-compose.dev.yml up -d --build vision
|
|
docker compose -f docker-compose.dev.yml exec server \
|
|
python -c "import urllib.request;print(urllib.request.urlopen('http://vision:8100/health',timeout=30).read().decode())"
|
|
```
|
|
|
|
Expected: `{"status":"ok","engine_version":"<40 caratteri>"}`.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/vision_worker/ Dockerfile.vision \
|
|
docker-compose.dev.yml docker-compose.yml .env.example
|
|
git commit -m "feat(vision): il worker, container separato per non appesantire l'API"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Il server esegue e salva
|
|
|
|
Il punto in cui una quota di visione diventa una `measurement` come le altre. Da qui in poi statistica, gate del fuori tolleranza, autorizzazione del capoturno ed export funzionano senza sapere che esiste una camera.
|
|
|
|
**Files:**
|
|
- Create: `src/backend/migrations/versions/012_vision_results.py`
|
|
- Create: `src/backend/models/orm/vision.py`
|
|
- Create: `src/backend/models/api/vision.py`
|
|
- Create: `src/backend/services/vision_service.py`
|
|
- Create: `src/backend/api/routers/vision.py`
|
|
- Create: `src/backend/tests/test_vision_execute.py`
|
|
- Modify: `src/backend/models/orm/__init__.py` (esporta `VisionResult`)
|
|
- Modify: `src/backend/models/orm/measurement.py:40` (enum `input_method`), più la colonna `vision_result_id`
|
|
- Modify: `src/backend/services/measurement_service.py:216` (parametro `vision_result_id`)
|
|
- Modify: `src/backend/main.py:24,72` (registrazione del router)
|
|
- Modify: `src/backend/config.py` (impostazione `vision_worker_url`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `vision_json` e `vision_output` dal Task 1; la risposta di `POST /run` dal Task 3.
|
|
- Produces:
|
|
- `VisionResult` ORM con `image_path`, `overlay_path`, `engine_version`, `executed_on`, `station_id`, `device_id`, `calibration_snapshot`, `graph_snapshot`, `duration_ms`, `executed_at`
|
|
- `vision_service.execute_task(db, task, image_bytes, measured_by, *, version_id, lot_number, serial_number, production_run_id) -> tuple[VisionResult, list[Measurement]]`
|
|
- `POST /api/vision/execute` (multipart: `task_id`, `image`, opzionali `lot_number`, `serial_number`, `production_run_id`)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Crea `src/backend/tests/test_vision_execute.py`:
|
|
|
|
```python
|
|
"""Una quota da camera è una misura come le altre: stesso verdetto, stesso gate."""
|
|
import io
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy import select
|
|
|
|
from src.backend.models.orm.measurement import Measurement
|
|
from src.backend.models.orm.recipe import RecipeVersion
|
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|
from src.backend.models.orm.vision import VisionResult
|
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
|
|
|
|
|
def an_image() -> bytes:
|
|
buffer = io.BytesIO()
|
|
Image.fromarray(np.zeros((64, 64), dtype=np.uint8)).save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
async def recipe_parts(db_session, recipe_id: int):
|
|
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone."""
|
|
version = (await db_session.execute(
|
|
select(RecipeVersion).where(
|
|
RecipeVersion.recipe_id == recipe_id,
|
|
RecipeVersion.is_current == True, # noqa: E712
|
|
)
|
|
)).scalar_one()
|
|
task = (await db_session.execute(
|
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
|
)).scalars().first()
|
|
subtask = (await db_session.execute(
|
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
|
)).scalars().first()
|
|
return version, task, subtask
|
|
|
|
|
|
@pytest.fixture
|
|
def worker_says(monkeypatch):
|
|
"""Il worker non gira nei test: se ne finge la risposta."""
|
|
def _install(outputs: dict, failures: list | None = None):
|
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
|
return {
|
|
"outputs": outputs,
|
|
"failures": failures or [],
|
|
"engine_version": "a" * 40,
|
|
"duration_ms": 12,
|
|
}
|
|
monkeypatch.setattr(
|
|
"src.backend.services.vision_service._call_worker", fake_call,
|
|
)
|
|
return _install
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_camera_quote_becomes_a_measurement(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
subtask.vision_output = "t1.diameter"
|
|
# Il fixture crea la quota con nominale 10 e limiti stretti attorno.
|
|
await db_session.commit()
|
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
saved = (await db_session.execute(
|
|
select(Measurement).where(Measurement.subtask_id == subtask.id)
|
|
)).scalars().all()
|
|
assert len(saved) == 1
|
|
assert saved[0].pass_fail == "pass"
|
|
assert saved[0].input_method == "camera"
|
|
assert saved[0].vision_result_id is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_execution_records_which_engine_produced_it(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
subtask.vision_output = "t1.diameter"
|
|
await db_session.commit()
|
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
|
|
|
await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
|
)
|
|
|
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
|
assert result.engine_version == "a" * 40
|
|
assert result.executed_on == "server"
|
|
assert result.graph_snapshot == task.vision_json
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_missing_output_names_itself(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
"""Nessuna misura a metà: se manca un'uscita, l'errore dice quale."""
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
subtask.vision_output = "t1.diameter"
|
|
await db_session.commit()
|
|
worker_says({"t1.radius": 5.0})
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "t1.diameter" in response.json()["detail"]
|
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_task_without_a_graph_is_refused(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
await db_session.commit()
|
|
worker_says({})
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "graph" in response.json()["detail"].lower()
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `uv run pytest src/backend/tests/test_vision_execute.py -v`
|
|
Expected: FAIL con `ModuleNotFoundError: No module named 'src.backend.models.orm.vision'`.
|
|
|
|
- [ ] **Step 3: Write the migration**
|
|
|
|
Crea `src/backend/migrations/versions/012_vision_results.py`:
|
|
|
|
```python
|
|
"""one row per vision execution, and camera as an input method
|
|
|
|
The vision surroundings do not go on `measurements`: statistics and the export
|
|
read that table on every pass. And it would be wrong modelling anyway - one
|
|
acquisition produces N quotes, and image, overlay, device and engine version are
|
|
the same for all of them.
|
|
|
|
Revision ID: 012_vision_results
|
|
Revises: 011_vision_graph
|
|
Create Date: 2026-08-16
|
|
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '012_vision_results'
|
|
down_revision: Union[str, None] = '011_vision_graph'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
OLD_METHODS = ('usb_caliper', 'manual')
|
|
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
'vision_results',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('task_id', sa.Integer(),
|
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
|
sa.Column('image_path', sa.String(length=500), nullable=True),
|
|
sa.Column('overlay_path', sa.String(length=500), nullable=True),
|
|
sa.Column('engine_version', sa.String(length=64), nullable=False),
|
|
sa.Column('executed_on',
|
|
sa.Enum('server', 'station', name='vision_executed_on_enum'),
|
|
nullable=False),
|
|
sa.Column('station_id', sa.Integer(),
|
|
sa.ForeignKey('stations.id'), nullable=True),
|
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
|
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
|
|
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
|
sa.Column('executed_at', sa.DateTime(), nullable=False,
|
|
server_default=sa.func.now()),
|
|
mysql_engine='InnoDB',
|
|
mysql_charset='utf8mb4',
|
|
)
|
|
|
|
with op.batch_alter_table('measurements') as batch:
|
|
batch.alter_column(
|
|
'input_method',
|
|
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
|
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
|
existing_nullable=False,
|
|
)
|
|
batch.add_column(
|
|
sa.Column('vision_result_id', sa.Integer(), nullable=True)
|
|
)
|
|
batch.create_foreign_key(
|
|
'fk_measurements_vision_result',
|
|
'vision_results', ['vision_result_id'], ['id'],
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
with op.batch_alter_table('measurements') as batch:
|
|
batch.drop_constraint('fk_measurements_vision_result',
|
|
type_='foreignkey')
|
|
batch.drop_column('vision_result_id')
|
|
batch.alter_column(
|
|
'input_method',
|
|
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
|
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
|
existing_nullable=False,
|
|
)
|
|
|
|
op.drop_table('vision_results')
|
|
```
|
|
|
|
`device_code` è una stringa e non una chiave esterna: il registro dei device arriva col piano 2, e una misura presa oggi deve restare leggibile anche se quel device domani sparisce.
|
|
|
|
- [ ] **Step 4: Write the ORM model**
|
|
|
|
Crea `src/backend/models/orm/vision.py`:
|
|
|
|
```python
|
|
"""One row per vision execution - not per quote."""
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from src.backend.database import Base
|
|
|
|
|
|
class VisionResult(Base):
|
|
__tablename__ = "vision_results"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
task_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
|
)
|
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
executed_on: Mapped[str] = mapped_column(
|
|
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
|
|
)
|
|
station_id: Mapped[Optional[int]] = mapped_column(
|
|
Integer, ForeignKey("stations.id"), nullable=True
|
|
)
|
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
|
# Copied, not referenced: a calibration redone six months from now must not
|
|
# rewrite what a measurement already taken means. Same for the graph.
|
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
executed_at: Mapped[datetime] = mapped_column(
|
|
DateTime, nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
|
```
|
|
|
|
`Base` viene da `src.backend.database`, come in tutti gli altri modelli ORM del progetto. Aggiungi `VisionResult` a `src/backend/models/orm/__init__.py` accanto agli altri.
|
|
|
|
In `src/backend/models/orm/measurement.py`, estendi l'enum a riga 40 e aggiungi la colonna:
|
|
|
|
```python
|
|
input_method: Mapped[str] = mapped_column(
|
|
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
|
nullable=False,
|
|
default="manual",
|
|
)
|
|
```
|
|
|
|
```python
|
|
vision_result_id: Mapped[Optional[int]] = mapped_column(
|
|
Integer, ForeignKey("vision_results.id"), nullable=True
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 5: Extend save_measurement**
|
|
|
|
In `src/backend/services/measurement_service.py`, aggiungi il parametro in coda alla firma di `save_measurement` (riga 216) e passalo al costruttore:
|
|
|
|
```python
|
|
production_run_id: int | None = None,
|
|
vision_result_id: int | None = None,
|
|
) -> Measurement:
|
|
```
|
|
|
|
```python
|
|
production_run_id=production_run_id,
|
|
vision_result_id=vision_result_id,
|
|
)
|
|
```
|
|
|
|
Non serve toccare `_enforce_recipe_rules`: blocca solo `manual` quando la ricetta lo vieta, quindi `camera` passa come passa `usb_caliper`. E non serve toccare il gate del fuori tolleranza: è già in `save_measurement` e vale per chiunque.
|
|
|
|
- [ ] **Step 6: Write the service**
|
|
|
|
Crea `src/backend/services/vision_service.py`:
|
|
|
|
```python
|
|
"""Route the image to the worker, map the outputs onto the quotes, save.
|
|
|
|
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
|
|
to the worker over HTTP and stays ignorant of how a number was produced.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.backend.config import settings
|
|
from src.backend.models.orm.measurement import Measurement
|
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|
from src.backend.models.orm.vision import VisionResult
|
|
from src.backend.services import measurement_service
|
|
|
|
|
|
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
|
"""The one place that talks to the worker; the tests replace it."""
|
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
response = await client.post(
|
|
f"{settings.vision_worker_url}/run",
|
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
|
data={"graph": json.dumps(graph)},
|
|
)
|
|
if response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker refused the job: {response.text}",
|
|
)
|
|
return response.json()
|
|
|
|
|
|
async def execute_task(
|
|
db: AsyncSession,
|
|
task: RecipeTask,
|
|
image_bytes: bytes,
|
|
measured_by: int,
|
|
*,
|
|
version_id: int,
|
|
lot_number: str | None = None,
|
|
serial_number: str | None = None,
|
|
production_run_id: int | None = None,
|
|
) -> tuple[VisionResult, list[Measurement]]:
|
|
if not task.vision_json:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="this task carries no vision graph",
|
|
)
|
|
|
|
quotes = (await db.execute(
|
|
select(RecipeSubtask)
|
|
.where(RecipeSubtask.task_id == task.id)
|
|
.where(RecipeSubtask.vision_output.is_not(None))
|
|
)).scalars().all()
|
|
if not quotes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="no quote on this task names a vision output",
|
|
)
|
|
|
|
report = await _call_worker(image_bytes, task.vision_json)
|
|
outputs = report["outputs"]
|
|
|
|
# Every quote or none. A half-measured piece read as a whole one is worse
|
|
# than a piece not measured at all.
|
|
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
|
|
if missing:
|
|
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=(
|
|
f"the graph did not produce {', '.join(missing)}"
|
|
+ (f" (tools that failed: {failed})" if failed else "")
|
|
),
|
|
)
|
|
|
|
result = VisionResult(
|
|
task_id=task.id,
|
|
engine_version=report["engine_version"],
|
|
executed_on="server",
|
|
graph_snapshot=task.vision_json,
|
|
duration_ms=report.get("duration_ms"),
|
|
)
|
|
db.add(result)
|
|
await db.flush()
|
|
|
|
saved: list[Measurement] = []
|
|
for quote in quotes:
|
|
saved.append(await measurement_service.save_measurement(
|
|
db,
|
|
subtask_id=quote.id,
|
|
version_id=version_id,
|
|
measured_by=measured_by,
|
|
value=float(outputs[quote.vision_output]),
|
|
lot_number=lot_number,
|
|
serial_number=serial_number,
|
|
input_method="camera",
|
|
production_run_id=production_run_id,
|
|
vision_result_id=result.id,
|
|
))
|
|
|
|
return result, saved
|
|
```
|
|
|
|
In `src/backend/config.py`, aggiungi accanto alle altre impostazioni:
|
|
|
|
```python
|
|
vision_worker_url: str = "http://vision:8100"
|
|
```
|
|
|
|
- [ ] **Step 7: Write the router**
|
|
|
|
Crea `src/backend/api/routers/vision.py`:
|
|
|
|
```python
|
|
"""The client asks for a measurement and gets an outcome.
|
|
|
|
It does not know, and must not know, whether the server or the station computed
|
|
the numbers: that is what lets a station's configuration change without touching
|
|
the frontend or the recipes.
|
|
"""
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.backend.api.middleware.api_key import get_current_user
|
|
from src.backend.database import get_db
|
|
from src.backend.models.orm.task import RecipeTask
|
|
from src.backend.models.orm.user import User
|
|
from src.backend.services import vision_service
|
|
|
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
|
|
|
|
|
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
|
"""Shared by the three endpoints of this router."""
|
|
task = (await db.execute(
|
|
select(RecipeTask).where(RecipeTask.id == task_id)
|
|
)).scalar_one_or_none()
|
|
if task is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
return task
|
|
|
|
|
|
@router.post("/execute")
|
|
async def execute(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
lot_number: str | None = Form(None),
|
|
serial_number: str | None = Form(None),
|
|
production_run_id: int | None = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
task = await _task_or_404(db, task_id)
|
|
|
|
result, saved = await vision_service.execute_task(
|
|
db,
|
|
task,
|
|
await image.read(),
|
|
current_user.id,
|
|
version_id=task.version_id,
|
|
lot_number=lot_number,
|
|
serial_number=serial_number,
|
|
production_run_id=production_run_id,
|
|
)
|
|
await db.commit()
|
|
|
|
return {
|
|
"vision_result_id": result.id,
|
|
"engine_version": result.engine_version,
|
|
"measurements": [
|
|
{"subtask_id": m.subtask_id, "value": float(m.value),
|
|
"pass_fail": m.pass_fail}
|
|
for m in saved
|
|
],
|
|
}
|
|
```
|
|
|
|
Registralo in `src/backend/main.py`, con l'import accanto agli altri a riga 24 e la chiamata accanto alle altre a riga 72:
|
|
|
|
```python
|
|
from src.backend.api.routers.vision import router as vision_router
|
|
```
|
|
|
|
```python
|
|
app.include_router(vision_router)
|
|
```
|
|
|
|
- [ ] **Step 8: Run the tests to verify they pass**
|
|
|
|
Run: `uv run pytest src/backend/tests/test_vision_execute.py -v`
|
|
Expected: PASS, tutti e quattro.
|
|
|
|
- [ ] **Step 9: Run the whole backend suite**
|
|
|
|
Run: `uv run pytest src/backend/tests/ -q`
|
|
Expected: zero fallimenti. In particolare `test_out_of_tolerance.py` e `test_production_runs.py` devono restare verdi senza modifiche: se una di quelle si rompe, la logica del verdetto è stata toccata e non doveva esserlo.
|
|
|
|
- [ ] **Step 10: Verify the MySQL rendering**
|
|
|
|
Run: `uv run alembic -c src/backend/migrations/alembic.ini upgrade 011_vision_graph:012_vision_results --sql`
|
|
Expected: `CREATE TABLE vision_results`, un `MODIFY` di `input_method` con `camera`, `ADD COLUMN vision_result_id` e la chiave esterna.
|
|
|
|
- [ ] **Step 11: Commit**
|
|
|
|
```bash
|
|
git add src/backend/migrations/versions/012_vision_results.py \
|
|
src/backend/models/orm/vision.py src/backend/models/orm/__init__.py \
|
|
src/backend/models/orm/measurement.py \
|
|
src/backend/services/vision_service.py \
|
|
src/backend/services/measurement_service.py \
|
|
src/backend/api/routers/vision.py src/backend/main.py \
|
|
src/backend/config.py src/backend/tests/test_vision_execute.py
|
|
git commit -m "feat(vision): il server esegue tramite worker e salva misure vere"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Il confine, sorvegliato
|
|
|
|
La decisione 4 della spec regge su un confine che nessuno vede rompersi. Questo task lo rende visibile.
|
|
|
|
**Files:**
|
|
- Create: `src/backend/tests/test_vision_boundary.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: l'albero dei sorgenti di `src/backend/` e il file `.gitmodules` dal Task 2.
|
|
- Produces: nessuna API. È una prova che vincola le altre.
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
Segue la specie dei tre test che il progetto ha già e che leggono i sorgenti invece di renderizzare — `test_offline.py`, `test_layout_shell.py`, `test_template_js_syntax.py`.
|
|
|
|
Crea `src/backend/tests/test_vision_boundary.py`:
|
|
|
|
```python
|
|
"""Il server non importa VisionSuite, e questo lo verifica.
|
|
|
|
Senza qualcuno che lo controlli, il confine si perde in silenzio: basta un
|
|
import comodo perché l'immagine dell'API torni da cinque gigabyte e il motivo
|
|
per cui il worker esiste svanisca senza che nessuna prova diventi rossa.
|
|
"""
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
BACKEND = Path(__file__).resolve().parents[1]
|
|
FORBIDDEN = {"visionsuite", "pm2d", "dxf_compare", "Acquire", "torch"}
|
|
|
|
|
|
def _imported_roots(source: Path) -> set[str]:
|
|
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
|
roots: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
roots.update(alias.name.split(".")[0] for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
roots.add(node.module.split(".")[0])
|
|
return roots
|
|
|
|
|
|
def test_the_server_never_imports_visionsuite():
|
|
offenders = []
|
|
for source in BACKEND.rglob("*.py"):
|
|
if "tests" in source.parts or "migrations" in source.parts:
|
|
continue
|
|
forbidden = _imported_roots(source) & FORBIDDEN
|
|
if forbidden:
|
|
offenders.append(f"{source.relative_to(BACKEND)}: {sorted(forbidden)}")
|
|
|
|
assert offenders == [], (
|
|
"il server deve restare cieco alla visione; usa il worker via HTTP:\n"
|
|
+ "\n".join(offenders)
|
|
)
|
|
|
|
|
|
def test_the_submodule_is_pinned():
|
|
"""Server e stazione devono montare lo stesso commit: qui c'è la fonte."""
|
|
gitmodules = Path(__file__).resolve().parents[3] / ".gitmodules"
|
|
assert gitmodules.exists(), "vendor/visionsuite non è un sottomodulo"
|
|
assert "vendor/visionsuite" in gitmodules.read_text(encoding="utf-8")
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it passes**
|
|
|
|
Run: `uv run pytest src/backend/tests/test_vision_boundary.py -v`
|
|
Expected: PASS entrambe. Se la prima fallisce, il Task 4 ha introdotto un import che non doveva esserci: si toglie l'import, non si allarga la prova.
|
|
|
|
- [ ] **Step 3: Verify the test actually bites**
|
|
|
|
Aggiungi temporaneamente `import pm2d` in cima a `src/backend/services/vision_service.py`, rilancia la prova e verifica che diventi **rossa** nominando quel file. Poi togli l'import.
|
|
|
|
Una prova che non è mai stata vista fallire non è una prova: è una riga che passa.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/backend/tests/test_vision_boundary.py
|
|
git commit -m "test(vision): il confine fra server e VisionSuite è sorvegliato"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Le immagini di riferimento
|
|
|
|
Ciò che permette al Maker di comporre alla scrivania, di provare una ricetta prima di metterla in produzione, e — più avanti — di rieseguire il parco ricette dopo un aggiornamento del motore.
|
|
|
|
**Files:**
|
|
- Create: `src/backend/migrations/versions/013_reference_images.py`
|
|
- Create: `src/backend/tests/test_vision_reference.py`
|
|
- Modify: `src/backend/models/orm/vision.py` (classe `VisionReferenceImage`)
|
|
- Modify: `src/backend/models/orm/__init__.py`
|
|
- Modify: `src/backend/services/vision_service.py` (funzioni `save_reference_image`, `preview`)
|
|
- Modify: `src/backend/api/routers/vision.py` (due endpoint)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `execute_task` e `_call_worker` dal Task 4.
|
|
- Produces:
|
|
- `VisionReferenceImage` ORM con `task_id`, `path`, `station_id`, `device_code`, `calibration_snapshot`, `engine_version`, `acquired_at`, `note`, `expected_json`
|
|
- `vision_service.save_reference_image(db, task_id, image_bytes, *, note=None) -> VisionReferenceImage`
|
|
- `vision_service.preview(db, task, image_bytes) -> dict` — esegue e **non salva misure**
|
|
- `POST /api/vision/reference-images`, `POST /api/vision/preview`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Crea `src/backend/tests/test_vision_reference.py`:
|
|
|
|
```python
|
|
"""Il Maker compone alla scrivania: immagine salvata, grafo provato, niente misure."""
|
|
import io
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy import select
|
|
|
|
from src.backend.models.orm.measurement import Measurement
|
|
from src.backend.models.orm.recipe import RecipeVersion
|
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|
from src.backend.models.orm.vision import VisionReferenceImage
|
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
|
|
|
|
|
def an_image() -> bytes:
|
|
buffer = io.BytesIO()
|
|
Image.fromarray(np.zeros((64, 64), dtype=np.uint8)).save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
async def recipe_parts(db_session, recipe_id: int):
|
|
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone."""
|
|
version = (await db_session.execute(
|
|
select(RecipeVersion).where(
|
|
RecipeVersion.recipe_id == recipe_id,
|
|
RecipeVersion.is_current == True, # noqa: E712
|
|
)
|
|
)).scalar_one()
|
|
task = (await db_session.execute(
|
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
|
)).scalars().first()
|
|
subtask = (await db_session.execute(
|
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
|
)).scalars().first()
|
|
return version, task, subtask
|
|
|
|
|
|
@pytest.fixture
|
|
def worker_says(monkeypatch):
|
|
def _install(outputs: dict):
|
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
|
return {"outputs": outputs, "failures": [],
|
|
"engine_version": "b" * 40, "duration_ms": 7}
|
|
monkeypatch.setattr(
|
|
"src.backend.services.vision_service._call_worker", fake_call,
|
|
)
|
|
return _install
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_reference_image_keeps_its_provenance(
|
|
client, db_session, maker_user,
|
|
):
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
await db_session.commit()
|
|
|
|
response = await client.post(
|
|
"/api/vision/reference-images",
|
|
headers=auth_headers(maker_user),
|
|
files={"image": ("ref.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id), "note": "pezzo campione"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
saved = (await db_session.execute(
|
|
select(VisionReferenceImage)
|
|
)).scalars().one()
|
|
assert saved.task_id == task.id
|
|
assert saved.note == "pezzo campione"
|
|
assert saved.acquired_at is not None
|
|
assert saved.path
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preview_runs_the_graph_and_saves_no_measurement(
|
|
client, db_session, maker_user, worker_says,
|
|
):
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
subtask.vision_output = "t1.diameter"
|
|
await db_session.commit()
|
|
worker_says({"t1.diameter": 42.0})
|
|
|
|
response = await client.post(
|
|
"/api/vision/preview",
|
|
headers=auth_headers(maker_user),
|
|
files={"image": ("ref.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["outputs"]["t1.diameter"] == 42.0
|
|
# È il punto di questa prova: comporre non misura.
|
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `uv run pytest src/backend/tests/test_vision_reference.py -v`
|
|
Expected: FAIL con `ImportError: cannot import name 'VisionReferenceImage'`.
|
|
|
|
- [ ] **Step 3: Write the migration**
|
|
|
|
Crea `src/backend/migrations/versions/013_reference_images.py`:
|
|
|
|
```python
|
|
"""reference images, with their provenance
|
|
|
|
A reference image without provenance is a trap: the graph is re-run months later,
|
|
different numbers come out, and there is no way to tell an updated engine from a
|
|
moved lens.
|
|
|
|
`expected_json` holds what the graph is supposed to produce on this image. It is
|
|
what makes a regression run possible after a VisionSuite upgrade - the safety net
|
|
that makes bumping the submodule sustainable with an engine on two hosts.
|
|
|
|
Revision ID: 013_reference_images
|
|
Revises: 012_vision_results
|
|
Create Date: 2026-08-16
|
|
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '013_reference_images'
|
|
down_revision: Union[str, None] = '012_vision_results'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
'vision_reference_images',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('task_id', sa.Integer(),
|
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
|
sa.Column('path', sa.String(length=500), nullable=False),
|
|
sa.Column('station_id', sa.Integer(),
|
|
sa.ForeignKey('stations.id'), nullable=True),
|
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
|
sa.Column('engine_version', sa.String(length=64), nullable=True),
|
|
sa.Column('note', sa.String(length=500), nullable=True),
|
|
sa.Column('expected_json', sa.JSON(), nullable=True),
|
|
sa.Column('acquired_at', sa.DateTime(), nullable=False,
|
|
server_default=sa.func.now()),
|
|
mysql_engine='InnoDB',
|
|
mysql_charset='utf8mb4',
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_table('vision_reference_images')
|
|
```
|
|
|
|
- [ ] **Step 4: Write the model**
|
|
|
|
In `src/backend/models/orm/vision.py`, sotto `VisionResult`:
|
|
|
|
```python
|
|
class VisionReferenceImage(Base):
|
|
"""An image kept so a graph can be composed, tried and re-tried without a
|
|
camera - which is exactly what VisionSuite's source abstraction is for."""
|
|
|
|
__tablename__ = "vision_reference_images"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
task_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
|
)
|
|
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
station_id: Mapped[Optional[int]] = mapped_column(
|
|
Integer, ForeignKey("stations.id"), nullable=True
|
|
)
|
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
acquired_at: Mapped[datetime] = mapped_column(
|
|
DateTime, nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
|
```
|
|
|
|
Esportala da `src/backend/models/orm/__init__.py`.
|
|
|
|
- [ ] **Step 5: Extend the service**
|
|
|
|
In `src/backend/services/vision_service.py`:
|
|
|
|
```python
|
|
async def save_reference_image(
|
|
db: AsyncSession,
|
|
task_id: int,
|
|
image_bytes: bytes,
|
|
*,
|
|
note: str | None = None,
|
|
station_id: int | None = None,
|
|
device_code: str | None = None,
|
|
calibration_snapshot: dict | None = None,
|
|
) -> VisionReferenceImage:
|
|
"""Keep an image so the graph can be composed and re-tried without a camera."""
|
|
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
|
|
# rest of the codebase uses the former (see production_export_service).
|
|
folder = settings.upload_path / "vision" / "reference" / str(task_id)
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
name = f"{uuid4().hex}.png"
|
|
(folder / name).write_bytes(image_bytes)
|
|
|
|
reference = VisionReferenceImage(
|
|
task_id=task_id,
|
|
path=str(Path("vision") / "reference" / str(task_id) / name),
|
|
note=note,
|
|
station_id=station_id,
|
|
device_code=device_code,
|
|
calibration_snapshot=calibration_snapshot,
|
|
)
|
|
db.add(reference)
|
|
await db.flush()
|
|
await db.refresh(reference)
|
|
return reference
|
|
|
|
|
|
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
|
|
"""Run the graph and save nothing. Composing is not measuring."""
|
|
if not task.vision_json:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="this task carries no vision graph",
|
|
)
|
|
return await _call_worker(image_bytes, task.vision_json)
|
|
```
|
|
|
|
Aggiungi in testa al modulo gli import che servono: `from pathlib import Path`, `from uuid import uuid4`, e `VisionReferenceImage` accanto a `VisionResult`.
|
|
|
|
- [ ] **Step 6: Extend the router**
|
|
|
|
In `src/backend/api/routers/vision.py`, due endpoint che riusano `_task_or_404`, già definita nel Task 4.
|
|
|
|
```python
|
|
@router.post("/reference-images")
|
|
async def add_reference_image(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
note: str | None = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
task = await _task_or_404(db, task_id)
|
|
reference = await vision_service.save_reference_image(
|
|
db, task.id, await image.read(), note=note,
|
|
)
|
|
await db.commit()
|
|
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
|
|
|
|
|
@router.post("/preview")
|
|
async def preview(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
task = await _task_or_404(db, task_id)
|
|
return await vision_service.preview(db, task, await image.read())
|
|
```
|
|
|
|
- [ ] **Step 7: Run the tests to verify they pass**
|
|
|
|
Run: `uv run pytest src/backend/tests/test_vision_reference.py -v`
|
|
Expected: PASS entrambe.
|
|
|
|
- [ ] **Step 8: Run everything**
|
|
|
|
Run: `uv run pytest -q`
|
|
Expected: la suite intera — backend, frontend, runner, worker — senza fallimenti. Annota il totale nel messaggio di commit: un conteggio di test vale solo se si dichiara l'ambiente in cui è stato fatto, ed è una regola che VisionSuite si è già data.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add src/backend/migrations/versions/013_reference_images.py \
|
|
src/backend/models/orm/vision.py src/backend/models/orm/__init__.py \
|
|
src/backend/services/vision_service.py \
|
|
src/backend/api/routers/vision.py \
|
|
src/backend/tests/test_vision_reference.py
|
|
git commit -m "feat(vision): immagini di riferimento, per comporre senza camera"
|
|
```
|
|
|
|
---
|
|
|
|
## Dopo questo piano
|
|
|
|
Il sistema esegue un grafo di visione e produce misure vere, provabile interamente via API e senza hardware. Restano, in ordine:
|
|
|
|
- **Piano 1b — Authoring nel Maker:** scelta dell'immagine di riferimento, aggancio delle uscite alle quote, pulsante «prova».
|
|
- **Piano 2 — Agente di stazione e registro device:** `station_devices`, WebSocket di registrazione, fotogrammi dal vivo.
|
|
- **Piano 3 — Esecuzione sulla stazione:** policy per stazione, ripiego alla caduta del server, rifiuto su versione discorde del motore.
|
|
- **Piano 4 — DXF compare:** il secondo tipo di task, con quote sintetiche e rapporto allegato.
|
|
|
|
Prima del piano 2 vanno chiuse le tre verifiche a monte elencate nella spec: lo stato reale di VisionSuite rispetto al suo README, il test di calibrazione dato per rosso, e il peso effettivo del sottoinsieme di pacchetti che il worker installa.
|