Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3899d5ecd9 | |||
| f986c172c2 | |||
| c1c7ae8d0e | |||
| cf49695140 | |||
| c9e01a223b | |||
| 3690c35c47 | |||
| 3856ee335a | |||
| e640c4a36e | |||
| accf416cc9 | |||
| 84a4b5f34d | |||
| 9238f8926b |
@@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true
|
|||||||
VISION_RUN_TIMEOUT_S=100
|
VISION_RUN_TIMEOUT_S=100
|
||||||
# Quante esecuzioni di visione in parallelo accetta il worker.
|
# Quante esecuzioni di visione in parallelo accetta il worker.
|
||||||
VISION_MAX_CONCURRENCY=2
|
VISION_MAX_CONCURRENCY=2
|
||||||
|
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
|
||||||
|
VISION_RECIPES_ROOT=/app/uploads
|
||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
||||||
|
|||||||
@@ -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`.
|
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:
|
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
|
```bash
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ services:
|
|||||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
container_name: tmflow-vision
|
container_name: tmflow-vision
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
|
||||||
|
VISION_RECIPES_ROOT: /app/uploads
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads:ro
|
- upload_data:/app/uploads:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ vision = [
|
|||||||
"vs-task; python_version>='3.13'",
|
"vs-task; python_version>='3.13'",
|
||||||
"vs-measure; python_version>='3.13'",
|
"vs-measure; python_version>='3.13'",
|
||||||
"vs-pm2d; 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'",
|
"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-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||||
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", 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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
@@ -377,6 +377,47 @@ def api_upload_file():
|
|||||||
return jsonify(resp), 201
|
return jsonify(resp), 201
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# VISION RECIPE PROXY (#46)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_get_vision_recipe(task_id: int):
|
||||||
|
"""Proxy: ricetta visione corrente del task (cartella, file, uscite)."""
|
||||||
|
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe")
|
||||||
|
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_upload_vision_recipe(task_id: int):
|
||||||
|
"""Proxy: carica ricetta .json + file allegati (multipart forward)."""
|
||||||
|
recipe = request.files.get("recipe")
|
||||||
|
if recipe is None or recipe.filename == "":
|
||||||
|
return jsonify({"error": True, "detail": _("Nessun file ricetta caricato")}), 400
|
||||||
|
|
||||||
|
# Ricetta + 0..n allegati (DXF, modelli .npz, calibrazione), stesso nome campo `files`
|
||||||
|
files = [("recipe", (recipe.filename, recipe.stream, recipe.content_type))]
|
||||||
|
for f in request.files.getlist("files"):
|
||||||
|
if f.filename:
|
||||||
|
files.append(("files", (f.filename, f.stream, f.content_type)))
|
||||||
|
|
||||||
|
resp = api_client.post(f"/api/vision/tasks/{task_id}/recipe", data={}, files=files)
|
||||||
|
|
||||||
|
# Il 422 (errori di validazione) arriva al browser com'è, con `errors`
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, abort, flash, jsonify, redirect, render_template,
|
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
@@ -820,3 +822,67 @@ def api_close_production(run_id: int):
|
|||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||||
return proxy_file(file_path)
|
return proxy_file(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route: vision execute (#46) - the browser sends an image, we relay it to
|
||||||
|
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@measure_bp.route("/api/vision/execute", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_execute():
|
||||||
|
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
|
||||||
|
image = request.files.get("image")
|
||||||
|
task_id = request.form.get("task_id")
|
||||||
|
if image is None or not task_id:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Same context fields as the manual flow; empty ones are left out so the
|
||||||
|
# backend sees them as absent rather than as "".
|
||||||
|
form = {"task_id": task_id}
|
||||||
|
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
|
||||||
|
val = request.form.get(key)
|
||||||
|
if val:
|
||||||
|
form[key] = val
|
||||||
|
form.setdefault("lot_number", session.get("lot_number", ""))
|
||||||
|
form.setdefault("serial_number", session.get("serial_number", ""))
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
data=form,
|
||||||
|
files={"image": (
|
||||||
|
image.filename or "capture.jpg",
|
||||||
|
image.read(),
|
||||||
|
image.mimetype or "application/octet-stream",
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
status_code = resp.get("status_code", 500)
|
||||||
|
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
return jsonify(body), status_code if status_code >= 400 else 500
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_result_image(result_id: int):
|
||||||
|
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
|
||||||
|
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{base_url}/api/vision/results/{result_id}/image",
|
||||||
|
headers={"X-API-Key": session.get("api_key", "")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return Response(resp.text, status=resp.status_code)
|
||||||
|
return Response(
|
||||||
|
resp.content,
|
||||||
|
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
|
||||||
|
*
|
||||||
|
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
|
||||||
|
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
|
||||||
|
* the image. `acquire(blob)` is the single entry point on purpose: the agent
|
||||||
|
* camera (#3) will call it with its own blob and reuse everything below.
|
||||||
|
*
|
||||||
|
* Config comes from window.__visionCfg (set by the template):
|
||||||
|
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
|
||||||
|
* taskType, subtasks, labels.
|
||||||
|
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||||
|
* which the template binds to the parent task component.
|
||||||
|
*/
|
||||||
|
function visionExecute() {
|
||||||
|
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||||
|
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy: false,
|
||||||
|
error: '',
|
||||||
|
result: null, // last execute response
|
||||||
|
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||||
|
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||||
|
|
||||||
|
// Entry point shared with the future agent camera.
|
||||||
|
async acquire(blob) {
|
||||||
|
if (!blob || this.busy) return;
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
this.busy = true;
|
||||||
|
this.error = '';
|
||||||
|
this.result = null;
|
||||||
|
// Show the picked image at once: the overlay is drawn on top when the result arrives.
|
||||||
|
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||||
|
this.imageSrc = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const c = this.ctx() || {};
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('task_id', cfg.taskId);
|
||||||
|
fd.append('task_type', cfg.taskType || '');
|
||||||
|
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||||
|
fd.append('lot_number', c.lot || '');
|
||||||
|
fd.append('serial_number', c.serial || '');
|
||||||
|
if (c.runId) fd.append('production_run_id', c.runId);
|
||||||
|
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const resp = await fetch(cfg.executeUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || body.error) {
|
||||||
|
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.result = body;
|
||||||
|
// Draw once Alpine has rendered the canvas.
|
||||||
|
this.$nextTick(() => this.drawOverlay());
|
||||||
|
} catch (e) {
|
||||||
|
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Input change: take the file and allow picking the same one again.
|
||||||
|
onPick(ev) {
|
||||||
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
ev.target.value = '';
|
||||||
|
if (f) this.acquire(f);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Image shown: the saved one from the server when available, else the local one.
|
||||||
|
get shownImage() {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
|
||||||
|
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
|
||||||
|
}
|
||||||
|
return this.imageSrc;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quote rows: server measurements joined with the subtask names.
|
||||||
|
get rows() {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
const subs = cfg.subtasks || [];
|
||||||
|
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||||
|
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||||
|
return {
|
||||||
|
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
||||||
|
value: m.value,
|
||||||
|
unit: st.unit || 'mm',
|
||||||
|
status: m.pass_fail,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
statusClass(s) {
|
||||||
|
// Same palette as the measurement markers of the manual flow.
|
||||||
|
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||||
|
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
|
||||||
|
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||||
|
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
||||||
|
// are used as they are and CSS scales the whole thing to the displayed size.
|
||||||
|
drawOverlay() {
|
||||||
|
const img = this.$refs.img, cv = this.$refs.overlay;
|
||||||
|
if (!img || !cv || !img.naturalWidth) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const g = cv.getContext('2d');
|
||||||
|
g.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
g.lineWidth = Math.max(2, cv.width / 400);
|
||||||
|
g.lineJoin = 'round';
|
||||||
|
for (const item of ((this.result && this.result.overlay) || [])) {
|
||||||
|
const rows = item.rows || [], cols = item.cols || [];
|
||||||
|
if (!rows.length || rows.length !== cols.length) continue;
|
||||||
|
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(cols[0], rows[0]);
|
||||||
|
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Ricetta visione del Maker (#46): componente Alpine per i task camera_measure / dxf_compare.
|
||||||
|
// Carica la ricetta VisionSuite v2 + i file allegati verso /maker/api/vision/tasks/<id>/recipe,
|
||||||
|
// mostra cartella, file e uscite numeriche; notifica le uscite al task editor con un evento.
|
||||||
|
// Uso: x-data="visionRecipe(task.id, csrfToken, msgs)". Dipende da Alpine.js.
|
||||||
|
function visionRecipe(taskId, csrf, msgs) {
|
||||||
|
return {
|
||||||
|
taskId: taskId,
|
||||||
|
recipeDir: null,
|
||||||
|
files: [],
|
||||||
|
outputs: [],
|
||||||
|
errors: [], // errori 422 elencati in chiaro
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
recipeFile: null,
|
||||||
|
attachments: [],
|
||||||
|
msgs: msgs || {},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// All'apertura: ricetta corrente (GET); il 404/errore non blocca l'editor
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`);
|
||||||
|
if (resp.ok) this._apply(await resp.json());
|
||||||
|
} catch (e) { console.error('visionRecipe init', e); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Aggiorna lo stato e avvisa il task editor (select vision_output delle quote)
|
||||||
|
_apply(data) {
|
||||||
|
this.recipeDir = data.recipe_dir || null;
|
||||||
|
this.files = data.files || [];
|
||||||
|
this.outputs = data.outputs || [];
|
||||||
|
window.dispatchEvent(new CustomEvent('vision-outputs', {
|
||||||
|
detail: { taskId: this.taskId, outputs: this.outputs }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Il 422 porta `errors` (lista) oppure `detail` (stringa o lista di voci)
|
||||||
|
_errorsFrom(data) {
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) return data.errors.map(String);
|
||||||
|
const d = data.detail;
|
||||||
|
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x))));
|
||||||
|
if (d && typeof d === 'object') {
|
||||||
|
if (Array.isArray(d.errors)) return d.errors.map(String);
|
||||||
|
return [JSON.stringify(d)];
|
||||||
|
}
|
||||||
|
return [d || this.msgs.genericError || 'Error'];
|
||||||
|
},
|
||||||
|
|
||||||
|
async upload() {
|
||||||
|
if (!this.recipeFile) return;
|
||||||
|
this.busy = true;
|
||||||
|
this.errors = [];
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('recipe', this.recipeFile);
|
||||||
|
for (const f of this.attachments) fd.append('files', f);
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': typeof csrf === 'function' ? csrf() : csrf },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
this.errors = this._errorsFrom(data);
|
||||||
|
} else {
|
||||||
|
this._apply(data);
|
||||||
|
this.recipeFile = null;
|
||||||
|
this.attachments = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.errors = [this.msgs.genericError || String(e)];
|
||||||
|
}
|
||||||
|
this.busy = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onRecipe(ev) { this.recipeFile = ev.target.files[0] || null; },
|
||||||
|
onAttachments(ev) { this.attachments = Array.from(ev.target.files || []); }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -277,6 +277,8 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||||
@@ -460,6 +462,55 @@
|
|||||||
|
|
||||||
<div class="tmf-card-body space-y-4">
|
<div class="tmf-card-body space-y-4">
|
||||||
|
|
||||||
|
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||||
|
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||||
|
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||||
|
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
|
||||||
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||||
|
{{ _('Ricetta visione') }}
|
||||||
|
</p>
|
||||||
|
<!-- Stato corrente: cartella, file, uscite -->
|
||||||
|
<template x-if="recipeDir">
|
||||||
|
<div class="text-xs space-y-1 mb-3">
|
||||||
|
<p><span class="text-[var(--text-secondary)]">{{ _('Cartella') }}:</span>
|
||||||
|
<span class="font-mono" x-text="recipeDir"></span></p>
|
||||||
|
<p x-show="files.length"><span class="text-[var(--text-secondary)]">{{ _('File') }}:</span>
|
||||||
|
<span class="font-mono" x-text="files.join(', ')"></span></p>
|
||||||
|
<p x-show="outputs.length"><span class="text-[var(--text-secondary)]">{{ _('Uscite numeriche') }}:</span></p>
|
||||||
|
<ul class="list-disc pl-5" x-show="outputs.length">
|
||||||
|
<template x-for="o in outputs" :key="o.key">
|
||||||
|
<li><span class="font-mono" x-text="o.key"></span>
|
||||||
|
<span class="text-[var(--text-muted)]" x-text="(o.quantity || o.unit) ? '(' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : ''"></span></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="!recipeDir && !loading" class="text-xs text-[var(--text-muted)] mb-3">
|
||||||
|
{{ _('Nessuna ricetta caricata') }}
|
||||||
|
</p>
|
||||||
|
<!-- Caricamento: ricetta .json + allegati -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Ricetta (.json)') }}</label>
|
||||||
|
<input type="file" accept=".json,application/json" class="tmf-input text-xs"
|
||||||
|
@change="onRecipe($event)">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('File allegati (DXF, modelli, calibrazione)') }}</label>
|
||||||
|
<input type="file" multiple class="tmf-input text-xs" @change="onAttachments($event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<button type="button" class="btn btn-primary text-xs" @click="upload()"
|
||||||
|
:disabled="!recipeFile || busy">{{ _('Carica ricetta') }}</button>
|
||||||
|
</div>
|
||||||
|
<!-- Errori 422 in chiaro -->
|
||||||
|
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
||||||
|
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ========== Task File Upload / Preview ========== -->
|
<!-- ========== Task File Upload / Preview ========== -->
|
||||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||||
@@ -543,6 +594,8 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -699,6 +752,18 @@
|
|||||||
class="tmf-input"
|
class="tmf-input"
|
||||||
placeholder="{{ _('Es. Diametro foro principale') }}">
|
placeholder="{{ _('Es. Diametro foro principale') }}">
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Uscita visione (#46): solo task camera_measure / dxf_compare -->
|
||||||
|
<div class="col-span-2 sm:col-span-3 lg:col-span-2"
|
||||||
|
x-show="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||||
|
<label class="tmf-label text-xs">{{ _('Uscita visione') }}</label>
|
||||||
|
<select x-model="newSubtask.vision_output" class="tmf-input">
|
||||||
|
<option value="">{{ _('Nessuna') }}</option>
|
||||||
|
<template x-for="o in (visionOutputs[task.id] || [])" :key="o.key">
|
||||||
|
<option :value="o.key" :selected="o.key === newSubtask.vision_output"
|
||||||
|
x-text="o.key + ((o.quantity || o.unit) ? ' (' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : '')"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<!-- Measurement Type -->
|
<!-- Measurement Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||||
@@ -1093,6 +1158,7 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
@@ -1129,8 +1195,11 @@ function taskEditor() {
|
|||||||
addingSubtaskForTask: null,
|
addingSubtaskForTask: null,
|
||||||
newSubtask: {
|
newSubtask: {
|
||||||
marker_number: 1, description: '', measurement_type: '',
|
marker_number: 1, description: '', measurement_type: '',
|
||||||
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
|
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm',
|
||||||
|
vision_output: ''
|
||||||
},
|
},
|
||||||
|
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
||||||
|
visionOutputs: {},
|
||||||
|
|
||||||
// ---- Delete modals ----
|
// ---- Delete modals ----
|
||||||
deleteTaskModal: false,
|
deleteTaskModal: false,
|
||||||
@@ -1153,6 +1222,10 @@ function taskEditor() {
|
|||||||
// Init
|
// Init
|
||||||
// ============================================================
|
// ============================================================
|
||||||
init() {
|
init() {
|
||||||
|
// Uscite della ricetta visione: evento dal componente visionRecipe (#46)
|
||||||
|
window.addEventListener('vision-outputs', (ev) => {
|
||||||
|
this.visionOutputs = { ...this.visionOutputs, [ev.detail.taskId]: ev.detail.outputs || [] };
|
||||||
|
});
|
||||||
// Sort tasks by order_index on load
|
// Sort tasks by order_index on load
|
||||||
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||||
|
|
||||||
@@ -1610,7 +1683,8 @@ function taskEditor() {
|
|||||||
uwl: null,
|
uwl: null,
|
||||||
lwl: null,
|
lwl: null,
|
||||||
ltl: null,
|
ltl: null,
|
||||||
unit: 'mm'
|
unit: 'mm',
|
||||||
|
vision_output: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1634,7 +1708,8 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
ltl: this.newSubtask.ltl,
|
||||||
unit: this.newSubtask.unit || 'mm'
|
unit: this.newSubtask.unit || 'mm',
|
||||||
|
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1687,7 +1762,8 @@ function taskEditor() {
|
|||||||
uwl: subtask.uwl,
|
uwl: subtask.uwl,
|
||||||
lwl: subtask.lwl,
|
lwl: subtask.lwl,
|
||||||
ltl: subtask.ltl,
|
ltl: subtask.ltl,
|
||||||
unit: subtask.unit || 'mm'
|
unit: subtask.unit || 'mm',
|
||||||
|
vision_output: subtask.vision_output || ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1711,7 +1787,8 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
ltl: this.newSubtask.ltl,
|
||||||
unit: this.newSubtask.unit || 'mm'
|
unit: this.newSubtask.unit || 'mm',
|
||||||
|
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -191,7 +191,8 @@
|
|||||||
{# ================================================================
|
{# ================================================================
|
||||||
MAIN — 3-column layout
|
MAIN — 3-column layout
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
<div class="flex-1 flex overflow-hidden">
|
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
|
||||||
|
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
|
||||||
|
|
||||||
{# ──────────────────────────────────────────────
|
{# ──────────────────────────────────────────────
|
||||||
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
||||||
@@ -451,6 +452,63 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
|
||||||
|
quotes and the overlay. acquire(blob) is the entry point the agent camera
|
||||||
|
(#3) will reuse.
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="isVisionTask" x-cloak
|
||||||
|
x-data="visionExecute()"
|
||||||
|
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
||||||
|
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||||
|
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||||
|
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||||
|
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||||
|
@change="onPick($event)" :disabled="busy">
|
||||||
|
</label>
|
||||||
|
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||||
|
|
||||||
|
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||||
|
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
||||||
|
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||||
|
</div>
|
||||||
|
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||||
|
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
|
||||||
|
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||||
|
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="w-full text-sm" x-show="rows.length">
|
||||||
|
<template x-for="r in rows" :key="r.label">
|
||||||
|
<tr>
|
||||||
|
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||||
|
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
|
||||||
|
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div x-show="result && result.failures && result.failures.length"
|
||||||
|
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
|
||||||
|
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
|
||||||
|
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
|
||||||
|
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
MEASUREMENT TIMER BANNER
|
MEASUREMENT TIMER BANNER
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -912,6 +970,18 @@
|
|||||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||||
|
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
|
||||||
|
<script>
|
||||||
|
window.__visionCfg = {
|
||||||
|
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||||
|
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||||
|
taskId: {{ task.id|tojson }},
|
||||||
|
taskType: {{ (task.task_type or '')|tojson }},
|
||||||
|
subtasks: window.__taskSubtasks,
|
||||||
|
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
||||||
measurement lives - are shared with the other screens rather than restated. #}
|
measurement lives - are shared with the other screens rather than restated. #}
|
||||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||||
@@ -1776,6 +1846,12 @@ function taskExecute() {
|
|||||||
return declared === 'measure' || declared === 'camera_measure';
|
return declared === 'measure' || declared === 'camera_measure';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* #46: image-based tasks run a VisionSuite recipe on the server. */
|
||||||
|
get isVisionTask() {
|
||||||
|
const t = this.task.task_type;
|
||||||
|
return t === 'camera_measure' || t === 'dxf_compare';
|
||||||
|
},
|
||||||
|
|
||||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||||
loud rather than silently rendering an empty screen. */
|
loud rather than silently rendering an empty screen. */
|
||||||
get isMeasureTaskWithoutQuotes() {
|
get isMeasureTaskWithoutQuotes() {
|
||||||
|
|||||||
@@ -161,3 +161,82 @@ class TestVersionConflictProxy:
|
|||||||
|
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
|
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisionRecipeProxy:
|
||||||
|
"""Proxy ricetta visione (#46): /maker/api/vision/tasks/<id>/recipe."""
|
||||||
|
|
||||||
|
def test_upload_forwards_recipe_and_files(self, logged_in_client, mock_api_client):
|
||||||
|
import io
|
||||||
|
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"task_id": 7, "recipe_dir": "vision/recipes/7/abc", "files": ["a.dxf"],
|
||||||
|
"outputs": [{"key": "n1.points_in_pct", "quantity": None, "unit": None}],
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/maker/api/vision/tasks/7/recipe",
|
||||||
|
data={
|
||||||
|
"recipe": (io.BytesIO(b"{}"), "r.json"),
|
||||||
|
"files": [(io.BytesIO(b"dxf"), "a.dxf"), (io.BytesIO(b"npz"), "m.npz")],
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["outputs"][0]["key"] == "n1.points_in_pct"
|
||||||
|
endpoint = mock_api_client.post.call_args.args[0]
|
||||||
|
sent = mock_api_client.post.call_args.kwargs["files"]
|
||||||
|
assert endpoint == "/api/vision/tasks/7/recipe"
|
||||||
|
# un campo `recipe` e un campo `files` per ogni allegato
|
||||||
|
assert [name for name, _f in sent] == ["recipe", "files", "files"]
|
||||||
|
|
||||||
|
def test_upload_without_recipe_is_400(self, logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/maker/api/vision/tasks/7/recipe", data={}, content_type="multipart/form-data"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_upload_proxies_422(self, logged_in_client, mock_api_client):
|
||||||
|
import io
|
||||||
|
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "file mancante: a.dxf",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/maker/api/vision/tasks/7/recipe",
|
||||||
|
data={"recipe": (io.BytesIO(b"{}"), "r.json")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "a.dxf" in resp.get_json()["detail"]
|
||||||
|
|
||||||
|
def test_get_recipe_proxies(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {
|
||||||
|
"task_id": 7, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
|
||||||
|
}
|
||||||
|
resp = logged_in_client.get("/maker/api/vision/tasks/7/recipe")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_api_client.get.assert_called_once_with("/api/vision/tasks/7/recipe")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskEditorVisionUi:
|
||||||
|
"""task_editor.html espone i tipi visione e la select vision_output (#46)."""
|
||||||
|
|
||||||
|
def test_editor_has_vision_types_and_section(self, logged_in_client, mock_api_client):
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
|
||||||
|
"/api/recipes/3/tasks": [{
|
||||||
|
"id": 11, "title": "T", "task_type": "dxf_compare", "subtasks": [],
|
||||||
|
"file_path": None, "order_index": 1,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||||
|
assert 'value="camera_measure"' in html
|
||||||
|
assert 'value="dxf_compare"' in html
|
||||||
|
assert "visionRecipe(" in html
|
||||||
|
assert "vision_output" in html
|
||||||
|
assert "js/vision_recipe.js" in html
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
|
||||||
|
|
||||||
|
The backend of contract B is not merged yet: api_client is mocked.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
TASK = {
|
||||||
|
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
|
||||||
|
"title": "Foto", "directive": None, "description": None, "file_path": None,
|
||||||
|
"file_type": None, "annotations_json": None, "subtasks": [],
|
||||||
|
}
|
||||||
|
RECIPE = {
|
||||||
|
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||||
|
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||||
|
"current_version": {"id": 5, "version_number": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
"""camera_measure screen offers the file/camera input and the acquire entry point."""
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/tasks/12": TASK,
|
||||||
|
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
})
|
||||||
|
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||||
|
assert 'capture="environment"' in html
|
||||||
|
assert 'accept="image/*"' in html
|
||||||
|
assert "visionExecute()" in html
|
||||||
|
assert "/measure/api/vision/execute" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
|
||||||
|
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/vision/execute",
|
||||||
|
data={
|
||||||
|
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
|
||||||
|
"production_run_id": "4",
|
||||||
|
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["vision_result_id"] == 7
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/execute"
|
||||||
|
assert kwargs["data"]["task_id"] == "12"
|
||||||
|
assert kwargs["data"]["lot_number"] == "L1"
|
||||||
|
assert kwargs["data"]["production_run_id"] == "4"
|
||||||
|
name, content, mime = kwargs["files"]["image"]
|
||||||
|
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/vision/execute",
|
||||||
|
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_result_image_proxy(logged_in_client):
|
||||||
|
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
|
||||||
|
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
|
||||||
|
resp = logged_in_client.get("/measure/api/vision/results/7/image")
|
||||||
|
assert resp.status_code == 200 and resp.data == b"IMG"
|
||||||
|
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
|
||||||
|
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"
|
||||||
+117
-22
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -18,6 +19,9 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from visionsuite.core.image import Image
|
from visionsuite.core.image import Image
|
||||||
from visionsuite.task import Task
|
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"
|
_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
|
# 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.
|
# number the caller must not measure a part by. Value is the reason.
|
||||||
invalid_outputs: dict[str, str] = field(default_factory=dict)
|
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)
|
@lru_cache(maxsize=1)
|
||||||
@@ -97,12 +105,46 @@ def engine_version() -> str:
|
|||||||
return _engine_version_from_git()
|
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(
|
def run_graph(
|
||||||
image: Any,
|
image: Any,
|
||||||
graph: dict,
|
graph: dict,
|
||||||
calibration: dict | None = None,
|
calibration: dict | None = None,
|
||||||
|
recipe_dir: Any = None,
|
||||||
) -> RunOutcome:
|
) -> 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
|
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
|
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
|
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
|
||||||
actually read via `.pixels`; an `Image` passed in is used as-is.
|
actually read via `.pixels`; an `Image` passed in is used as-is.
|
||||||
|
|
||||||
Two phases, on purpose: building the task from `graph` can fail on
|
`recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models,
|
||||||
nothing but the caller's input (a bad schema version, a tool with no
|
...); without it an empty temporary folder is used, so a recipe that needs
|
||||||
"kind", a graph that is not a dict at all) - that is `GraphBuildError`,
|
a file fails as a malformed graph instead of reading from the CWD.
|
||||||
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
|
Two phases, on purpose: building the session can fail on nothing but the
|
||||||
the worker must not call it one.
|
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:
|
load_tools() # #46: registra gli strumenti degli entry point di vs-tools
|
||||||
task = Task.from_dict(graph)
|
with tempfile.TemporaryDirectory() as empty_dir:
|
||||||
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
session = _open_session(graph, recipe_dir, calibration, empty_dir)
|
||||||
raise GraphBuildError(
|
wrapped_image = (
|
||||||
f"malformed graph: {type(exc).__name__}: {exc}"
|
image if isinstance(image, Image) else Image(pixels=np.asarray(image))
|
||||||
) from exc
|
)
|
||||||
|
started = time.monotonic()
|
||||||
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
|
report = session.run(wrapped_image)
|
||||||
context: dict[str, Any] = {"image": wrapped_image}
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
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] = {}
|
outputs: dict[str, float] = {}
|
||||||
failures: list[ToolFailure] = []
|
failures: list[ToolFailure] = []
|
||||||
invalid_outputs: dict[str, str] = {}
|
invalid_outputs: dict[str, str] = {}
|
||||||
|
overlay: list[dict] = []
|
||||||
for result in report:
|
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:
|
if not result.ok:
|
||||||
failures.append(ToolFailure(
|
failures.append(ToolFailure(
|
||||||
tool_id=result.tool_id,
|
tool_id=result.tool_id,
|
||||||
@@ -164,4 +208,55 @@ def run_graph(
|
|||||||
engine_version=engine_version(),
|
engine_version=engine_version(),
|
||||||
duration_ms=elapsed_ms,
|
duration_ms=elapsed_ms,
|
||||||
invalid_outputs=invalid_outputs,
|
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
|
||||||
|
|||||||
+146
-13
@@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
|||||||
def a_circle_graph() -> dict:
|
def a_circle_graph() -> dict:
|
||||||
"""A two-tool graph, built with vs-task's own API."""
|
"""A two-tool graph, built with vs-task's own API."""
|
||||||
from visionsuite.task import Task
|
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")
|
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.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||||
found.scan_size = 30.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")
|
quota.link("circle", found, "circle")
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others():
|
|||||||
graph = a_circle_graph()
|
graph = a_circle_graph()
|
||||||
graph["tools"].append(
|
graph["tools"].append(
|
||||||
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
{"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)
|
outcome = run_graph(a_disc(), graph)
|
||||||
@@ -81,14 +83,21 @@ class _FakeResult:
|
|||||||
self.ok = ok
|
self.ok = ok
|
||||||
self.outputs = outputs or {}
|
self.outputs = outputs or {}
|
||||||
self.error = error
|
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):
|
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
|
||||||
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
|
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
|
||||||
revisione) - a NaN must not become a quote's value (#16)."""
|
revisione) - a NaN must not become a quote's value (#16)."""
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
|
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
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 test_an_infinite_output_is_dropped_not_measured(monkeypatch):
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
|
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
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):
|
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."""
|
"""bool is a subclass of int - a flag output must not become a quote."""
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
|
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
outcome = run_graph(a_disc(), a_circle_graph())
|
||||||
|
|
||||||
@@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
|
|||||||
engine_version()
|
engine_version()
|
||||||
finally:
|
finally:
|
||||||
runner._engine_version_from_git.cache_clear()
|
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 == []
|
||||||
|
|||||||
+109
-17
@@ -10,12 +10,20 @@ import asyncio
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||||
from PIL import Image, UnidentifiedImageError
|
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")
|
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
||||||
|
|
||||||
@@ -31,6 +39,69 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2"))
|
|||||||
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
|
_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()
|
||||||
|
# #46: lettera di unita (C:/x) rifiutata su ogni OS; UNC coperto da startswith("\\")
|
||||||
|
or re.match(r"^[A-Za-z]:", rel)
|
||||||
|
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):
|
class _ImageDecodeError(Exception):
|
||||||
"""The upload is not a decodable image - raised inside the worker thread
|
"""The upload is not a decodable image - raised inside the worker thread
|
||||||
so it survives the trip through `asyncio.to_thread`."""
|
so it survives the trip through `asyncio.to_thread`."""
|
||||||
@@ -44,7 +115,12 @@ async def health() -> dict:
|
|||||||
return {"status": "ok", "engine_version": engine_version()}
|
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
|
"""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 -
|
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
|
||||||
and therefore `/health` - stays responsive while it executes. Once this
|
and therefore `/health` - stays responsive while it executes. Once this
|
||||||
@@ -56,27 +132,19 @@ def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
|
|||||||
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
|
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
|
||||||
except (UnidentifiedImageError, OSError) as exc:
|
except (UnidentifiedImageError, OSError) as exc:
|
||||||
raise _ImageDecodeError(str(exc)) from 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")
|
@app.post("/run")
|
||||||
async def run(
|
async def run(
|
||||||
image: UploadFile = File(...),
|
image: UploadFile = File(...),
|
||||||
graph: str = Form(...),
|
graph: str = Form(...),
|
||||||
|
recipe_dir: str | None = Form(None),
|
||||||
|
calibration: str | None = Form(None),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
try:
|
parsed_graph = _parse_graph(graph)
|
||||||
parsed_graph = json.loads(graph)
|
resolved_dir = _resolve_recipe_dir(recipe_dir)
|
||||||
except json.JSONDecodeError as exc:
|
parsed_calibration = _parse_calibration(calibration)
|
||||||
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__}",
|
|
||||||
)
|
|
||||||
|
|
||||||
content = await image.read()
|
content = await image.read()
|
||||||
|
|
||||||
@@ -89,7 +157,13 @@ async def run(
|
|||||||
async with _run_slots:
|
async with _run_slots:
|
||||||
try:
|
try:
|
||||||
outcome = await asyncio.wait_for(
|
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,
|
timeout=_RUN_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
@@ -130,4 +204,22 @@ async def run(
|
|||||||
"engine_version": outcome.engine_version,
|
"engine_version": outcome.engine_version,
|
||||||
"duration_ms": outcome.duration_ms,
|
"duration_ms": outcome.duration_ms,
|
||||||
"invalid_outputs": outcome.invalid_outputs,
|
"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}
|
||||||
|
|||||||
@@ -45,13 +45,15 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
|
|||||||
|
|
||||||
def a_circle_graph() -> dict:
|
def a_circle_graph() -> dict:
|
||||||
from visionsuite.task import Task
|
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")
|
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.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||||
found.scan_size = 30.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")
|
quota.link("circle", found, "circle")
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ async def test_a_graph_that_is_not_an_object_is_refused():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_tool_without_a_kind_is_refused_not_crashed():
|
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)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||||
response = await client.post(
|
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 response.status_code == 422
|
||||||
assert "malformed graph" in response.json()["detail"].lower()
|
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)
|
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
|
time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread
|
||||||
return runner.RunOutcome(engine_version="a" * 40)
|
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_slots", asyncio.Semaphore(1))
|
||||||
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0)
|
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)
|
time.sleep(0.3)
|
||||||
return runner.RunOutcome(engine_version="a" * 40)
|
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 second_response.status_code == 503
|
||||||
assert first_response.status_code == 200
|
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"] == []
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.1"
|
version = "0.136.1"
|
||||||
@@ -1079,8 +1118,8 @@ name = "numba"
|
|||||||
version = "0.67.0"
|
version = "0.67.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "llvmlite", marker = "python_full_version >= '3.13'" },
|
{ name = "llvmlite" },
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
{ 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" }
|
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 = [
|
wheels = [
|
||||||
@@ -1185,7 +1224,7 @@ name = "opencv-python"
|
|||||||
version = "5.0.0.93"
|
version = "5.0.0.93"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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" }
|
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 = [
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pypdfium2"
|
name = "pypdfium2"
|
||||||
version = "5.8.0"
|
version = "5.8.0"
|
||||||
@@ -1751,7 +1799,7 @@ name = "scipy"
|
|||||||
version = "1.18.0"
|
version = "1.18.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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" }
|
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 = [
|
wheels = [
|
||||||
@@ -1980,6 +2028,7 @@ vision = [
|
|||||||
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
||||||
{ name = "vs-pm2d", 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-task", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
|
||||||
]
|
]
|
||||||
vision-worker = [
|
vision-worker = [
|
||||||
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
|
{ 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-measure", marker = "python_full_version >= '3.13'" },
|
||||||
{ name = "vs-pm2d", 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-task", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[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-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-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-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" },
|
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
||||||
@@ -2157,8 +2208,14 @@ name = "visionsuite"
|
|||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
source = { editable = "vendor/visionsuite/packages/vs-core" }
|
source = { editable = "vendor/visionsuite/packages/vs-core" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
{ name = "numpy" },
|
||||||
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
|
{ name = "opencv-python" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dxf = [
|
||||||
|
{ name = "ezdxf" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -2168,42 +2225,70 @@ requires-dist = [
|
|||||||
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
|
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
|
||||||
{ name = "numpy", specifier = ">=1.24" },
|
{ name = "numpy", specifier = ">=1.24" },
|
||||||
{ name = "opencv-python", specifier = ">=4.8" },
|
{ name = "opencv-python", specifier = ">=4.8" },
|
||||||
|
{ name = "pillow", specifier = ">=12.2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev", "fast", "dxf"]
|
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]]
|
[[package]]
|
||||||
name = "vs-measure"
|
name = "vs-measure"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "vendor/visionsuite/packages/vs-measure" }
|
source = { editable = "vendor/visionsuite/packages/vs-measure" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
{ name = "numpy" },
|
||||||
{ name = "scipy", marker = "python_full_version >= '3.13'" },
|
{ name = "scipy" },
|
||||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
{ name = "visionsuite" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
|
||||||
{ name = "numpy", specifier = ">=1.24" },
|
{ name = "numpy", specifier = ">=1.24" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||||
{ name = "scipy", specifier = ">=1.11" },
|
{ name = "scipy", specifier = ">=1.11" },
|
||||||
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev", "fast"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vs-pm2d"
|
name = "vs-pm2d"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
|
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
|
{ name = "fastapi" },
|
||||||
{ name = "numba", marker = "python_full_version >= '3.13'" },
|
{ name = "numba" },
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
{ name = "numpy" },
|
||||||
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
|
{ name = "opencv-python" },
|
||||||
{ name = "pillow", marker = "python_full_version >= '3.13'" },
|
{ name = "pillow" },
|
||||||
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
|
{ name = "python-multipart" },
|
||||||
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
{ name = "visionsuite" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -2226,19 +2311,54 @@ name = "vs-task"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "vendor/visionsuite/packages/vs-task" }
|
source = { editable = "vendor/visionsuite/packages/vs-task" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
{ name = "numpy" },
|
||||||
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
{ name = "visionsuite" },
|
||||||
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
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 = "numpy", specifier = ">=1.24" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||||
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
{ 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-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]]
|
[[package]]
|
||||||
name = "watchfiles"
|
name = "watchfiles"
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: f095fcc254...bc0df1667c
Reference in New Issue
Block a user