Compare commits
22 Commits
767bede43b
...
V3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a6f62089 | |||
| f20b92bafb | |||
| 6bfe3a5d1a | |||
| 5717b3e7aa | |||
| 85d7976f4e | |||
| a72e3f11af | |||
| 46566ebfa5 | |||
| 0bd95367bb | |||
| 38ef0587f5 | |||
| 533edc5be8 | |||
| 3870cc3b0f | |||
| 398af016f8 | |||
| b86eeada86 | |||
| ddf7788d77 | |||
| 05f2937611 | |||
| 71da162e1f | |||
| 9387e7c306 | |||
| 0a3d1f5222 | |||
| c5f3366dd1 | |||
| 9546ded1e8 | |||
| b578ac9e1b | |||
| cb0fab8c92 |
@@ -30,6 +30,15 @@ STATION_CODE=ST-DEFAULT
|
|||||||
# another station's recipes would silently break traceability.
|
# another station's recipes would silently break traceability.
|
||||||
STATION_SWITCH_ENABLED=0
|
STATION_SWITCH_ENABLED=0
|
||||||
|
|
||||||
|
# --- Vision ---
|
||||||
|
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
||||||
|
VISION_WORKER_URL=http://vision:8100
|
||||||
|
# The pinned VisionSuite commit, stamped into the vision image at build time
|
||||||
|
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
|
||||||
|
# immediately without it. Set with:
|
||||||
|
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
|
||||||
|
VISION_ENGINE_VERSION=
|
||||||
|
|
||||||
# --- File Storage ---
|
# --- File Storage ---
|
||||||
# Resolved against the project root in src/backend/config.py.
|
# Resolved against the project root in src/backend/config.py.
|
||||||
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||||
|
|||||||
@@ -77,3 +77,6 @@ nul
|
|||||||
# Competitor analysis (local only)
|
# Competitor analysis (local only)
|
||||||
Concorrente/
|
Concorrente/
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/visionsuite"]
|
||||||
|
path = vendor/visionsuite
|
||||||
|
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
|
||||||
|
# needs libGL and glib at runtime even though it is never imported by name here.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
RUN uv sync --extra vision-worker --frozen --no-dev
|
||||||
|
|
||||||
|
COPY src/vision/ ./src/vision/
|
||||||
|
COPY src/vision_worker/ ./src/vision_worker/
|
||||||
|
|
||||||
|
# Ruling R8: the version is stamped at build time, not discovered at runtime.
|
||||||
|
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
|
||||||
|
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
|
||||||
|
# outside the build context - so `git rev-parse HEAD` cannot work in here.
|
||||||
|
# Pass the submodule's commit in from the host, which does have git:
|
||||||
|
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
# docker compose build vision
|
||||||
|
ARG VISION_ENGINE_VERSION
|
||||||
|
# I4: a build without the commit must fail now, at build time - not later, at
|
||||||
|
# every request including /health, with a container Docker still reports as
|
||||||
|
# healthy because no healthcheck existed to say otherwise.
|
||||||
|
RUN test -n "$VISION_ENGINE_VERSION" || { \
|
||||||
|
echo "VISION_ENGINE_VERSION is required. Build with:"; \
|
||||||
|
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
|
||||||
|
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Two workers, not four: each holds the vision stack in memory.
|
||||||
|
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
|
||||||
|
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
|
||||||
@@ -386,7 +386,89 @@ uv run pytest --cov src/backend
|
|||||||
uv run pytest src/frontend/flask_app/tests/
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
Stato corrente su `V3.0.0`: **360 pass, 0 fail** (212 backend + 148 frontend).
|
Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
|
||||||
|
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
|
||||||
|
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
|
||||||
|
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
|
||||||
|
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
|
||||||
|
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
|
||||||
|
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
|
||||||
|
Per eseguirli davvero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
|
||||||
|
uv sync --extra vision --extra dev --python 3.13
|
||||||
|
|
||||||
|
# Esegue i test del runner di visione in quell'ambiente
|
||||||
|
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
|
||||||
|
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
|
||||||
|
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
|
||||||
|
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
|
||||||
|
consumatore che incorpora solo il runner (come un futuro agente di stazione,
|
||||||
|
che non è un servizio web) non deve trascinarsi dietro un server che non gli
|
||||||
|
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
|
||||||
|
per avere un worker funzionante:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
|
||||||
|
gli extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Il worker di visione (`Dockerfile.vision`)
|
||||||
|
|
||||||
|
Il worker gira in un container separato dal server FastAPI principale, così
|
||||||
|
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
|
||||||
|
richieda di riavviare il traffico di produzione. Il container non pubblica
|
||||||
|
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
|
||||||
|
`http://vision:8100` sulla rete `tmflow-net`.
|
||||||
|
|
||||||
|
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
|
||||||
|
(`engine_version`), perché una stazione che misura con un motore diverso da
|
||||||
|
quello atteso deve poter essere identificata. Il container, però, non ha
|
||||||
|
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
|
||||||
|
submodule, e la copia `.git` che lo collega al repository ospitante non
|
||||||
|
viene inclusa nel contesto di build — quindi la versione non può essere
|
||||||
|
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
|
||||||
|
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
|
||||||
|
dove il repository Git è presente per intero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.dev.yml build vision
|
||||||
|
```
|
||||||
|
|
||||||
|
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
|
||||||
|
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
|
||||||
|
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
|
||||||
|
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
|
||||||
|
ricade su `git rev-parse` se la variabile non è impostata — comodo per
|
||||||
|
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
|
||||||
|
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
|
||||||
|
il `.git` del submodule non arriva nel contesto di build, e il worker
|
||||||
|
risponde con un errore esplicito su `/health` invece di indovinare una
|
||||||
|
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
|
||||||
|
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
|
||||||
|
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
|
||||||
|
in produzione).
|
||||||
|
|
||||||
|
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
|
||||||
|
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
|
||||||
|
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
|
||||||
|
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
|
||||||
|
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
|
||||||
|
3.13 descritto sopra. Il fallimento è preesistente e indipendente da questo
|
||||||
|
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
|
||||||
|
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
|
||||||
|
che nei propri commenti cita pagine di documentazione esterne.
|
||||||
|
|
||||||
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
||||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||||
@@ -413,6 +495,7 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
|
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -41,6 +42,23 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
|
container_name: tmflow-vision
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -50,6 +51,23 @@ services:
|
|||||||
- tmflow-net
|
- tmflow-net
|
||||||
- traefik-net
|
- traefik-net
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
|
container_name: tmflow-vision
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ Sblocca tre cose che altrimenti non ci sarebbero:
|
|||||||
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
||||||
sottomodulo, con un motore che sta su due host.
|
sottomodulo, con un motore che sta su due host.
|
||||||
|
|
||||||
|
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
|
||||||
|
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
|
||||||
|
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
|
||||||
|
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
|
||||||
|
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
|
||||||
|
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
|
||||||
|
> completa.
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
### I componenti
|
### I componenti
|
||||||
@@ -379,6 +387,73 @@ richiede una camera collegata.
|
|||||||
e i driver possono stare sulla macchina o nel container. La decisione è
|
e i driver possono stare sulla macchina o nel container. La decisione è
|
||||||
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
||||||
|
|
||||||
|
## Correzioni imposte dall'esecuzione
|
||||||
|
|
||||||
|
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
|
||||||
|
che qui erano scritte male o taciute, corrette dai fatti.
|
||||||
|
|
||||||
|
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
|
||||||
|
|
||||||
|
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
|
||||||
|
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
|
||||||
|
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
|
||||||
|
lettura cattiva irrisolta.
|
||||||
|
|
||||||
|
Una camera produce tutte le N quote **nello stesso istante**. Dentro
|
||||||
|
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
|
||||||
|
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
|
||||||
|
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
|
||||||
|
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
|
||||||
|
|
||||||
|
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
|
||||||
|
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
|
||||||
|
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
|
||||||
|
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
|
||||||
|
|
||||||
|
### Il repository è deliberatamente bi-ambiente
|
||||||
|
|
||||||
|
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
|
||||||
|
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
|
||||||
|
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
|
||||||
|
tengono separate le due risoluzioni.
|
||||||
|
|
||||||
|
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
|
||||||
|
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
|
||||||
|
|
||||||
|
### La versione del motore si timbra al build
|
||||||
|
|
||||||
|
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
|
||||||
|
nel contesto di build non esiste: interrogare git dentro il container non
|
||||||
|
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
|
||||||
|
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
|
||||||
|
l'altro. Il build fallisce se la variabile manca.
|
||||||
|
|
||||||
|
### La divisione degli extra compra esplicitezza, non peso
|
||||||
|
|
||||||
|
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
|
||||||
|
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
|
||||||
|
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
|
||||||
|
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
|
||||||
|
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
|
||||||
|
— e va aggiunta alle verifiche su VisionSuite qui sotto.
|
||||||
|
|
||||||
|
### L'immagine di produzione non viene ancora conservata
|
||||||
|
|
||||||
|
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
|
||||||
|
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
|
||||||
|
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
|
||||||
|
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
|
||||||
|
|
||||||
|
### Prima di applicare le migrazioni in produzione
|
||||||
|
|
||||||
|
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
|
||||||
|
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
|
||||||
|
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
|
||||||
|
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
|
||||||
|
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
|
||||||
|
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
|
||||||
|
reali, perché `measurements` è la tabella grossa.
|
||||||
|
|
||||||
## Da verificare prima di cominciare
|
## Da verificare prima di cominciare
|
||||||
|
|
||||||
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
||||||
|
|||||||
+33
-1
@@ -53,6 +53,32 @@ dev = [
|
|||||||
"coverage>=7.0.0",
|
"coverage>=7.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
|
||||||
|
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
|
||||||
|
# marker keeps the base 3.11 resolution untouched. numpy is declared
|
||||||
|
# explicitly - runner.py imports it directly, and it must not depend on
|
||||||
|
# being pulled in as a side effect of vs-pm2d's own dependencies.
|
||||||
|
vision = [
|
||||||
|
"visionsuite; python_version>='3.13'",
|
||||||
|
"vs-task; python_version>='3.13'",
|
||||||
|
"vs-measure; python_version>='3.13'",
|
||||||
|
"vs-pm2d; python_version>='3.13'",
|
||||||
|
"numpy; python_version>='3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
|
||||||
|
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
|
||||||
|
# whole structure exists so a consumer installs only what it needs, and a
|
||||||
|
# later station agent embeds the same runner without being a web service.
|
||||||
|
# It must not drag fastapi/uvicorn onto shop-floor PCs.
|
||||||
|
vision-worker = [
|
||||||
|
"tiemeasureflow[vision]",
|
||||||
|
"fastapi>=0.110.0; python_version>='3.13'",
|
||||||
|
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
|
||||||
|
"pillow>=10.0.0; python_version>='3.13'",
|
||||||
|
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
# Backend
|
# Backend
|
||||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||||
@@ -70,6 +96,12 @@ packages = ["src/backend", "src/frontend"]
|
|||||||
# Pin the resolver to the deps we declared; reproducible builds.
|
# Pin the resolver to the deps we declared; reproducible builds.
|
||||||
package = false
|
package = false
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||||
|
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||||
|
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||||
|
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
|
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ async def create_task(
|
|||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
|
vision_json=data.vision_json,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -197,6 +198,7 @@ async def create_task(
|
|||||||
ltl=sub_data.ltl,
|
ltl=sub_data.ltl,
|
||||||
unit=sub_data.unit,
|
unit=sub_data.unit,
|
||||||
image_path=sub_data.image_path,
|
image_path=sub_data.image_path,
|
||||||
|
vision_output=sub_data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(sub)
|
db.add(sub)
|
||||||
|
|
||||||
@@ -340,6 +342,7 @@ async def create_subtask(
|
|||||||
ltl=data.ltl,
|
ltl=data.ltl,
|
||||||
unit=data.unit,
|
unit=data.unit,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
|
vision_output=data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(subtask)
|
db.add(subtask)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""The client asks for a measurement and gets an outcome.
|
||||||
|
|
||||||
|
It does not know, and must not know, whether the server or the station computed
|
||||||
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
|
the frontend or the recipes.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||||
|
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
|
# A vision graph measures pixels, not a PDF: stricter than files.py's
|
||||||
|
# ALLOWED_TYPES, which also accepts documents for technical drawings.
|
||||||
|
_IMAGE_EXTENSIONS = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||||
|
"""Shared by the endpoints of this router."""
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == task_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_image(image: UploadFile) -> bytes:
|
||||||
|
"""Type checked from the header before reading, size checked once the body
|
||||||
|
is in hand - the same two-step convention `files.py::upload_file` already
|
||||||
|
uses, reused rather than reinvented here.
|
||||||
|
"""
|
||||||
|
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||||
|
)
|
||||||
|
content = await image.read()
|
||||||
|
if not validate_file_size(len(content)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"File size {len(content)} bytes exceeds maximum "
|
||||||
|
f"{settings.max_upload_size_mb}MB"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||||
|
async def execute(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
lot_number: str | None = Form(None),
|
||||||
|
serial_number: str | None = Form(None),
|
||||||
|
production_run_id: int | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_measurement_tec),
|
||||||
|
) -> VisionExecuteResponse:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
|
||||||
|
result, saved = await vision_service.execute_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
await _read_image(image),
|
||||||
|
current_user.id,
|
||||||
|
version_id=task.version_id,
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return VisionExecuteResponse(
|
||||||
|
vision_result_id=result.id,
|
||||||
|
engine_version=result.engine_version,
|
||||||
|
measurements=[
|
||||||
|
VisionMeasurementResult(
|
||||||
|
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||||
|
)
|
||||||
|
for m in saved
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Composing a recipe and keeping a reference image are Maker operations, not
|
||||||
|
# measuring: both endpoints below gate on `require_maker`, not the
|
||||||
|
# `require_measurement_tec` that `execute` above uses.
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reference-images")
|
||||||
|
async def add_reference_image(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
note: str | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
content = await _read_image(image)
|
||||||
|
reference = await vision_service.save_reference_image(
|
||||||
|
db, task.id, content, note=note,
|
||||||
|
extension=_IMAGE_EXTENSIONS[image.content_type],
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def preview(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str | None = None
|
openrouter_api_key: str | None = None
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||||
|
|
||||||
|
# Vision worker (internal network only, reachable as `vision` in Compose)
|
||||||
|
vision_worker_url: str = "http://vision:8100"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async MySQL connection string."""
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from src.backend.api.routers.statistics import router as statistics_router
|
|||||||
from src.backend.api.routers.setup import router as setup_router
|
from src.backend.api.routers.setup import router as setup_router
|
||||||
from src.backend.api.routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
from src.backend.api.routers.production import router as production_router
|
from src.backend.api.routers.production import router as production_router
|
||||||
|
from src.backend.api.routers.vision import router as vision_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -75,6 +76,7 @@ app.include_router(reports_router)
|
|||||||
app.include_router(setup_router)
|
app.include_router(setup_router)
|
||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
app.include_router(production_router)
|
app.include_router(production_router)
|
||||||
|
app.include_router(vision_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""the vision graph lives on the task, and the quote names its output
|
||||||
|
|
||||||
|
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
|
||||||
|
renaming costs one statement here; from the first saved task onwards it would
|
||||||
|
be a data migration and the typo would already be in the public API.
|
||||||
|
|
||||||
|
Revision ID: 011_vision_graph
|
||||||
|
Revises: 010_meas_authorisation
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '011_vision_graph'
|
||||||
|
down_revision: Union[str, None] = '010_meas_authorisation'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
||||||
|
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_output', sa.String(length=120), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.drop_column('vision_output')
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
batch.drop_column('vision_json')
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""one row per vision execution, and camera as an input method
|
||||||
|
|
||||||
|
The vision surroundings do not go on `measurements`: statistics and the export
|
||||||
|
read that table on every pass. And it would be wrong modelling anyway - one
|
||||||
|
acquisition produces N quotes, and image, overlay, device and engine version are
|
||||||
|
the same for all of them.
|
||||||
|
|
||||||
|
Revision ID: 012_vision_results
|
||||||
|
Revises: 011_vision_graph
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '012_vision_results'
|
||||||
|
down_revision: Union[str, None] = '011_vision_graph'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_METHODS = ('usb_caliper', 'manual')
|
||||||
|
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('image_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('overlay_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('executed_on',
|
||||||
|
sa.Enum('server', 'station', name='vision_executed_on_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('executed_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_result_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_measurements_vision_result',
|
||||||
|
'vision_results', ['vision_result_id'], ['id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.drop_constraint('fk_measurements_vision_result',
|
||||||
|
type_='foreignkey')
|
||||||
|
batch.drop_column('vision_result_id')
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_table('vision_results')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""reference images, with their provenance
|
||||||
|
|
||||||
|
A reference image without provenance is a trap: the graph is re-run months later,
|
||||||
|
different numbers come out, and there is no way to tell an updated engine from a
|
||||||
|
moved lens.
|
||||||
|
|
||||||
|
`expected_json` holds what the graph is supposed to produce on this image. It is
|
||||||
|
what makes a regression run possible after a VisionSuite upgrade - the safety net
|
||||||
|
that makes bumping the submodule sustainable with an engine on two hosts.
|
||||||
|
|
||||||
|
Revision ID: 013_reference_images
|
||||||
|
Revises: 012_vision_results
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '013_reference_images'
|
||||||
|
down_revision: Union[str, None] = '012_vision_results'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_reference_images',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('path', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('note', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('expected_json', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('acquired_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('vision_reference_images')
|
||||||
@@ -12,6 +12,11 @@ class MeasurementCreate(BaseModel):
|
|||||||
value: float
|
value: float
|
||||||
lot_number: Optional[str] = Field(None, max_length=100)
|
lot_number: Optional[str] = Field(None, max_length=100)
|
||||||
serial_number: Optional[str] = Field(None, max_length=100)
|
serial_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
# "camera" is deliberately absent from this pattern, even though the ORM
|
||||||
|
# enum and the database allow it. Do not add it here: a camera
|
||||||
|
# measurement must be produced by vision_service.execute_task, never by a
|
||||||
|
# client posting JSON straight to this endpoint - there is no image, no
|
||||||
|
# graph and no engine_version behind a value that arrived this way.
|
||||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||||
input_duration_ms: Optional[int] = Field(None, ge=0)
|
input_duration_ms: Optional[int] = Field(None, ge=0)
|
||||||
# The production this belongs to, when one is open at the station.
|
# The production this belongs to, when one is open at the station.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class SubtaskCreate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str = Field("mm", max_length=20)
|
unit: str = Field("mm", max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskUpdate(BaseModel):
|
class SubtaskUpdate(BaseModel):
|
||||||
@@ -35,6 +36,7 @@ class SubtaskUpdate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: Optional[str] = Field(None, max_length=20)
|
unit: Optional[str] = Field(None, max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskResponse(BaseModel):
|
class SubtaskResponse(BaseModel):
|
||||||
@@ -53,6 +55,7 @@ class SubtaskResponse(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str
|
unit: str
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
@@ -64,6 +67,7 @@ class TaskCreate(BaseModel):
|
|||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
subtasks: list[SubtaskCreate] = []
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +80,7 @@ class TaskUpdate(BaseModel):
|
|||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
@@ -93,6 +98,7 @@ class TaskResponse(BaseModel):
|
|||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
subtasks: list[SubtaskResponse] = []
|
subtasks: list[SubtaskResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Pydantic schemas for the vision execution response."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class VisionMeasurementResult(BaseModel):
|
||||||
|
"""One quote's outcome, as much of it as the client needs to show."""
|
||||||
|
subtask_id: int
|
||||||
|
value: float
|
||||||
|
pass_fail: str
|
||||||
|
|
||||||
|
|
||||||
|
class VisionExecuteResponse(BaseModel):
|
||||||
|
"""What `POST /api/vision/execute` returns: the run, and every quote it filled."""
|
||||||
|
vision_result_id: int
|
||||||
|
engine_version: str
|
||||||
|
measurements: list[VisionMeasurementResult]
|
||||||
@@ -7,6 +7,7 @@ from src.backend.models.orm.access_log import AccessLog
|
|||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -22,4 +23,6 @@ __all__ = [
|
|||||||
"StationRecipeAssignment",
|
"StationRecipeAssignment",
|
||||||
"ProductionRun",
|
"ProductionRun",
|
||||||
"ProductionEvent",
|
"ProductionEvent",
|
||||||
|
"VisionResult",
|
||||||
|
"VisionReferenceImage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ class Measurement(Base):
|
|||||||
|
|
||||||
# Input method
|
# Input method
|
||||||
input_method: Mapped[str] = mapped_column(
|
input_method: Mapped[str] = mapped_column(
|
||||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="manual",
|
default="manual",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The vision execution that produced this value, when there was one. Null for
|
||||||
|
# everything taken by caliper or typed by hand.
|
||||||
|
vision_result_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("vision_results.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Time spent by the operator entering this value (client-reported)
|
# Time spent by the operator entering this value (client-reported)
|
||||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
|||||||
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
||||||
# contents. The last two are not built yet; they are in the enum from the start
|
# contents. The last two are not built yet; they are in the enum from the start
|
||||||
# because widening a MySQL enum later is an ALTER on a live table.
|
# because widening a MySQL enum later is an ALTER on a live table.
|
||||||
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
|
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
||||||
|
|
||||||
# Types the operator screen treats as "there are quotes to take here".
|
# Types the operator screen treats as "there are quotes to take here".
|
||||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||||
@@ -47,6 +47,10 @@ class RecipeTask(Base):
|
|||||||
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||||
)
|
)
|
||||||
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# The vision graph, serialised by vs-task. Opaque here on purpose: the
|
||||||
|
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||||
|
# out of this container.
|
||||||
|
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||||
@@ -89,6 +93,12 @@ class RecipeSubtask(Base):
|
|||||||
|
|
||||||
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
|
||||||
|
# A link is an id, not a pointer - the same rule vs-task follows, and for
|
||||||
|
# the same reason: a pointer does not survive being written to disk.
|
||||||
|
vision_output: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(120), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""One row per vision execution - not per quote."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VisionResult(Base):
|
||||||
|
__tablename__ = "vision_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
executed_on: Mapped[str] = mapped_column(
|
||||||
|
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
|
||||||
|
)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
# Copied, not referenced: a calibration redone six months from now must not
|
||||||
|
# rewrite what a measurement already taken means. Same for the graph.
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
executed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionResult task={self.task_id} engine={self.engine_version}>"
|
||||||
|
|
||||||
|
|
||||||
|
class VisionReferenceImage(Base):
|
||||||
|
"""An image kept so a graph can be composed, tried and re-tried without a
|
||||||
|
camera - which is exactly what VisionSuite's source abstraction is for."""
|
||||||
|
|
||||||
|
__tablename__ = "vision_reference_images"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
acquired_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionReferenceImage task={self.task_id} path={self.path}>"
|
||||||
@@ -224,8 +224,18 @@ async def save_measurement(
|
|||||||
input_method: str = "manual",
|
input_method: str = "manual",
|
||||||
input_duration_ms: int | None = None,
|
input_duration_ms: int | None = None,
|
||||||
production_run_id: int | None = None,
|
production_run_id: int | None = None,
|
||||||
|
vision_result_id: int | None = None,
|
||||||
|
enforce_tolerance_gate: bool = True,
|
||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Save a single measurement with auto-calculated pass/fail."""
|
"""Save a single measurement with auto-calculated pass/fail.
|
||||||
|
|
||||||
|
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
|
||||||
|
below - everything else (verdict, recipe rules, deviation) still applies.
|
||||||
|
This is for a caller that has already evaluated the gate itself for a whole
|
||||||
|
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||||
|
the gate models "moving on" between measurements taken one after another,
|
||||||
|
which does not exist inside a single camera acquisition.
|
||||||
|
"""
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||||
@@ -240,6 +250,7 @@ async def save_measurement(
|
|||||||
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
||||||
# that same quote again is the way out that does not need the capoturno; the
|
# that same quote again is the way out that does not need the capoturno; the
|
||||||
# next quote is not.
|
# next quote is not.
|
||||||
|
if enforce_tolerance_gate:
|
||||||
blocking = await pending_authorisation(
|
blocking = await pending_authorisation(
|
||||||
db, version_id, measured_by, production_run_id,
|
db, version_id, measured_by, production_run_id,
|
||||||
)
|
)
|
||||||
@@ -266,6 +277,7 @@ async def save_measurement(
|
|||||||
input_method=input_method,
|
input_method=input_method,
|
||||||
input_duration_ms=input_duration_ms,
|
input_duration_ms=input_duration_ms,
|
||||||
production_run_id=production_run_id,
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=vision_result_id,
|
||||||
)
|
)
|
||||||
db.add(measurement)
|
db.add(measurement)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ async def _copy_tasks_to_version(
|
|||||||
file_path=task.file_path,
|
file_path=task.file_path,
|
||||||
file_type=task.file_type,
|
file_type=task.file_type,
|
||||||
annotations_json=task.annotations_json,
|
annotations_json=task.annotations_json,
|
||||||
|
# Carried over explicitly, same reasoning as task_type above: a
|
||||||
|
# copy-on-write version that dropped the camera graph would leave
|
||||||
|
# a camera_measure task unable to execute from the moment a
|
||||||
|
# recipe with measurements is next edited.
|
||||||
|
vision_json=task.vision_json,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush() # get new_task.id
|
await db.flush() # get new_task.id
|
||||||
@@ -105,6 +110,7 @@ async def _copy_tasks_to_version(
|
|||||||
ltl=sub.ltl,
|
ltl=sub.ltl,
|
||||||
unit=sub.unit,
|
unit=sub.unit,
|
||||||
image_path=sub.image_path,
|
image_path=sub.image_path,
|
||||||
|
vision_output=sub.vision_output,
|
||||||
)
|
)
|
||||||
db.add(new_sub)
|
db.add(new_sub)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Route the image to the worker, map the outputs onto the quotes, save.
|
||||||
|
|
||||||
|
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
|
||||||
|
to the worker over HTTP and stays ignorant of how a number was produced.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
"""The one place that talks to the worker; the tests replace it.
|
||||||
|
|
||||||
|
The worker's fault must not become the API's fault (spec's edge-case
|
||||||
|
table: "il worker isola il guasto, l'API resta in piedi, il task
|
||||||
|
riporta l'errore"). A stopped worker raises `httpx.ConnectError`; a job
|
||||||
|
past the timeout raises `httpx.ReadTimeout` - both would otherwise
|
||||||
|
propagate past this function and surface to the operator as a naked
|
||||||
|
500. The response shape is validated here too: a proxy answering 200
|
||||||
|
with something that is not the worker's JSON contract must not turn
|
||||||
|
into a bare KeyError further down.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.vision_worker_url}/run",
|
||||||
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
|
data={"graph": json.dumps(graph)},
|
||||||
|
)
|
||||||
|
except httpx.ConnectError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc}",
|
||||||
|
) from exc
|
||||||
|
except httpx.ReadTimeout as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||||
|
)
|
||||||
|
missing = {"outputs", "failures", "engine_version"} - report.keys()
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=(
|
||||||
|
"the vision worker's answer was malformed: missing "
|
||||||
|
f"{', '.join(sorted(missing))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
task: RecipeTask,
|
||||||
|
image_bytes: bytes,
|
||||||
|
measured_by: int,
|
||||||
|
*,
|
||||||
|
version_id: int,
|
||||||
|
lot_number: str | None = None,
|
||||||
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
) -> tuple[VisionResult, list[Measurement]]:
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordered by marker_number - the number the operator sees on the annotated
|
||||||
|
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
|
||||||
|
# all key off it). Which quote is saved first must never be an accident of
|
||||||
|
# how SQLite/MySQL happens to return rows.
|
||||||
|
quotes = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.order_by(RecipeSubtask.marker_number)
|
||||||
|
)).scalars().all()
|
||||||
|
if not quotes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="no quote on this task names a vision output",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per
|
||||||
|
# quote. `save_measurement`'s own gate models "moving on" between
|
||||||
|
# measurements taken one after another - a real later moment where the
|
||||||
|
# operator could leave a bad reading behind. A camera acquisition produces
|
||||||
|
# every quote of this task at the same instant: there is no "moving on"
|
||||||
|
# between them, so applying the sequential rule inside the batch would
|
||||||
|
# block on a fail this very request just saved. Check it once here,
|
||||||
|
# against the whole set this acquisition is about to produce, and pass
|
||||||
|
# enforce_tolerance_gate=False to every save_measurement call below.
|
||||||
|
#
|
||||||
|
# Checked before the worker runs (M3): a blocked operator finds out
|
||||||
|
# without burning a whole vision execution first - the worker is the
|
||||||
|
# expensive part, the gate check is not.
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
quote_ids = {q.id for q in quotes}
|
||||||
|
if blocking is not None and blocking.subtask_id not in quote_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = await _call_worker(image_bytes, task.vision_json)
|
||||||
|
outputs = report["outputs"]
|
||||||
|
|
||||||
|
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||||
|
# than a piece not measured at all.
|
||||||
|
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
|
||||||
|
if missing:
|
||||||
|
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(
|
||||||
|
f"the graph did not produce {', '.join(missing)}"
|
||||||
|
+ (f" (tools that failed: {failed})" if failed else "")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = VisionResult(
|
||||||
|
task_id=task.id,
|
||||||
|
engine_version=report["engine_version"],
|
||||||
|
executed_on="server",
|
||||||
|
graph_snapshot=task.vision_json,
|
||||||
|
duration_ms=report.get("duration_ms"),
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
saved: list[Measurement] = []
|
||||||
|
for quote in quotes:
|
||||||
|
saved.append(await measurement_service.save_measurement(
|
||||||
|
db,
|
||||||
|
subtask_id=quote.id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=measured_by,
|
||||||
|
value=float(outputs[quote.vision_output]),
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
input_method="camera",
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=result.id,
|
||||||
|
enforce_tolerance_gate=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result, saved
|
||||||
|
|
||||||
|
|
||||||
|
async def save_reference_image(
|
||||||
|
db: AsyncSession,
|
||||||
|
task_id: int,
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
note: str | None = None,
|
||||||
|
station_id: int | None = None,
|
||||||
|
device_code: str | None = None,
|
||||||
|
calibration_snapshot: dict | None = None,
|
||||||
|
extension: str = "png",
|
||||||
|
) -> VisionReferenceImage:
|
||||||
|
"""Keep an image so the graph can be composed and re-tried without a camera.
|
||||||
|
|
||||||
|
`extension` must match the upload's real content type (the router derives
|
||||||
|
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
|
||||||
|
would be a lie the filename tells forever.
|
||||||
|
"""
|
||||||
|
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
|
||||||
|
# rest of the codebase uses the former (see production_export_service).
|
||||||
|
folder = settings.upload_path / "vision" / "reference" / str(task_id)
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f"{uuid4().hex}.{extension}"
|
||||||
|
(folder / name).write_bytes(image_bytes)
|
||||||
|
|
||||||
|
reference = VisionReferenceImage(
|
||||||
|
task_id=task_id,
|
||||||
|
path=str(Path("vision") / "reference" / str(task_id) / name),
|
||||||
|
note=note,
|
||||||
|
station_id=station_id,
|
||||||
|
device_code=device_code,
|
||||||
|
calibration_snapshot=calibration_snapshot,
|
||||||
|
)
|
||||||
|
db.add(reference)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(reference)
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
|
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
|
||||||
|
"""Run the graph and save nothing. Composing is not measuring."""
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
return await _call_worker(image_bytes, task.vision_json)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Il server non importa VisionSuite, e questo lo verifica.
|
||||||
|
|
||||||
|
Senza qualcuno che lo controlli, il confine si perde in silenzio: basta un
|
||||||
|
import comodo perché l'immagine dell'API torni da cinque gigabyte e il motivo
|
||||||
|
per cui il worker esiste svanisca senza che nessuna prova diventi rossa.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# Full dotted prefixes, not bare roots: "src" alone would forbid every import
|
||||||
|
# the backend legitimately makes of itself (src.backend.*), so the entries
|
||||||
|
# for the vision tree are multi-segment ("src.vision", "src.vision_worker")
|
||||||
|
# while VisionSuite's own top-level packages stay single-segment.
|
||||||
|
#
|
||||||
|
# M2: "Acquire" used to sit here as a stand-in for vs-camera, but it is a
|
||||||
|
# class/SDK name (Balluff's "mvIMPACT Acquire" SDK), never an import root -
|
||||||
|
# it could never match. vs-camera imports under the same `visionsuite`
|
||||||
|
# namespace as vs-core (see
|
||||||
|
# vendor/visionsuite/packages/vs-camera/pyproject.toml: "il codice si importa
|
||||||
|
# come visionsuite.camera...."), which "visionsuite" below already forbids,
|
||||||
|
# so there is nothing separate to add for it.
|
||||||
|
FORBIDDEN = {
|
||||||
|
"visionsuite", "pm2d", "dxf_compare", "torch",
|
||||||
|
"src.vision", "src.vision_worker",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _imported_modules(source: Path) -> set[str]:
|
||||||
|
"""Full dotted module paths this file imports - not just their first
|
||||||
|
segment. `from src.vision.runner import ...` must be checked against
|
||||||
|
"src.vision.runner" (inside the forbidden "src.vision"), not against
|
||||||
|
"src" alone, which is never forbidden since the backend imports itself
|
||||||
|
constantly.
|
||||||
|
"""
|
||||||
|
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||||
|
modules: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
modules.update(alias.name for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||||
|
modules.add(node.module)
|
||||||
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden_hits(modules: set[str]) -> set[str]:
|
||||||
|
"""Which FORBIDDEN entries a set of imported module paths triggers.
|
||||||
|
|
||||||
|
A module counts as forbidden if it equals a forbidden entry exactly, or
|
||||||
|
lives inside it ("src.vision.runner" is inside "src.vision").
|
||||||
|
"""
|
||||||
|
hits: set[str] = set()
|
||||||
|
for module in modules:
|
||||||
|
for entry in FORBIDDEN:
|
||||||
|
if module == entry or module.startswith(entry + "."):
|
||||||
|
hits.add(entry)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_server_never_imports_visionsuite():
|
||||||
|
offenders = []
|
||||||
|
for source in BACKEND.rglob("*.py"):
|
||||||
|
if "tests" in source.parts or "migrations" in source.parts:
|
||||||
|
continue
|
||||||
|
forbidden = _forbidden_hits(_imported_modules(source))
|
||||||
|
if forbidden:
|
||||||
|
offenders.append(f"{source.relative_to(BACKEND)}: {sorted(forbidden)}")
|
||||||
|
|
||||||
|
assert offenders == [], (
|
||||||
|
"il server deve restare cieco alla visione; usa il worker via HTTP:\n"
|
||||||
|
+ "\n".join(offenders)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_submodule_is_pinned():
|
||||||
|
"""Server e stazione devono montare lo stesso commit: qui c'è la fonte."""
|
||||||
|
gitmodules = Path(__file__).resolve().parents[3] / ".gitmodules"
|
||||||
|
assert gitmodules.exists(), "vendor/visionsuite non è un sottomodulo"
|
||||||
|
assert "vendor/visionsuite" in gitmodules.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_module_path_import_is_caught_not_just_its_root(tmp_path):
|
||||||
|
"""M2: `from src.vision.runner import ...` inside src/backend/** must be
|
||||||
|
caught. Checking only the first dotted segment ("src") would miss it -
|
||||||
|
"src" itself is never forbidden, the backend imports itself all the time.
|
||||||
|
This is the blind spot the reviewer found: same repository, no install
|
||||||
|
needed, the most tempting shortcut of all.
|
||||||
|
"""
|
||||||
|
offender = tmp_path / "sneaky.py"
|
||||||
|
offender.write_text("from src.vision.runner import run_graph\n")
|
||||||
|
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_vision_worker_import_is_also_caught(tmp_path):
|
||||||
|
offender = tmp_path / "sneaky.py"
|
||||||
|
offender.write_text("import src.vision_worker.main\n")
|
||||||
|
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision_worker"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unrelated_src_backend_import_is_not_caught(tmp_path):
|
||||||
|
"""The fix must not turn every self-import of the backend into an
|
||||||
|
offender - only the vision subtrees are forbidden."""
|
||||||
|
innocent = tmp_path / "innocent.py"
|
||||||
|
innocent.write_text("from src.backend.config import settings\n")
|
||||||
|
assert _forbidden_hits(_imported_modules(innocent)) == set()
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""C1: the vision graph must survive copy-on-write, and creation must save it.
|
||||||
|
|
||||||
|
The spec assumed versioning was free for `vision_json`/`vision_output` - "un
|
||||||
|
campo in piu nella copia, non una logica nuova". `_copy_tasks_to_version`
|
||||||
|
copies an explicit field list that omitted both fields, so the first edit to
|
||||||
|
a recipe that already has measurements silently drops the camera graph from
|
||||||
|
the new version. The creation paths (`POST /api/recipes/{id}/tasks` and
|
||||||
|
`POST /api/tasks/{id}/subtasks`) have the same shape of bug: `TaskCreate` and
|
||||||
|
`SubtaskCreate` declare the fields, but the routers never pass them to the
|
||||||
|
ORM constructors, so the API answers 200 and silently does not store them.
|
||||||
|
|
||||||
|
Each test file here reads on its own - the same idiom `test_vision_graph.py`
|
||||||
|
and `test_recipes.py` already use.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
|
||||||
|
"""Give the current version a measurement, so an update takes the
|
||||||
|
copy-on-write path instead of updating in place."""
|
||||||
|
version_id = (await db.execute(
|
||||||
|
select(RecipeVersion.id).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
subtask_id = (await db.execute(
|
||||||
|
select(RecipeSubtask.id)
|
||||||
|
.join(RecipeSubtask.task)
|
||||||
|
.where(RecipeSubtask.task.has(version_id=version_id))
|
||||||
|
)).scalar_one()
|
||||||
|
db.add(Measurement(
|
||||||
|
subtask_id=subtask_id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=user_id,
|
||||||
|
value=10.0,
|
||||||
|
pass_fail="pass",
|
||||||
|
deviation=0.0,
|
||||||
|
input_method="manual",
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _current_task_and_subtask(db: AsyncSession, recipe_id: int):
|
||||||
|
version = (await db.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_on_write_preserves_the_vision_graph(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||||
|
):
|
||||||
|
"""A recipe edit that triggers copy-on-write must not drop the camera graph."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, subtask = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# A measurement on v1 forces the next PUT onto the copy-on-write path.
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/recipes/{recipe.id}",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"change_notes": "Edit that must not drop the graph"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
version, new_task, new_subtask = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
assert version.version_number == 2, "the edit should have created v2"
|
||||||
|
assert new_task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
assert new_subtask.vision_output == "t1.diameter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creating_a_task_persists_its_vision_graph(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||||
|
):
|
||||||
|
"""POST /api/recipes/{id}/tasks must store vision_json and vision_output,
|
||||||
|
not just accept and echo them back."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={
|
||||||
|
"title": "Camera task",
|
||||||
|
"task_type": "camera_measure",
|
||||||
|
"vision_json": {"schema_version": 1, "name": "p", "tools": []},
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"marker_number": 1,
|
||||||
|
"description": "Diameter",
|
||||||
|
"nominal": 10.0,
|
||||||
|
"vision_output": "t1.diameter",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["vision_json"] == {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
assert body["subtasks"][0]["vision_output"] == "t1.diameter"
|
||||||
|
|
||||||
|
# Reload from the database - the response reflects the ORM object right
|
||||||
|
# after flush, so this catches a constructor that dropped the field.
|
||||||
|
db_session.expunge_all()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == body["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
sub = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().one()
|
||||||
|
assert task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
assert sub.vision_output == "t1.diameter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creating_a_subtask_persists_its_vision_output(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||||
|
):
|
||||||
|
"""POST /api/tasks/{id}/subtasks (the standalone endpoint) must also store it."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/tasks/{task.id}/subtasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={
|
||||||
|
"marker_number": 2,
|
||||||
|
"description": "Radius",
|
||||||
|
"nominal": 5.0,
|
||||||
|
"vision_output": "t1.radius",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["vision_output"] == "t1.radius"
|
||||||
|
|
||||||
|
db_session.expunge_all()
|
||||||
|
sub = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.marker_number == 2)
|
||||||
|
)).scalar_one()
|
||||||
|
assert sub.vision_output == "t1.radius"
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""A quote from a camera is a measurement like any other: same verdict, same gate."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
GOOD = 10.0 # inside every limit of the fixture quote
|
||||||
|
OUT = 99.0 # far outside
|
||||||
|
|
||||||
|
|
||||||
|
def an_image() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
async def two_vision_quotes(db_session, recipe_id: int):
|
||||||
|
"""The fixture recipe's task, with a second quote mapped to a vision output.
|
||||||
|
|
||||||
|
Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both
|
||||||
|
quotes belong to a single camera acquisition instead of two separate
|
||||||
|
manual readings.
|
||||||
|
"""
|
||||||
|
version, task, first = await recipe_parts(db_session, recipe_id)
|
||||||
|
first.vision_output = "t1.diameter"
|
||||||
|
|
||||||
|
second = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=2, description="Second quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
vision_output="t1.radius",
|
||||||
|
)
|
||||||
|
db_session.add(second)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(second)
|
||||||
|
return version, task, first, second
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker_says(monkeypatch):
|
||||||
|
"""The worker does not run in tests: its response is faked here."""
|
||||||
|
def _install(outputs: dict, failures: list | None = None):
|
||||||
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"outputs": outputs,
|
||||||
|
"failures": failures or [],
|
||||||
|
"engine_version": "a" * 40,
|
||||||
|
"duration_ms": 12,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", fake_call,
|
||||||
|
)
|
||||||
|
return _install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_camera_quote_becomes_a_measurement(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
# The fixture creates the quote with nominal 10 and tight limits around it.
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = (await db_session.execute(
|
||||||
|
select(Measurement).where(Measurement.subtask_id == subtask.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(saved) == 1
|
||||||
|
assert saved[0].pass_fail == "pass"
|
||||||
|
assert saved[0].input_method == "camera"
|
||||||
|
assert saved[0].vision_result_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_execution_records_which_engine_produced_it(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.engine_version == "a" * 40
|
||||||
|
assert result.executed_on == "server"
|
||||||
|
assert result.graph_snapshot == task.vision_json
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_missing_output_names_itself(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""No half measurement: if an output is missing, the error names it."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.radius": 5.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "t1.diameter" in response.json()["detail"]
|
||||||
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_task_without_a_graph_is_refused(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "graph" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per quote.
|
||||||
|
# A camera acquisition produces every quote of the task at the same instant -
|
||||||
|
# there is no "moving on" between them, so the sequential gate must not fire
|
||||||
|
# inside the batch. It still has to fire between two separate acquisitions.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""The quote evaluated first fails; the one after it must still be saved."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
by_subtask = {
|
||||||
|
m.subtask_id: m.pass_fail
|
||||||
|
for m in (await db_session.execute(select(Measurement))).scalars().all()
|
||||||
|
}
|
||||||
|
assert by_subtask == {first.id: "fail", second.id: "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_pending_fail_outside_the_batch_still_blocks(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""A fail left over from something else this camera task does not cover."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
# A third quote on the same task, not part of this acquisition (no vision
|
||||||
|
# output), already failed and is waiting on a supervisor.
|
||||||
|
third = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=3, description="Not a vision quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db_session.add(third)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(third)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=third.id, version_id=version.id,
|
||||||
|
measured_by=measurement_tec_user.id, value=OUT,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "supervisor" in response.json()["detail"].lower()
|
||||||
|
assert (await db_session.execute(
|
||||||
|
select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id]))
|
||||||
|
)).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch,
|
||||||
|
):
|
||||||
|
"""M3: a blocked operator must not burn a whole vision execution to learn
|
||||||
|
they are blocked. The gate is checked before the worker runs, not after."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
third = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=3, description="Not a vision quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db_session.add(third)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(third)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=third.id, version_id=version.id,
|
||||||
|
measured_by=measurement_tec_user.id, value=OUT,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
raise AssertionError("the worker must not run while the operator is blocked")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "supervisor" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_new_acquisition_can_remeasure_its_own_pending_quote(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""Re-shooting the same task is the way out, same as re-measuring by hand."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
|
||||||
|
first_shot = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p1.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert first_shot.status_code == 200
|
||||||
|
|
||||||
|
# first.id is now pending authorisation - but it belongs to this same
|
||||||
|
# task, so a new acquisition of it is not "moving on".
|
||||||
|
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
||||||
|
second_shot = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p2.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert second_shot.status_code == 200
|
||||||
|
|
||||||
|
readings = (await db_session.execute(
|
||||||
|
select(Measurement)
|
||||||
|
.where(Measurement.subtask_id == first.id)
|
||||||
|
.order_by(Measurement.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert [m.pass_fail for m in readings] == ["fail", "pass"]
|
||||||
|
|
||||||
|
assert await measurement_service.pending_authorisation(
|
||||||
|
db_session, version.id, measurement_tec_user.id,
|
||||||
|
) is None
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Il grafo di visione vive sul task, e la quota sa quale uscita la riempie."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.tests.conftest import create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone.
|
||||||
|
|
||||||
|
Versione corrente, il suo unico task, la sua prima quota. È l'idioma che
|
||||||
|
`test_out_of_tolerance.py` usa già, ripetuto perché ogni file di prova si
|
||||||
|
legge da solo.
|
||||||
|
"""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_task_carries_a_vision_graph(db_session, maker_user):
|
||||||
|
"""Il grafo si salva e si rilegge identico: il server non lo interpreta."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-1")
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
graph = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "diametro del foro",
|
||||||
|
"tools": [{"id": "t1", "kind": "DetectCircle", "name": "foro"}],
|
||||||
|
}
|
||||||
|
task.vision_json = graph
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
reloaded = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == task.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert reloaded.vision_json == graph
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subtask_names_the_output_that_fills_it(db_session, maker_user):
|
||||||
|
"""La quota dichiara quale uscita del grafo le compete."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-2")
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
reloaded = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert reloaded.vision_output == "t1.diameter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_typo_is_gone():
|
||||||
|
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
|
||||||
|
assert "dxf_compare" in TASK_TYPES
|
||||||
|
assert "xf_compare" not in TASK_TYPES
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""The Maker composes at a desk: image saved, graph tried, no measurement."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
def an_image() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker_says(monkeypatch):
|
||||||
|
def _install(outputs: dict):
|
||||||
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
return {"outputs": outputs, "failures": [],
|
||||||
|
"engine_version": "b" * 40, "duration_ms": 7}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", fake_call,
|
||||||
|
)
|
||||||
|
return _install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
"""Keep reference images out of the real repo tree during tests.
|
||||||
|
|
||||||
|
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
|
||||||
|
replaces the left operand of `/` when the right one is absolute - so
|
||||||
|
redirecting the bare `upload_dir` setting to pytest's absolute `tmp_path`
|
||||||
|
is enough to redirect the resolved path too.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_reference_image_keeps_its_provenance(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("ref.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "note": "sample part"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = (await db_session.execute(
|
||||||
|
select(VisionReferenceImage)
|
||||||
|
)).scalars().one()
|
||||||
|
assert saved.task_id == task.id
|
||||||
|
assert saved.note == "sample part"
|
||||||
|
assert saved.acquired_at is not None
|
||||||
|
assert saved.path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_runs_the_graph_and_saves_no_measurement(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": 42.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("ref.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["outputs"]["t1.diameter"] == 42.0
|
||||||
|
# This is the whole point of this test: composing does not measure.
|
||||||
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""I3: the three vision endpoints must not accept an unbounded or wrong-typed
|
||||||
|
upload. `/execute`, `/preview` and `/reference-images` read the whole image
|
||||||
|
into memory with no size cap, and `/reference-images` wrote whatever arrived
|
||||||
|
to disk with a `.png` extension regardless of its real type - a PDF is not
|
||||||
|
something a vision graph can measure.
|
||||||
|
|
||||||
|
Reuses the project's existing convention (`validate_file_size`,
|
||||||
|
`ALLOWED_IMAGE_TYPES` from `files.py`) instead of inventing a second one.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
"""Keep the reference image this file stores out of the real repo tree.
|
||||||
|
|
||||||
|
The same guard `test_vision_reference.py` carries: without it, the test that
|
||||||
|
proves a jpeg is stored with its own extension leaves a real file under
|
||||||
|
`uploads/vision/reference/`, so every suite run dirties the working tree.
|
||||||
|
|
||||||
|
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
|
||||||
|
replaces the left operand of `/` when the right one is absolute - so
|
||||||
|
redirecting the bare `upload_dir` setting is enough to redirect the
|
||||||
|
resolved path too.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def a_png() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def a_jpeg() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (64, 64), color=0).save(buffer, format="JPEG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker_says(monkeypatch):
|
||||||
|
def _install(outputs: dict):
|
||||||
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
return {"outputs": outputs, "failures": [],
|
||||||
|
"engine_version": "c" * 40, "duration_ms": 5}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", fake_call,
|
||||||
|
)
|
||||||
|
return _install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_rejects_a_non_image_upload(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
|
||||||
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_rejects_an_oversized_upload(
|
||||||
|
client, db_session, measurement_tec_user, worker_says, monkeypatch,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", a_png(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_rejects_a_non_image_upload(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reference_images_rejects_a_non_image_upload(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (await db_session.execute(
|
||||||
|
select(VisionReferenceImage)
|
||||||
|
)).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reference_image_keeps_the_extension_matching_its_real_type(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
"""A jpeg upload must not be written to disk as `<uuid>.png`."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.jpg", a_jpeg(), "image/jpeg")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||||
|
assert saved.path.endswith(".jpg")
|
||||||
|
assert (settings.upload_path / saved.path).exists()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""I2: a broken worker must answer with a named status, never a naked 500.
|
||||||
|
|
||||||
|
`_call_worker` mapped a non-200 HTTP response to 502, but let
|
||||||
|
`httpx.ConnectError` (worker stopped) and `httpx.ReadTimeout` (job past the
|
||||||
|
120s budget) propagate unhandled - with no exception handler in main.py that
|
||||||
|
turns those into anything but FastAPI's generic 500. It also read
|
||||||
|
`report["outputs"]`, `report["failures"]` and `report["engine_version"]`
|
||||||
|
without checking the response had that shape at all, so a proxy answering 200
|
||||||
|
with something else (HTML, an empty body) would raise a bare KeyError instead
|
||||||
|
of a diagnosable error.
|
||||||
|
|
||||||
|
The spec's edge-case table: "il worker isola il guasto, l'API resta in
|
||||||
|
piedi, il task riporta l'errore" - the worker's fault must not become the
|
||||||
|
API's fault.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.backend.services.vision_service import _call_worker
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
"""Stands in for httpx.Response: only what `_call_worker` touches."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, body=..., text: str = "", raise_on_json: bool = False):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._body = body
|
||||||
|
self.text = text
|
||||||
|
self._raise_on_json = raise_on_json
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if self._raise_on_json:
|
||||||
|
raise ValueError("not JSON")
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncClient:
|
||||||
|
"""Stands in for httpx.AsyncClient: post() does whatever the test wants."""
|
||||||
|
|
||||||
|
def __init__(self, *, response=None, raises=None):
|
||||||
|
self._response = response
|
||||||
|
self._raises = raises
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, *args, **kwargs):
|
||||||
|
if self._raises is not None:
|
||||||
|
raise self._raises
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
def _install(monkeypatch, *, response=None, raises=None):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service.httpx.AsyncClient",
|
||||||
|
lambda *a, **k: _FakeAsyncClient(response=response, raises=raises),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_stopped_worker_answers_502_not_a_naked_500(monkeypatch):
|
||||||
|
_install(monkeypatch, raises=httpx.ConnectError("Connection refused"))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 502
|
||||||
|
assert "unreachable" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_job_past_the_time_budget_answers_504(monkeypatch):
|
||||||
|
_install(monkeypatch, raises=httpx.ReadTimeout("timed out"))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 504
|
||||||
|
assert "time" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_response_missing_the_expected_keys_is_reported_not_raised_raw(monkeypatch):
|
||||||
|
"""A proxy answering 200 with an unexpected shape must not KeyError."""
|
||||||
|
_install(monkeypatch, response=_FakeResponse(200, body={"outputs": {}}))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 502
|
||||||
|
assert "malformed" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_response_that_is_not_a_json_object_is_reported(monkeypatch):
|
||||||
|
"""200 with HTML or a bare list, say - not a dict at all."""
|
||||||
|
_install(monkeypatch, response=_FakeResponse(200, body=["not", "a", "dict"]))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 502
|
||||||
|
assert "malformed" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_well_formed_response_still_passes_through(monkeypatch):
|
||||||
|
body = {"outputs": {"t1.diameter": 10.0}, "failures": [], "engine_version": "a" * 40}
|
||||||
|
_install(monkeypatch, response=_FakeResponse(200, body=body))
|
||||||
|
|
||||||
|
report = await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert report == body
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
'measure': _('Misura'),
|
'measure': _('Misura'),
|
||||||
'drawing': _('Disegno'),
|
'drawing': _('Disegno'),
|
||||||
'note': _('Nota'),
|
'note': _('Nota'),
|
||||||
'xf_compare': _('Confronto profilo'),
|
'dxf_compare': _('Confronto profilo'),
|
||||||
'camera_measure': _('Misura camera'),
|
'camera_measure': _('Misura camera'),
|
||||||
} %}
|
} %}
|
||||||
{% set is_measure = task.task_type in ('measure', 'camera_measure') %}
|
{% set is_measure = task.task_type in ('measure', 'camera_measure') %}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ cdn...">` because it is the quickest way to try something, it works at their des
|
|||||||
and nobody finds out until the machine is on the shop floor.
|
and nobody finds out until the machine is on the shop floor.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -32,11 +33,42 @@ _DOC_HOSTS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tracked_files():
|
||||||
|
"""The files git knows about, or None when git cannot answer.
|
||||||
|
|
||||||
|
Being outside `vendor/` is not the same as being ours. A scratch copy of a
|
||||||
|
library dropped into `static/js/` to debug something looks first-party and is
|
||||||
|
not: it is not in the repository, it never reaches the shop floor, and its
|
||||||
|
comments are not our comments. Trackedness is the honest test for that.
|
||||||
|
|
||||||
|
Trackedness, and not `.gitignore`: an ignore pattern says nothing about a
|
||||||
|
file that is already tracked, so matching patterns would be the wrong
|
||||||
|
question.
|
||||||
|
|
||||||
|
When git cannot answer we scan everything, as before. A guard test that goes
|
||||||
|
quiet the moment it loses its footing is worse than one that cries wolf.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
listed = subprocess.run(
|
||||||
|
["git", "ls-files", "-z"],
|
||||||
|
cwd=APP_ROOT, capture_output=True, text=True, check=True,
|
||||||
|
).stdout
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return {(APP_ROOT / name).resolve() for name in listed.split("\0") if name}
|
||||||
|
|
||||||
|
|
||||||
def _first_party_files(suffixes):
|
def _first_party_files(suffixes):
|
||||||
|
tracked = _tracked_files()
|
||||||
|
|
||||||
|
def is_ours(path):
|
||||||
|
return tracked is None or path.resolve() in tracked
|
||||||
|
|
||||||
for path in sorted(STATIC.rglob("*")):
|
for path in sorted(STATIC.rglob("*")):
|
||||||
if path.suffix in suffixes and "vendor" not in path.parts:
|
if path.suffix in suffixes and "vendor" not in path.parts and is_ours(path):
|
||||||
yield path
|
yield path
|
||||||
for path in sorted(TEMPLATES.rglob("*.html")):
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||||
|
if is_ours(path):
|
||||||
yield path
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Graph plus image in, numbers out.
|
||||||
|
|
||||||
|
The one implementation of vision execution: the worker beside the server and
|
||||||
|
the station agent both call this, and they must agree to the digit. No HTTP, no
|
||||||
|
database, no filesystem beyond reading the pinned submodule's commit.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from visionsuite.core.image import Image
|
||||||
|
from visionsuite.task import Task
|
||||||
|
|
||||||
|
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolFailure:
|
||||||
|
tool_id: str
|
||||||
|
tool_name: str
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunOutcome:
|
||||||
|
outputs: dict[str, float] = field(default_factory=dict)
|
||||||
|
failures: list[ToolFailure] = field(default_factory=list)
|
||||||
|
engine_version: str = ""
|
||||||
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _engine_version_from_git() -> str:
|
||||||
|
"""Read the pinned commit from the submodule checkout.
|
||||||
|
|
||||||
|
Cached because the subprocess call is not free and the answer cannot
|
||||||
|
change within a run. Kept separate from `engine_version()` so that the
|
||||||
|
(cheap) environment-variable check below is never served a stale answer,
|
||||||
|
and so a test can force this path to fail without poisoning the cache
|
||||||
|
for real callers.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(
|
||||||
|
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
|
||||||
|
text=True,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
).strip()
|
||||||
|
except (OSError, subprocess.CalledProcessError) as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"engine_version: cannot determine which VisionSuite commit this "
|
||||||
|
"process is running. VISION_ENGINE_VERSION is not set, and "
|
||||||
|
f"`git -C {_SUBMODULE} rev-parse HEAD` failed ({exc}). "
|
||||||
|
"In the vision worker container this is expected - it has no "
|
||||||
|
"usable .git for the vendored submodule - so the image must be "
|
||||||
|
"built with `--build-arg VISION_ENGINE_VERSION=<commit>` "
|
||||||
|
"(see Dockerfile.vision). In a development checkout, make sure "
|
||||||
|
"the vendor/visionsuite submodule is initialised. Refusing to "
|
||||||
|
"guess: a station must not measure with an engine it cannot name."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def engine_version() -> str:
|
||||||
|
"""The pinned VisionSuite commit: stamped on every number produced.
|
||||||
|
|
||||||
|
Resolution order: the VISION_ENGINE_VERSION environment variable (set at
|
||||||
|
container build time - Ruling R8, see Dockerfile.vision) if set and
|
||||||
|
non-empty; otherwise `git rev-parse HEAD` on the vendored submodule,
|
||||||
|
which works in a development checkout. Never falls back to a guess, an
|
||||||
|
empty string, or "unknown" - a station running an engine it cannot name
|
||||||
|
must refuse to measure rather than produce numbers nobody can trace.
|
||||||
|
"""
|
||||||
|
stamped = os.environ.get("VISION_ENGINE_VERSION", "").strip()
|
||||||
|
if stamped:
|
||||||
|
return stamped
|
||||||
|
return _engine_version_from_git()
|
||||||
|
|
||||||
|
|
||||||
|
def run_graph(
|
||||||
|
image: Any,
|
||||||
|
graph: dict,
|
||||||
|
calibration: dict | None = None,
|
||||||
|
) -> RunOutcome:
|
||||||
|
"""Run a serialised vs-task graph against one image.
|
||||||
|
|
||||||
|
A tool that fails does not stop the others - on a crooked piece an edge may
|
||||||
|
simply not be there, and the other quotes are still wanted. Failures come
|
||||||
|
back named, they are not swallowed.
|
||||||
|
|
||||||
|
`image` is whatever the caller already has in hand: a plain numpy array is
|
||||||
|
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
|
||||||
|
actually read via `.pixels`; an `Image` passed in is used as-is.
|
||||||
|
"""
|
||||||
|
task = Task.from_dict(graph)
|
||||||
|
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
|
||||||
|
context: dict[str, Any] = {"image": wrapped_image}
|
||||||
|
if calibration is not None:
|
||||||
|
context["calibration"] = calibration
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
report = task.run(context=context)
|
||||||
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
outputs: dict[str, float] = {}
|
||||||
|
failures: list[ToolFailure] = []
|
||||||
|
for result in report:
|
||||||
|
if not result.ok:
|
||||||
|
failures.append(ToolFailure(
|
||||||
|
tool_id=result.tool_id,
|
||||||
|
tool_name=result.tool_name,
|
||||||
|
error=result.error or "unknown failure",
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
for name, value in result.outputs.items():
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
outputs[f"{result.tool_id}.{name}"] = float(value)
|
||||||
|
|
||||||
|
return RunOutcome(
|
||||||
|
outputs=outputs,
|
||||||
|
failures=failures,
|
||||||
|
engine_version=engine_version(),
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""The runner: graph plus image in, numbers out. No HTTP, no database."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# The vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d declare it
|
||||||
|
# upstream, see pyproject.toml's `vision` extra), while backend and frontend
|
||||||
|
# stay on 3.11. This must run before `import numpy` - numpy itself is only
|
||||||
|
# installed alongside the vision extra - so that `uv run pytest` on the
|
||||||
|
# default 3.11 environment reports this module as skipped, with a reason,
|
||||||
|
# instead of erroring out of the whole collection.
|
||||||
|
pytest.importorskip(
|
||||||
|
"visionsuite.task",
|
||||||
|
reason=(
|
||||||
|
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
|
||||||
|
"declare it upstream); run it with "
|
||||||
|
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from src.vision import runner
|
||||||
|
from src.vision.runner import RunOutcome, engine_version, run_graph
|
||||||
|
|
||||||
|
|
||||||
|
def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
||||||
|
"""A light disc on a dark ground: a fake part, but a measurable one."""
|
||||||
|
image = np.zeros((size, size), dtype=np.uint8)
|
||||||
|
yy, xx = np.ogrid[:size, :size]
|
||||||
|
centre = size // 2
|
||||||
|
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def a_circle_graph() -> dict:
|
||||||
|
"""A two-tool graph, built with vs-task's own API."""
|
||||||
|
from visionsuite.task import Task
|
||||||
|
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
||||||
|
|
||||||
|
task = Task(name="prova")
|
||||||
|
found = task.add(DetectCircleTool(name="disco"))
|
||||||
|
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||||
|
found.scan_size = 30.0
|
||||||
|
|
||||||
|
quota = task.add(DiameterTool(name="diametro"))
|
||||||
|
quota.link("circle", found, "circle")
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_graph_produces_named_outputs():
|
||||||
|
outcome = run_graph(a_disc(), a_circle_graph())
|
||||||
|
|
||||||
|
assert isinstance(outcome, RunOutcome)
|
||||||
|
assert outcome.failures == []
|
||||||
|
# Keys are "tool id dot output name" - the same shape a subtask stores in
|
||||||
|
# vision_output.
|
||||||
|
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
||||||
|
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".diameter"))
|
||||||
|
assert diameter == pytest.approx(120.0, abs=4.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_tool_does_not_sink_the_others():
|
||||||
|
"""vs-task does not stop the run on an error: the runner reports it instead."""
|
||||||
|
graph = a_circle_graph()
|
||||||
|
graph["tools"].append(
|
||||||
|
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
||||||
|
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = run_graph(a_disc(), graph)
|
||||||
|
|
||||||
|
assert [f.tool_id for f in outcome.failures] == ["ghost"]
|
||||||
|
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_engine_says_which_version_it_is():
|
||||||
|
version = engine_version()
|
||||||
|
assert len(version) == 40 # a full git SHA
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_version_honours_the_environment_variable(monkeypatch):
|
||||||
|
"""A build-stamped VISION_ENGINE_VERSION wins over git, if set (Ruling R8:
|
||||||
|
the container has no usable .git for the vendored submodule, so the
|
||||||
|
worker image stamps the commit at build time instead of discovering it)."""
|
||||||
|
stamped = "a" * 40
|
||||||
|
monkeypatch.setenv("VISION_ENGINE_VERSION", stamped)
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert engine_version() == stamped
|
||||||
|
finally:
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
|
||||||
|
"""No env var and no resolvable git HEAD: refuse loudly. An engine that
|
||||||
|
cannot say what it is has no business producing measurements - it must
|
||||||
|
never fall back to a guess, an empty string, or "unknown"."""
|
||||||
|
monkeypatch.delenv("VISION_ENGINE_VERSION", raising=False)
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|
||||||
|
def fake_check_output(*args, **kwargs):
|
||||||
|
raise FileNotFoundError("git: command not found")
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner.subprocess, "check_output", fake_check_output)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(RuntimeError, match="VISION_ENGINE_VERSION"):
|
||||||
|
engine_version()
|
||||||
|
finally:
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""The heavy container: the runner behind an internal API.
|
||||||
|
|
||||||
|
Separate from the FastAPI server on purpose. The API image stays light, a
|
||||||
|
VisionSuite upgrade does not restart production traffic, and an execution that
|
||||||
|
crashes does not take the other tablets' requests down with it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.vision.runner import engine_version, run_graph
|
||||||
|
|
||||||
|
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"status": "ok", "engine_version": engine_version()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/run")
|
||||||
|
async def run(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
graph: str = Form(...),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
parsed_graph = json.loads(graph)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"graph is not valid JSON: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
frame = np.array(Image.open(image.file).convert("L"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
outcome = run_graph(frame, parsed_graph)
|
||||||
|
except ValueError as exc:
|
||||||
|
# from_dict refuses a schema version it does not handle; that is a bad
|
||||||
|
# request, not a server fault.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"outputs": outcome.outputs,
|
||||||
|
"failures": [
|
||||||
|
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
|
||||||
|
for f in outcome.failures
|
||||||
|
],
|
||||||
|
"engine_version": outcome.engine_version,
|
||||||
|
"duration_ms": outcome.duration_ms,
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""The worker is a thin shell: it receives an image and a graph, and answers
|
||||||
|
with numbers."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Same reasoning as src/vision/tests/test_runner.py: the vision tree (and
|
||||||
|
# therefore this worker, which imports src.vision.runner) requires Python
|
||||||
|
# >=3.13. This must run before `import numpy` / `from src.vision_worker.main
|
||||||
|
# import app` so that `uv run pytest` on the default 3.11 environment reports
|
||||||
|
# this module as skipped, with a reason, instead of erroring out of
|
||||||
|
# collection.
|
||||||
|
pytest.importorskip(
|
||||||
|
"visionsuite.task",
|
||||||
|
reason=(
|
||||||
|
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
|
||||||
|
"declare it upstream); run it with "
|
||||||
|
"`uv run --python 3.13 --extra vision-worker --extra dev pytest "
|
||||||
|
"src/vision_worker/tests`"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.vision_worker.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
|
||||||
|
image = np.zeros((size, size), dtype=np.uint8)
|
||||||
|
yy, xx = np.ogrid[:size, :size]
|
||||||
|
centre = size // 2
|
||||||
|
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.fromarray(image).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def a_circle_graph() -> dict:
|
||||||
|
from visionsuite.task import Task
|
||||||
|
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
||||||
|
|
||||||
|
task = Task(name="prova")
|
||||||
|
found = task.add(DetectCircleTool(name="disco"))
|
||||||
|
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||||
|
found.scan_size = 30.0
|
||||||
|
quota = task.add(DiameterTool(name="diametro"))
|
||||||
|
quota.link("circle", found, "circle")
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_says_which_engine_it_carries():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||||
|
response = await client.get("/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()["engine_version"]) == 40
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_returns_the_named_outputs():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/run",
|
||||||
|
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||||
|
data={"graph": json.dumps(a_circle_graph())},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["failures"] == []
|
||||||
|
assert any(key.endswith(".diameter") for key in body["outputs"])
|
||||||
|
# Pinned to src/vision/tests/test_runner.py's in-process expectation
|
||||||
|
# (same graph, same image, same tolerance) - the whole point of a shared
|
||||||
|
# engine_version is that the number does not depend on which path
|
||||||
|
# computed it.
|
||||||
|
diameter = next(v for k, v in body["outputs"].items() if k.endswith(".diameter"))
|
||||||
|
assert diameter == pytest.approx(120.0, abs=4.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_malformed_graph_is_refused_not_crashed():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/run",
|
||||||
|
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||||
|
data={"graph": json.dumps({"schema_version": 99, "tools": []})},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "99" in response.json()["detail"]
|
||||||
@@ -941,6 +941,35 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "llvmlite"
|
||||||
|
version = "0.49.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/27/72ae94ea5c8f7349ec1c229d4cd058feb799cbd0833ad6d1b47c919b37b7/llvmlite-0.49.0.tar.gz", hash = "sha256:00f16db782f4a13c78c5804aedc434e46794a77e89999a168f9401106270e50a", size = 194467, upload-time = "2026-08-11T16:26:00.489Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/d0/ab52de2328e97ca96cdf0331a5f774796bddc420a51768f4501193f80cbb/llvmlite-0.49.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:4b0e710880b7cc910392bd6b9f1bbf468fed99b182e4420d51598f36114b3dce", size = 40479230, upload-time = "2026-08-11T16:23:28.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/80/0989432d12b7c86a6f5f380eb92eca7de779af9b34dedbd311b694d7da8d/llvmlite-0.49.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a8c0fc9d624bdc30a3d2db11eb2fb98f80fb209d20b37604eda516cd9b699cf4", size = 59890659, upload-time = "2026-08-11T16:23:37.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/e9/76859ca36aaa460b6ae0508e01637f0e9bdb9b59faaa4637ade3b94bbcca/llvmlite-0.49.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20496a5c9fdb8179fb9300e7d19f6782555d98aeeb4a322264aa7fd99f980618", size = 58344482, upload-time = "2026-08-11T16:23:44.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/49/47cd23e05d52d117b6119871ec299adedc9d8d332a2296964d9b2adc06d9/llvmlite-0.49.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a5b06c1b5fc4ae4c9b169b065f42b719448ef1f873687ef224ef69969b75ec3", size = 41865253, upload-time = "2026-08-11T16:23:50.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/ae/3f699ebe3590e15e023a6372dd147526fd8ec398aacf9ceb844e854964a8/llvmlite-0.49.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b541c8fac3450db7574d1f53cf9dff83f285bfed9d69bf81fe71fc2a7d4f97fe", size = 40479231, upload-time = "2026-08-11T16:23:56.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/3c/e97f69c62a2d972066d9a2612ce1f3de313035ac897a5b9f787cad8b55f7/llvmlite-0.49.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6acba646d88abbc87d5c113a3d62c1fbf8b8fee11c6493f516803e30f21ae870", size = 59890658, upload-time = "2026-08-11T16:24:05.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/e6/e942ee08605fc0526ff3854260c384d8315a5830e16c4c2a5aebc14dc9bf/llvmlite-0.49.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec8ad805e7515cb8440a690eb3cef4d34acb29eef80b705ec4e1c1ad3c43c68", size = 58344481, upload-time = "2026-08-11T16:24:13.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/49/2a44871cac6b5a2fd4aabd68cfdaf6de9a5c7edb36dee5d47b77bda4b50f/llvmlite-0.49.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a9c9e3af4e214acfefa4f73ebe7bc3fb35854a62b654edb3953f5ae33c08ba3", size = 41865543, upload-time = "2026-08-11T16:24:20.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/85/0b536a3c59f2636d9dd51dda832b6c1d0ffec37608429dedf128664918f1/llvmlite-0.49.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:039fa4054a06f537fb39248d4472284ca96be311a142ec09e69f95630ab469cc", size = 40479230, upload-time = "2026-08-11T16:24:27.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/1c/ca8ba47b057b793099784475499771780ec46839f2782f753a7079d23520/llvmlite-0.49.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddc7aecd4f56397ed6e8f120ec5dcd5a1a8f0e6032ca4af413462792d4dca2e3", size = 59890659, upload-time = "2026-08-11T16:24:35.595Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/af/9526dfdd33a923f33e29a18b8f9801ee7ee4b7397e88d28192c1024c4a75/llvmlite-0.49.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dee64784201b64c13a8df62c48a4f4218858faaa65889866bb29bdc243c038", size = 58344482, upload-time = "2026-08-11T16:24:45.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/7f/9f5afcf6476b228d6b170408f377a0c4f91477fc1fc91f8141088b45bf46/llvmlite-0.49.0-cp313-cp313-win_amd64.whl", hash = "sha256:a1b414dc6b164738ec39dd8987cea73829057b7dd92fc6d91b52838385fc1dd2", size = 41865544, upload-time = "2026-08-11T16:24:53.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/d1/16599b8c9f21802448059482eab48a9e74086dc56b901a677ba355565e64/llvmlite-0.49.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:80a84683d04516bb51da1bbeebddaf2c2f558809c93078a8f91807909ae331f8", size = 40479230, upload-time = "2026-08-11T16:25:01.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/61/0b23849141a4c4e7091fcd158ebb45195896974bebca3e58fee7cad4b4f4/llvmlite-0.49.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4281a0171d66d2098adce4ba706b8c550b1b10718650f682d64cde16e84e4de5", size = 59890659, upload-time = "2026-08-11T16:25:08.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/92/628692b74b31e27af9ba7e8ba651941ee4956959d5478123c453f59aad4a/llvmlite-0.49.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b095f15fb12c4d90495df5b1a3772b4732cc408398b204a787dbedd370e09c69", size = 58344479, upload-time = "2026-08-11T16:25:15.731Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/8a/412fc273521b02cbfe0b5f8ad56cc696385f6eaeecdb9e9ae6a90111d98d/llvmlite-0.49.0-cp314-cp314-win_amd64.whl", hash = "sha256:294e2f0b70aef8f92d0ae7b203e2609f08beb39437eee73de59a21669331aae9", size = 42986588, upload-time = "2026-08-11T16:25:22.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/15/f47cf45c00c8b165ac3d268502dcb21d900e86f27fd338268a66ce922ab0/llvmlite-0.49.0-cp314-cp314-win_arm64.whl", hash = "sha256:95d1071023ed858b79f6971954fd7cc1f5dbcbab987718a4ccbe1411e47d0b81", size = 37441881, upload-time = "2026-08-11T16:25:28.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/2e/eafd488766d1c02413cba24f7b22acb9b3ccdfd8407e98d30eb16bac4e2a/llvmlite-0.49.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72", size = 40479230, upload-time = "2026-08-11T16:25:35.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/07/a2c4f04e2111ccc274b4d5e3331398a9dcf6d6e5e55d6444b1ad9d6381cf/llvmlite-0.49.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5555ea1d63928481cbf7fcb1d67452b216c7e5b393a4eb7aa1401e67f2a4fc4", size = 59890658, upload-time = "2026-08-11T16:25:43.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/f9/7b7b50f80b4585bcd78675ff3110c256877b11df32a8cde284f851762f57/llvmlite-0.49.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32adb84fdaae28aeb86fdb6253084ee707ee157289a2e98fe3caf48a62bee82", size = 58344482, upload-time = "2026-08-11T16:25:51.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/c6/32d68bfbf1d0c36888530ef6fd72864861af23dc546302b41033471a8c3a/llvmlite-0.49.0-cp314-cp314t-win_amd64.whl", hash = "sha256:be637e465010bc9c50f070468f7f1cf5385e92fee364d192dd5e6cea790ecba9", size = 42986602, upload-time = "2026-08-11T16:25:57.69Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "logistro"
|
name = "logistro"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1045,6 +1074,131 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numba"
|
||||||
|
version = "0.67.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "llvmlite", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
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 = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/ed/55ba4e54ee878396de6b18e6533cc4a92fa519e8c82d55cf40f98c0a6831/numba-0.67.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3fa3d1b27f96f2c0d54513d953d7197886aa1eaa7d2439a0eedc44d993fb181a", size = 2744821, upload-time = "2026-08-11T23:03:17.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/78/3f3c45dbaec3cf02bbb1825731beca50f591227e95143d6bd7a64897641c/numba-0.67.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c80c847301dc33dc8f84a97a952004023d9a05578ae4512b087176264cc1960", size = 3827182, upload-time = "2026-08-11T23:03:19.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/24/4e70cb86534283d859c3aea2302da523e41539b98dd6c3c4d0a42af95cda/numba-0.67.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7a7b0121466f1e9a8a074b0545fe90e16389623abf979b5d7c299dca1294d7e", size = 3532817, upload-time = "2026-08-11T23:03:22.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/4d/23dab7f4233be0fc34f54a169ed85238467cd24d8adf2498e5c12ea19dc7/numba-0.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfba1ac34f0363fb1a250a10e97240780d11e05227892f7286b26fbfd0ad58ce", size = 2815700, upload-time = "2026-08-11T23:03:23.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/58/915cddba90010348ed0444451132fdde9b000bcbaff1582029b5bf115d11/numba-0.67.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6004d8d5f28d4028687fb2d972d629295b13685943bd2ed5cd8810c3b848e219", size = 2745050, upload-time = "2026-08-11T23:03:25.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/38/926757caaac18a66f057d7544a63620bf360a07d281c9f7ecadd2aa83963/numba-0.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f63d43db06b4756424d6d2484737c902e0ae944a0eec3e8b0b4de2c695b15caa", size = 3884596, upload-time = "2026-08-11T23:03:27.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/6d/58291dc58da39d98b32db7f044729f6d8d4920cd9622fbab3179b54ff4c4/numba-0.67.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76d3335aaeffb9dc88309420890e73497a00be08a7530441bc2b58ffe025bfa5", size = 3585290, upload-time = "2026-08-11T23:03:29.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/63/ab21828b4056afed71f9ecb40f4de26c2c19de731cc001961aca74b79464/numba-0.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:50e2b72406c18cda5dd7431b0082cb85ea94e06c64c33607248fc8bef92cfb81", size = 2815645, upload-time = "2026-08-11T23:03:31.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/dd/bd9fe772f6c84597b76cac229b3f2890f01a2c64fd70e48ceaae10dd65cb/numba-0.67.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:77e1c7173fee57a0d84e006c7e70346689d6cb3e7db503489bae58646b4eff7b", size = 2744872, upload-time = "2026-08-11T23:03:33.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/1c/c05609739cc41116d36e30cb2b41fb00f126bb52e1b0bac907051ad8a35d/numba-0.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c4953387c77864b596d8296e2cfbdef82b0eea4166ab4864b05d226c51143e0", size = 3892004, upload-time = "2026-08-11T23:03:35.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/77/a5276ad4178250403e0e2251f3e1f8ac18feac779b0474a8bcb08558490d/numba-0.67.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88f6e0f5cb6c545e158b6ef0496c01b6d6958a7ccc6634a1576a94bbbab29ff2", size = 3591878, upload-time = "2026-08-11T23:03:37.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/80/d48f0ba7442516ceb5a1585f0c81d3aa531bc96bfcabcd9f8f925768c426/numba-0.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:b68ad5125fe245339cc8dcc036081fc1ea482c5063387b9612a76ccd83dc91cd", size = 2815504, upload-time = "2026-08-11T23:03:39.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/16/345b1e4774a08247aafcfdb93d4e8d24a3646366cbe72de33053fc0de1b5/numba-0.67.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f99f880ff25f418a67f9a1d00d0ddfbc63430f627b523e515085a592a7567f4b", size = 2745088, upload-time = "2026-08-11T23:03:41.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/36/e614ba2bc0f005ed0f37a6413f08fe705210297ddb9a37a475a8b9fdab61/numba-0.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269245a675abdd3e2c35ec6bb2f250355effa9032514d8f2354f0d2d10854bd", size = 3861040, upload-time = "2026-08-11T23:03:43.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/90/30c42a1dbc4176cf355e8e8be61803732c55597b1332925fe233912a43d9/numba-0.67.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f074a8e23db78490f11a3930c940be758316c10ac5985be83d2f298dc080acf7", size = 3561811, upload-time = "2026-08-11T23:03:46.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/6d/21bd16f770476e394c5e5f504935817967442a71251d6b86c244a2767980/numba-0.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4d576e62bf2c9370f61312b51573c4bb1f3fe96798bbab56730847a368a316c4", size = 2817421, upload-time = "2026-08-11T23:03:47.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/06/bb41b0e59b9ff52c94a2f01db24f6437df058caebb377b5f372fc343a6a2/numba-0.67.0-cp314-cp314-win_arm64.whl", hash = "sha256:7930748ce8355d2a5a28602abab056a61fdc676d17377f27d17993905428171f", size = 2788885, upload-time = "2026-08-11T23:03:49.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/7c/aa07151fbd0f4283f78de437cc196f9084789be89a2b4de3fdc2f6a4b414/numba-0.67.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:4a2ed006635bbd0fe45681ed49f3b4f4bad1abf0c233bcc5842c9e3a34cabd61", size = 2748150, upload-time = "2026-08-11T23:03:51.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/62/b8174ca95a4cc1a7ba1520767734e016991545590b8fbde521b681701a9f/numba-0.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa5f002f665bec321b950dacaa26ee009e1d720f6ac9d9856eed5efe1caa03a6", size = 3896986, upload-time = "2026-08-11T23:03:53.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/f9/3a7b6dbf81e01a48958b45ad2239edbc64707522ab17f11f9f18c44bf6d1/numba-0.67.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ab968b0e0fa744eba03351282dd8000796e6ec8e4518f47bd3ed86c0a20c7b", size = 3614644, upload-time = "2026-08-11T23:03:55.794Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/5b/248f5681c121ca853a9f4e39d342a3e01b8a0261b0275853eb3d0d56aa20/numba-0.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00c964a5b94d3ae82d83ac162cd610755875b98dadb779fdde06e6bfcdbca47e", size = 2822870, upload-time = "2026-08-11T23:03:58.097Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opencv-python"
|
||||||
|
version = "5.0.0.93"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
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 = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orjson"
|
name = "orjson"
|
||||||
version = "3.11.8"
|
version = "3.11.8"
|
||||||
@@ -1592,6 +1746,57 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scipy"
|
||||||
|
version = "1.18.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
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 = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simplejson"
|
name = "simplejson"
|
||||||
version = "4.1.1"
|
version = "4.1.1"
|
||||||
@@ -1769,6 +1974,24 @@ server = [
|
|||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
{ name = "weasyprint" },
|
{ name = "weasyprint" },
|
||||||
]
|
]
|
||||||
|
vision = [
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
vision-worker = [
|
||||||
|
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "pillow", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
@@ -1777,6 +2000,7 @@ requires-dist = [
|
|||||||
{ name = "asyncmy", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
{ name = "asyncmy", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
||||||
{ name = "bcrypt", marker = "extra == 'server'", specifier = ">=4.0.0" },
|
{ name = "bcrypt", marker = "extra == 'server'", specifier = ">=4.0.0" },
|
||||||
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||||
|
{ name = "fastapi", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.110.0" },
|
||||||
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110.0" },
|
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110.0" },
|
||||||
{ name = "flask", marker = "extra == 'client'", specifier = ">=3.0.0" },
|
{ name = "flask", marker = "extra == 'client'", specifier = ">=3.0.0" },
|
||||||
{ name = "flask-babel", marker = "extra == 'client'", specifier = ">=4.0.0" },
|
{ name = "flask-babel", marker = "extra == 'client'", specifier = ">=4.0.0" },
|
||||||
@@ -1786,7 +2010,9 @@ requires-dist = [
|
|||||||
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
|
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
|
||||||
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
||||||
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'vision'" },
|
||||||
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
|
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
|
||||||
|
{ name = "pillow", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=10.0.0" },
|
||||||
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
||||||
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
@@ -1794,14 +2020,21 @@ requires-dist = [
|
|||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
|
{ name = "python-multipart", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.0.6" },
|
||||||
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.6" },
|
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.6" },
|
||||||
{ name = "requests", marker = "extra == 'client'", specifier = ">=2.31.0" },
|
{ name = "requests", marker = "extra == 'client'", specifier = ">=2.31.0" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2.0.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2.0.0" },
|
||||||
|
{ name = "tiemeasureflow", extras = ["vision"], marker = "extra == 'vision-worker'" },
|
||||||
{ name = "urllib3", marker = "extra == 'client'", specifier = ">=2.0.0" },
|
{ name = "urllib3", marker = "extra == 'client'", specifier = ">=2.0.0" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.30.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.30.0" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.30.0" },
|
||||||
|
{ name = "visionsuite", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-core" },
|
||||||
|
{ name = "vs-measure", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-measure" },
|
||||||
|
{ name = "vs-pm2d", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-pm2d" },
|
||||||
|
{ name = "vs-task", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-task" },
|
||||||
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
|
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["server", "client", "dev"]
|
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinycss2"
|
name = "tinycss2"
|
||||||
@@ -1919,6 +2152,94 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "visionsuite"
|
||||||
|
version = "0.0.1"
|
||||||
|
source = { editable = "vendor/visionsuite/packages/vs-core" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "ezdxf", marker = "extra == 'dxf'", specifier = ">=1.0" },
|
||||||
|
{ name = "matplotlib", marker = "extra == 'dev'" },
|
||||||
|
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
|
||||||
|
{ name = "numpy", specifier = ">=1.24" },
|
||||||
|
{ name = "opencv-python", specifier = ">=4.8" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev", "fast", "dxf"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vs-measure"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "vendor/visionsuite/packages/vs-measure" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "scipy", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "numpy", specifier = ">=1.24" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||||
|
{ name = "scipy", specifier = ">=1.11" },
|
||||||
|
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vs-pm2d"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "numba", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "pillow", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "numba", specifier = ">=0.65.0" },
|
||||||
|
{ name = "numpy", specifier = ">=1.24" },
|
||||||
|
{ name = "opencv-python", specifier = ">=4.8" },
|
||||||
|
{ name = "pillow", specifier = ">=12.2.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.26" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
|
||||||
|
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "httpx", specifier = ">=0.28.1" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vs-task"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "vendor/visionsuite/packages/vs-task" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "numpy", specifier = ">=1.24" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||||
|
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
|
||||||
|
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchfiles"
|
name = "watchfiles"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|||||||
+1
Submodule vendor/visionsuite added at f095fcc254
Reference in New Issue
Block a user