From 6877d8710ec1806845af9442ced56673fe89180e Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:16:30 +0200 Subject: [PATCH 01/17] feat(temporal): validation service scoring alert windows with the ONNX temporal model POST /jobs takes a window of frames plus YOLO boxes for one camera pose and returns a job id at once; a worker thread scores it with OnnxTemporalModel and GET /jobs/{id} serves the verdict. No torch. --- pyro_temporal_api/Dockerfile | 25 + pyro_temporal_api/pyproject.toml | 25 + .../pyro_temporal_api/__init__.py | 4 + pyro_temporal_api/pyro_temporal_api/jobs.py | 132 ++++ pyro_temporal_api/pyro_temporal_api/main.py | 91 +++ pyro_temporal_api/requirements.txt | 101 +++ pyro_temporal_api/tests/test_jobs.py | 124 ++++ pyro_temporal_api/uv.lock | 695 ++++++++++++++++++ 8 files changed, 1197 insertions(+) create mode 100644 pyro_temporal_api/Dockerfile create mode 100644 pyro_temporal_api/pyproject.toml create mode 100644 pyro_temporal_api/pyro_temporal_api/__init__.py create mode 100644 pyro_temporal_api/pyro_temporal_api/jobs.py create mode 100644 pyro_temporal_api/pyro_temporal_api/main.py create mode 100644 pyro_temporal_api/requirements.txt create mode 100644 pyro_temporal_api/tests/test_jobs.py create mode 100644 pyro_temporal_api/uv.lock diff --git a/pyro_temporal_api/Dockerfile b/pyro_temporal_api/Dockerfile new file mode 100644 index 00000000..d70a5b36 --- /dev/null +++ b/pyro_temporal_api/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11.13-slim-bullseye + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/usr/src/app + +# Layer 1: git for the temporal-model dependency, curl for the healthcheck +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl git \ + || { apt-get update && apt-get install -y --no-install-recommends --fix-missing curl git; } \ + && apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Layer 2: Pip deps (onnxruntime + temporal-model-core, no torch) +COPY --from=ghcr.io/astral-sh/uv:0.6.16 /uv /bin/uv +COPY requirements.txt /tmp/requirements.txt +RUN uv pip install --no-cache --system -r /tmp/requirements.txt && \ + rm /bin/uv /tmp/requirements.txt + +# Layer 3: Source code +WORKDIR /usr/src/app +COPY pyproject.toml ./pyproject.toml +COPY pyro_temporal_api ./pyro_temporal_api + +CMD ["uvicorn", "pyro_temporal_api.main:app", "--host", "0.0.0.0", "--port", "8082"] diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml new file mode 100644 index 00000000..1bdc2f5f --- /dev/null +++ b/pyro_temporal_api/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "pyro_temporal_api" +version = "0.1.0" +description = "FastAPI service validating engine alerts with the temporal smoke model (ONNX, no torch)" +authors = [{ name = "Pyronear", email = "contact@pyronear.org" }] +requires-python = ">=3.11,<3.13" +dependencies = [ + "fastapi>=0.110.0,<0.111", + "uvicorn[standard]>=0.30.0,<0.31", + "python-multipart>=0.0.9,<0.1", + "pyyaml>=6.0", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core", +] + +[dependency-groups] +dev = [ + "pytest>=8.0,<9", + "httpx>=0.27,<1", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/pyro_temporal_api/pyro_temporal_api/__init__.py b/pyro_temporal_api/pyro_temporal_api/__init__.py new file mode 100644 index 00000000..7260a753 --- /dev/null +++ b/pyro_temporal_api/pyro_temporal_api/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py new file mode 100644 index 00000000..054a2894 --- /dev/null +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -0,0 +1,132 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""Validation jobs: one submitted window of frames + boxes, scored by the temporal model in a worker thread.""" + +from __future__ import annotations + +import logging +import queue +import tempfile +import threading +import uuid +from collections import OrderedDict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from temporal_model.core import Detection, FrameDetections + +logger = logging.getLogger(__name__) + +MAX_JOBS_KEPT = 100 + + +@dataclass +class Job: + job_id: str + cam_id: str + frames: list[tuple[str, bytes]] # (frame_id, jpeg bytes), oldest first + boxes: list[list[list[float]]] # per frame: [[x1, y1, x2, y2, conf], ...] normalized xyxy + status: str = "pending" # pending | done | error + verdict: dict[str, Any] | None = None + error: str | None = None + _done: threading.Event = field(default_factory=threading.Event) + + def to_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "cam_id": self.cam_id, + "status": self.status, + "verdict": self.verdict, + "error": self.error, + } + + +def _to_frame_detections(frames: list[Any], boxes: list[list[list[float]]]) -> dict[str, FrameDetections]: + """Engine boxes (normalized xyxy + conf) -> the temporal model's per-frame detections.""" + out = {} + for idx, (frame, frame_boxes) in enumerate(zip(frames, boxes, strict=True)): + out[frame.frame_id] = FrameDetections( + frame_idx=idx, + frame_id=frame.frame_id, + timestamp=frame.timestamp, + detections=[ + Detection( + class_id=0, + cx=(x1 + x2) / 2, + cy=(y1 + y2) / 2, + w=x2 - x1, + h=y2 - y1, + confidence=float(conf), + ) + for x1, y1, x2, y2, conf in frame_boxes + ], + ) + return out + + +def score(model: Any, job: Job) -> dict[str, Any]: + """Run the temporal model on a job's window; frames live on disk only for the call.""" + with tempfile.TemporaryDirectory(prefix="temporal_job_") as td: + paths = [] + for frame_id, data in job.frames: + p = Path(td) / f"{frame_id}.jpg" + p.write_bytes(data) + paths.append(p) + frames = model.load_sequence(paths) + out = model.predict(frames, frame_detections=_to_frame_detections(frames, job.boxes)) + kept = out.details["tubes"]["kept"] + probs = [t["probability"] for t in kept if t["probability"] is not None] + return { + "is_positive": bool(out.is_positive), + "probability": max(probs) if probs else None, + "n_tubes": len(kept), + } + + +class JobStore: + """FIFO of jobs scored one at a time by a daemon thread; keeps the last MAX_JOBS_KEPT results.""" + + def __init__(self, model: Any) -> None: + self._model = model + self._jobs: OrderedDict[str, Job] = OrderedDict() + self._queue: queue.Queue[Job] = queue.Queue() + self._lock = threading.Lock() + self._worker = threading.Thread(target=self._run, name="temporal-worker", daemon=True) + self._worker.start() + + def submit(self, cam_id: str, frames: list[tuple[str, bytes]], boxes: list[list[list[float]]]) -> Job: + job = Job(job_id=uuid.uuid4().hex, cam_id=cam_id, frames=frames, boxes=boxes) + with self._lock: + self._jobs[job.job_id] = job + while len(self._jobs) > MAX_JOBS_KEPT: + self._jobs.popitem(last=False) + self._queue.put(job) + return job + + def get(self, job_id: str) -> Job | None: + with self._lock: + return self._jobs.get(job_id) + + def wait(self, job_id: str, timeout: float) -> bool: + """Block until the job finishes (tests and synchronous callers).""" + job = self.get(job_id) + return job is not None and job._done.wait(timeout) + + def _run(self) -> None: + while True: + job = self._queue.get() + try: + job.verdict = score(self._model, job) + job.status = "done" + logger.info("job %s cam %s: %s", job.job_id, job.cam_id, job.verdict) + except Exception as exc: + job.status = "error" + job.error = f"{type(exc).__name__}: {exc}" + logger.exception("job %s cam %s failed", job.job_id, job.cam_id) + finally: + job.frames = [] # release the JPEGs once scored + job._done.set() diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py new file mode 100644 index 00000000..36a14758 --- /dev/null +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -0,0 +1,91 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""Temporal validation service. + +The engine submits a window of frames + YOLO boxes for one camera pose +(``POST /jobs``) and reads the verdict on its next round (``GET /jobs/{id}``). +Scoring runs in a worker thread so requests return immediately. The model is +the torch-free ``model_onnx.zip`` runtime of pyronear/temporal-model. +""" + +from __future__ import annotations + +import json +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from temporal_model.core.onnx_model import OnnxTemporalModel + +from pyro_temporal_api.jobs import JobStore + +logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO) +logger = logging.getLogger(__name__) + +MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) +MAX_FRAMES = 20 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + model = OnnxTemporalModel.from_package(MODEL_PATH) + app.state.jobs = JobStore(model) + app.state.model_version = model_version(MODEL_PATH) + logger.info("Loaded temporal model %s (version %s)", MODEL_PATH, app.state.model_version) + yield + + +def model_version(path: Path) -> str | None: + import zipfile + + import yaml + + with zipfile.ZipFile(path) as zf: + return yaml.safe_load(zf.read("manifest.yaml")).get("model_version") + + +app = FastAPI(title="Pyro Temporal API", lifespan=lifespan) + + +@app.get("/health") +def health(request: Request): + return {"status": "ok", "model_version": getattr(request.app.state, "model_version", None)} + + +@app.post("/jobs", status_code=202) +async def submit_job( + request: Request, + cam_id: str = Form(...), + boxes: str = Form(..., description="JSON list, one entry per frame: [[x1, y1, x2, y2, conf], ...]"), + frames: list[UploadFile] = File(..., description="JPEG frames, oldest first; filename stem = frame id"), +): + try: + parsed = json.loads(boxes) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=422, detail=f"boxes is not valid JSON: {exc}") from exc + if not isinstance(parsed, list) or not all(isinstance(fb, list) for fb in parsed): + raise HTTPException(status_code=422, detail="boxes must be a list of per-frame box lists") + if not 1 <= len(frames) <= MAX_FRAMES: + raise HTTPException(status_code=422, detail=f"expected 1 to {MAX_FRAMES} frames, got {len(frames)}") + if len(parsed) != len(frames): + raise HTTPException(status_code=422, detail=f"{len(frames)} frames but {len(parsed)} box lists") + for fb in parsed: + if any(not isinstance(b, list) or len(b) != 5 for b in fb): + raise HTTPException(status_code=422, detail="each box must be [x1, y1, x2, y2, conf]") + + payload = [(Path(f.filename or "frame").stem, await f.read()) for f in frames] + job = request.app.state.jobs.submit(cam_id, payload, parsed) + return {"job_id": job.job_id} + + +@app.get("/jobs/{job_id}") +def get_job(request: Request, job_id: str): + job = request.app.state.jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="unknown job") + return job.to_dict() diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt new file mode 100644 index 00000000..e0d091e9 --- /dev/null +++ b/pyro_temporal_api/requirements.txt @@ -0,0 +1,101 @@ +# This file was autogenerated by uv via the following command: +# uv export --no-hashes --no-emit-project --no-default-groups --no-dev --format requirements-txt -o requirements.txt +annotated-types==0.8.0 + # via pydantic +anyio==4.15.1 + # via + # httpx + # starlette + # watchfiles +certifi==2026.7.22 + # via + # httpcore + # httpx +click==8.5.0 + # via + # huggingface-hub + # uvicorn +colorama==0.4.6 ; sys_platform == 'win32' + # via + # tqdm + # uvicorn +fastapi==0.110.3 + # via pyro-temporal-api +filelock==3.32.5 + # via huggingface-hub +flatbuffers==25.12.19 + # via onnxruntime +fsspec==2026.7.0 + # via huggingface-hub +h11==0.16.0 + # via + # httpcore + # uvicorn +hf-xet==1.6.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + # via huggingface-hub +httpcore==1.0.9 + # via httpx +httptools==0.8.0 + # via uvicorn +httpx==0.28.1 + # via huggingface-hub +huggingface-hub==1.30.0 + # via temporal-model-core +idna==3.19 + # via + # anyio + # httpx +numpy==1.26.4 + # via + # onnxruntime + # temporal-model-core +onnxruntime==1.29.0 + # via temporal-model-core +packaging==26.3 + # via + # huggingface-hub + # onnxruntime +pillow==12.3.0 + # via temporal-model-core +protobuf==7.36.1 + # via onnxruntime +pydantic==2.13.5 + # via + # fastapi + # temporal-model-core +pydantic-core==2.46.5 + # via pydantic +python-dotenv==1.2.3 + # via uvicorn +python-multipart==0.0.32 + # via pyro-temporal-api +pyyaml==6.0.3 + # via + # huggingface-hub + # pyro-temporal-api + # temporal-model-core + # uvicorn +starlette==0.37.2 + # via fastapi +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core + # via pyro-temporal-api +tqdm==4.70.0 + # via huggingface-hub +typing-extensions==4.16.0 + # via + # anyio + # fastapi + # huggingface-hub + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.4 + # via pydantic +uvicorn==0.30.6 + # via pyro-temporal-api +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn +watchfiles==1.2.0 + # via uvicorn +websockets==17.1 + # via uvicorn diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py new file mode 100644 index 00000000..d08f7e6a --- /dev/null +++ b/pyro_temporal_api/tests/test_jobs.py @@ -0,0 +1,124 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +import io +import json +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from pyro_temporal_api import main +from pyro_temporal_api.jobs import JobStore, _to_frame_detections + + +class FakeModel: + """Stands in for OnnxTemporalModel: records what it saw, answers from `verdicts`.""" + + def __init__(self, positive=True, probability=0.8, fail=False): + self.positive, self.probability, self.fail = positive, probability, fail + self.calls = [] + + def load_sequence(self, paths): + return [SimpleNamespace(frame_id=p.stem, image_path=p, timestamp=None) for p in paths] + + def predict(self, frames, frame_detections): + if self.fail: + raise RuntimeError("boom") + self.calls.append((frames, frame_detections)) + kept = [{"logit": 1.0, "probability": self.probability}] if frame_detections else [] + return SimpleNamespace(is_positive=self.positive, details={"tubes": {"kept": kept}}) + + +def _jpeg() -> bytes: + buf = io.BytesIO() + Image.new("RGB", (32, 32), "gray").save(buf, format="JPEG") + return buf.getvalue() + + +@pytest.fixture +def client(): + # No `with`: the lifespan (which loads the real model) must not run in tests. + model = FakeModel() + main.app.state.jobs = JobStore(model) + main.app.state.model_version = "test" + return TestClient(main.app), model + + +def _submit(client, n_frames=3, boxes=None, cam_id="192.168.1.10_1"): + files = [("frames", (f"{cam_id}_2026-09-08T10-00-0{i}.jpg", _jpeg(), "image/jpeg")) for i in range(n_frames)] + if boxes is None: + boxes = [[[0.4, 0.4, 0.5, 0.5, 0.3]]] * n_frames + return client.post("/jobs", data={"cam_id": cam_id, "boxes": json.dumps(boxes)}, files=files) + + +def test_health(client): + c, _ = client + assert c.get("/health").json() == {"status": "ok", "model_version": "test"} + + +def test_job_lifecycle_and_conversion(client): + c, model = client + r = _submit(c, n_frames=3) + assert r.status_code == 202 + job_id = r.json()["job_id"] + assert c.app.state.jobs.wait(job_id, timeout=5) + + body = c.get(f"/jobs/{job_id}").json() + assert body["status"] == "done" + assert body["cam_id"] == "192.168.1.10_1" + assert body["verdict"] == {"is_positive": True, "probability": 0.8, "n_tubes": 1} + + frames, dets = model.calls[0] + assert [f.frame_id for f in frames] == [f"192.168.1.10_1_2026-09-08T10-00-0{i}" for i in range(3)] + d = dets[frames[0].frame_id].detections[0] + assert (d.cx, d.cy, d.w, d.h, d.confidence) == pytest.approx((0.45, 0.45, 0.1, 0.1, 0.3)) + assert dets[frames[2].frame_id].frame_idx == 2 + + +def test_model_failure_is_reported_as_error(client): + c, model = client + model.fail = True + job_id = _submit(c).json()["job_id"] + assert c.app.state.jobs.wait(job_id, timeout=5) + body = c.get(f"/jobs/{job_id}").json() + assert body["status"] == "error" + assert "RuntimeError" in body["error"] + assert body["verdict"] is None + + +def test_unknown_job_is_404(client): + c, _ = client + assert c.get("/jobs/nope").status_code == 404 + + +@pytest.mark.parametrize( + ("kwargs", "detail"), + [ + ({"n_frames": 2, "boxes": [[]]}, "box lists"), + ({"n_frames": 1, "boxes": [[[0.1, 0.1, 0.2]]]}, "each box"), + ({"n_frames": 21}, "expected 1 to 20"), + ], +) +def test_rejects_malformed_submissions(client, kwargs, detail): + c, _ = client + r = _submit(c, **kwargs) + assert r.status_code == 422 + assert detail in r.json()["detail"] + + +def test_frames_are_released_after_scoring(client): + c, _ = client + job_id = _submit(c).json()["job_id"] + assert c.app.state.jobs.wait(job_id, timeout=5) + assert c.app.state.jobs.get(job_id).frames == [] + + +def test_to_frame_detections_keeps_empty_frames(): + frames = [SimpleNamespace(frame_id="a", timestamp=None), SimpleNamespace(frame_id="b", timestamp=None)] + dets = _to_frame_detections(frames, [[], [[0, 0, 1, 1, 0.5]]]) + assert dets["a"].detections == [] + assert len(dets["b"].detections) == 1 diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock new file mode 100644 index 00000000..a5e3123c --- /dev/null +++ b/pyro_temporal_api/uv.lock @@ -0,0 +1,695 @@ +version = 1 +revision = 2 +requires-python = ">=3.11, <3.13" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload_time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload_time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload_time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload_time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload_time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload_time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload_time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload_time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.110.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/22/7b9ee50b0a8c48f076a111d6e4071a9d4c25623dc67689c5f3aa375f779b/fastapi-0.110.3.tar.gz", hash = "sha256:555700b0159379e94fdbfc6bb66a0f1c43f4cf7060f25239af3d84b63a656626", size = 287508, upload_time = "2024-04-30T00:38:02.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/d1/5958526c3bdbed74f88bf69b86506db5b25a600207f0f688473667690de6/fastapi-0.110.3-py3-none-any.whl", hash = "sha256:fd7600612f755e4050beb74001310b5a7e1796d149c2ee363124abdfa0289d32", size = 91834, upload_time = "2024-04-30T00:37:58.935Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload_time = "2026-08-31T18:56:34.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload_time = "2026-08-31T18:56:33.078Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload_time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload_time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload_time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload_time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload_time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload_time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload_time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload_time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload_time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload_time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload_time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload_time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload_time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload_time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload_time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload_time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload_time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload_time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload_time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload_time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload_time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload_time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload_time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload_time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload_time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload_time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload_time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/97/2eb4abaa5b969ed385066a0496a3823b3ff467fc1082e2202955f1867d60/huggingface_hub-1.30.0.tar.gz", hash = "sha256:e6a6120bc8c8e2723d03648434ee247088cceb55ba7067e7d34d692cad5fdb57", size = 964291, upload_time = "2026-09-03T10:05:14.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/0e/3e45bbe0dd48f4e56b1d46649d342de853cd1c7e815323472ab62687f153/huggingface_hub-1.30.0-py3-none-any.whl", hash = "sha256:96ae0a8e99a234374a6fe43e989ebd21c04640b91ab2927e7e5773ba1131ca59", size = 796795, upload_time = "2026-09-03T10:05:12.21Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload_time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload_time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload_time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload_time = "2024-02-05T23:51:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload_time = "2024-02-05T23:52:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload_time = "2024-02-05T23:52:47.569Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload_time = "2024-02-05T23:53:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload_time = "2024-02-05T23:53:42.16Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload_time = "2024-02-05T23:54:11.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload_time = "2024-02-05T23:54:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload_time = "2024-02-05T23:54:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload_time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload_time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload_time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload_time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload_time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload_time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload_time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload_time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a8/0520890321b8ff40b908cf165a93eb58fbc8f85c14db637277ea866c9544/onnxruntime-1.29.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f", size = 21420890, upload_time = "2026-08-17T22:53:33.429Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/8bd3e0008ff8d386305351109a7329ea57e51a3ab57bc92340f29c4a5b5d/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16925ef8497e2c07e4b5ae15b504079b3ab3f65e22c58efd10dde0f3caea969a", size = 20803602, upload_time = "2026-08-17T22:53:36.47Z" }, + { url = "https://files.pythonhosted.org/packages/3b/91/a66cd77f28379ede419672edda3184f1eb286db215dce1e7b976fae2d63b/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:85f8e8406c52658735fe5c7fbfd3ebaa1ed340768324f6252e4274e374580a23", size = 23113193, upload_time = "2026-08-17T22:53:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/1c/82/2da968405c42340f03de0bcdb63be09ae1004f820b2295590d48951b5cf2/onnxruntime-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98", size = 13999253, upload_time = "2026-08-17T22:53:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/70c9c893bf732ee66124c2d8de6a21fc9361ec62cf378f857043efcbf0eb/onnxruntime-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:4eae472cf7dc3107dec1bb53cd6d142d1964616d08aae48654cd4254b2363c4b", size = 13741410, upload_time = "2026-08-17T22:53:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload_time = "2026-08-17T22:53:48.286Z" }, + { url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload_time = "2026-08-17T22:53:51.169Z" }, + { url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload_time = "2026-08-17T22:53:53.92Z" }, + { url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload_time = "2026-08-17T22:53:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload_time = "2026-08-17T22:53:58.977Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload_time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload_time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload_time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload_time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload_time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload_time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload_time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload_time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload_time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload_time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload_time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload_time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload_time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload_time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload_time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload_time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload_time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload_time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload_time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload_time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload_time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload_time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload_time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload_time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload_time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload_time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/73/f66c748df06e7fe24e658eddd600d19c4b40bad836c97ce2d0ad9851fb6b/protobuf-7.36.1.tar.gz", hash = "sha256:d0f6470f0ce2b84e3feaea2d4b816378b37ba4d4aa08a274305373de93e2d524", size = 512499, upload_time = "2026-08-31T22:40:04.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/6c/3a54a58f2948b0f485df9ecdd06590f15d0a7abf46a89d50c3de709ff4ff/protobuf-7.36.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:3cf2ee25d006cee57294a1196ea43b37feb78e0dcd1e8af5c1aeddb777655aca", size = 456046, upload_time = "2026-08-31T22:39:56.865Z" }, + { url = "https://files.pythonhosted.org/packages/6e/08/9f9548793c771095245c0eeaf0c84b76b16a5ef26158043d456f551344a0/protobuf-7.36.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:43d3d37b1eb24c113b9b7d02008cac44e423f00b611b7781ae998d7623972969", size = 344226, upload_time = "2026-08-31T22:39:58.263Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/1bdbd612fa3c51e42ea6f45d05e84dc748ddcd2663e1aa1e89a00a33facd/protobuf-7.36.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:39c518c05586c016d7874ff6079ee115bcec1ea5fbb1d177fbf7867ef4c67e44", size = 357229, upload_time = "2026-08-31T22:39:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/22/df/c799fe7a05ef16ba853a59db01f3a2c5f7d0676469589ccc4874f76a2a88/protobuf-7.36.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:97198b77e369a0abd8e262b8f6c7266c55ddb796a3a12c76d7b8881188ed83aa", size = 343228, upload_time = "2026-08-31T22:40:00.179Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/d4724ec5d86d496fe4108e220618aa0837ad3423a7af7ccdd9684ccc77c8/protobuf-7.36.1-cp310-abi3-win32.whl", hash = "sha256:0b53ce95272aad50ad25d7ff03373743209822e8ba42ea7fad27d2bee1547d00", size = 443002, upload_time = "2026-08-31T22:40:01.32Z" }, + { url = "https://files.pythonhosted.org/packages/db/37/155788a0d8daded960375af604202805308169f9b859419ea0aa370946e2/protobuf-7.36.1-cp310-abi3-win_amd64.whl", hash = "sha256:51139351435d9b43d88a55eaa49fb6f737fbb478fb0cbf2cf694d1a04a9d3363", size = 456518, upload_time = "2026-08-31T22:40:02.494Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload_time = "2026-08-31T22:40:03.54Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload_time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload_time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload_time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload_time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload_time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload_time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload_time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload_time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload_time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload_time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload_time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload_time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload_time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload_time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload_time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload_time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload_time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload_time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload_time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload_time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload_time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload_time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload_time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload_time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload_time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload_time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload_time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload_time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload_time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload_time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload_time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload_time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload_time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload_time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload_time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload_time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload_time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload_time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload_time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload_time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload_time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload_time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload_time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload_time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload_time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload_time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload_time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload_time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload_time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload_time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload_time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyro-temporal-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "temporal-model-core", extra = ["onnx"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.110.0,<0.111" }, + { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27,<1" }, + { name = "pytest", specifier = ">=8.0,<9" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload_time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload_time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload_time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload_time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload_time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload_time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload_time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload_time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload_time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload_time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload_time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload_time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload_time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload_time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload_time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload_time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload_time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload_time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload_time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload_time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload_time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload_time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload_time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload_time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload_time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload_time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "starlette" +version = "0.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/b5/6bceb93ff20bd7ca36e6f7c540581abb18f53130fabb30ba526e26fd819b/starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823", size = 2843736, upload_time = "2024-03-05T16:16:54.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/18/31fa32ed6c68ba66220204ef0be798c349d0a20c1901f9d4a794e08c76d8/starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee", size = 71908, upload_time = "2024-03-05T16:16:50.957Z" }, +] + +[[package]] +name = "temporal-model-core" +version = "0.1.0" +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33#449e6b049cc988bd7458ba101249680587eb0b33" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +onnx = [ + { name = "onnxruntime" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload_time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload_time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload_time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload_time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload_time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload_time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.30.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/01/5e637e7aa9dd031be5376b9fb749ec20b86f5a5b6a49b87fabd374d5fa9f/uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788", size = 42825, upload_time = "2024-08-13T09:27:35.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/8e/cdc7d6263db313030e4c257dd5ba3909ebc4e4fb53ad62d5f09b1a2f5458/uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5", size = 62835, upload_time = "2024-08-13T09:27:33.536Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload_time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload_time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload_time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload_time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload_time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload_time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload_time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload_time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload_time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload_time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload_time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload_time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload_time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload_time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload_time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload_time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload_time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload_time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload_time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload_time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload_time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload_time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload_time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload_time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload_time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload_time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload_time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload_time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload_time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload_time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload_time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload_time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload_time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload_time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload_time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload_time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload_time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload_time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload_time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload_time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload_time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload_time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload_time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload_time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload_time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload_time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload_time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload_time = "2026-08-26T14:55:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/1b/344ab22cea729e872f759b926441f7b822ab6cd106db527736afc066927f/websockets-17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9", size = 214667, upload_time = "2026-08-26T14:55:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/bace574b6ae80e1a8d6935b8c5f03fb67236233ec572e976fe826ff719cf/websockets-17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86", size = 214944, upload_time = "2026-08-26T14:55:23.618Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/08e35ca4a0ffafb500a16ff461bf9561ad2b755362adb5d077d4dba9affc/websockets-17.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d", size = 224004, upload_time = "2026-08-26T14:55:24.748Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/00ae2e147eaa086fe8bdddd36f57216ce72b9a9dfc0b17c717005ebdacaf/websockets-17.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695", size = 224278, upload_time = "2026-08-26T14:55:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/98/e2/7aeb4e00defa68826f449392922a382ce7fdf542fe52190558dc1714e284/websockets-17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134", size = 225511, upload_time = "2026-08-26T14:55:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/655be3d93c3edbe1a51606073b5454ea6b1b32d87aa26253a6df952417b7/websockets-17.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7", size = 228802, upload_time = "2026-08-26T14:55:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/98549a2afa9d68fe1b5a8e0a61cd461a43ea1ab7209bce675eea67c79190/websockets-17.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6", size = 226075, upload_time = "2026-08-26T14:55:29.695Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6b/cbc27e014d6c292b9b2709cfd32781a2b61eb30cd4c9130e7c57e41a204a/websockets-17.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750", size = 224846, upload_time = "2026-08-26T14:55:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/e57925f7a423d90f24559e85bac21a7f0b44c0cf4a5c0babc0759ca54bab/websockets-17.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8", size = 222136, upload_time = "2026-08-26T14:55:32.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/b9de0400addb542ba7c819022abc3afa46cd7e518068881bceadac69d995/websockets-17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098", size = 225000, upload_time = "2026-08-26T14:55:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bb/af2828a1d7f2beb792af6ba56d7b02d56262070266b95d2af9ef391fbfb0/websockets-17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a", size = 223592, upload_time = "2026-08-26T14:55:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/7379f254730c1dc7d8e4dd8d686868d8f7be55bdc94c6d3a44538840f639/websockets-17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c", size = 224360, upload_time = "2026-08-26T14:55:35.777Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/fcd91320dd71dda7046df9bc60f60c70ee15c052dee21e31ed6221dc8b5d/websockets-17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405", size = 225404, upload_time = "2026-08-26T14:55:36.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b2/655a4f939388079f80f1b3f8a1b9d40783e70a376e7423988cc9a590a09f/websockets-17.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66", size = 222982, upload_time = "2026-08-26T14:55:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/87/75/37c84c4371c6aa668910d7841036c4397ea10554c07030603ccd5e44b02a/websockets-17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0", size = 224017, upload_time = "2026-08-26T14:55:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/5e/20/8a9a94323bfcfe03bde3f9d98926bea4855856359702fe3ca0d07051ef5d/websockets-17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb", size = 224252, upload_time = "2026-08-26T14:55:40.498Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/aa66e6500188cd40306abeb92c9a738ca6dd7029d8d8532c538055ab5daf/websockets-17.1-cp311-cp311-win32.whl", hash = "sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142", size = 217484, upload_time = "2026-08-26T14:55:41.763Z" }, + { url = "https://files.pythonhosted.org/packages/01/a2/cdf3b551f0b9177023afd3a45d3b431a0d4064951008c4321a8b42ac2288/websockets-17.1-cp311-cp311-win_amd64.whl", hash = "sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee", size = 217779, upload_time = "2026-08-26T14:55:42.942Z" }, + { url = "https://files.pythonhosted.org/packages/e0/13/51253dbed7d16a4bb87b05110ad3bf12165f410e915f6da1edd4186d8dc1/websockets-17.1-cp311-cp311-win_arm64.whl", hash = "sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c", size = 217710, upload_time = "2026-08-26T14:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload_time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload_time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload_time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload_time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload_time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload_time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload_time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload_time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload_time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload_time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload_time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload_time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload_time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload_time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload_time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload_time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload_time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload_time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload_time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload_time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e4/af4abbcf07eac6a725ec6f865611526b2b0c23d482723de551bec667880d/websockets-17.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918", size = 214602, upload_time = "2026-08-26T14:58:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/4d/fe/819fba7ba35f92b639333da7355041c07dd50048f9c76fba0b8e292a6483/websockets-17.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886", size = 214874, upload_time = "2026-08-26T14:58:26.689Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/d370ab794f47fbeea648d17ad08caf0bb50131d6c04b7ad83e6af63c405a/websockets-17.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8", size = 215821, upload_time = "2026-08-26T17:25:23.616Z" }, + { url = "https://files.pythonhosted.org/packages/9b/6b/251b00fe634e2a9c2cb5d6390e0e97cec55e3d18dd09b4b976620eed5d7b/websockets-17.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf", size = 215714, upload_time = "2026-08-26T17:25:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/37fe0c96c206b4208a072c3a74add6a72af4b8228be3f5435163c5a6d099/websockets-17.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e", size = 216608, upload_time = "2026-08-26T17:25:28.134Z" }, + { url = "https://files.pythonhosted.org/packages/be/7e/75a0a491b512412e08333b9f8412757af6186fe1c598186261002de1a793/websockets-17.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d", size = 217870, upload_time = "2026-08-26T17:25:29.745Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload_time = "2026-08-26T17:25:31.397Z" }, +] From 1b88764180ba58970b558292669d1f8c33da76d9 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:16:30 +0200 Subject: [PATCH 02/17] feat(engine): hold alerts until the temporal service confirms them With TEMPORAL_API_URL set, the engine keeps the last 10 inference frames and boxes per camera pose, submits them when an alert becomes ongoing and reads the verdict on the next round. Negative verdicts resubmit the longer window; service errors fail open. --- pyroengine/engine.py | 71 ++++++++++++++++- pyroengine/temporal_client.py | 42 +++++++++++ src/run.py | 3 + tests/test_engine_temporal.py | 138 ++++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 pyroengine/temporal_client.py create mode 100644 tests/test_engine_temporal.py diff --git a/pyroengine/engine.py b/pyroengine/engine.py index c4440138..853bb99e 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -10,7 +10,7 @@ import time from collections import deque from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Never, Optional, Tuple @@ -23,6 +23,8 @@ from requests.exceptions import RequestException from requests.models import Response +from pyroengine.temporal_client import TemporalClient + __all__ = ["ContextCrop", "Engine"] # Client errors worth retrying later; every other 4xx is a permanent rejection of the payload. @@ -140,9 +142,16 @@ def __init__( save_detections_frames: Optional[bool] = False, send_last_image_period: int = 3600, # 1H last_bbox_mask_fetch_period: int = 3600, # 1H + temporal_api_url: Optional[str] = None, + temporal_window: int = 10, **kwargs: Any, # noqa: ANN401 ) -> None: cam_ids = list(cam_creds.keys()) if isinstance(cam_creds, dict) else None + # Temporal validation: when set, an ongoing alert is only staged once the temporal + # service has confirmed the last `temporal_window` frames of that camera pose. + # Set before super().__init__, which already builds the per-camera states. + self.temporal = TemporalClient(temporal_api_url) if temporal_api_url else None + self.temporal_window = temporal_window super().__init__( model_path=model_path, conf_thresh=conf_thresh, @@ -186,6 +195,7 @@ def __init__( state["last_bbox_mask_fetch"] = None state["last_pose_image_sent"] = init_now state["event_crop_boxes"] = [] + self._init_temporal_state(state) # Occlusion masks: cam_id -> dict of bboxes (keyed by mask id) self.occlusion_masks: Dict[str, Dict[Any, Any]] = {} @@ -202,8 +212,14 @@ def _new_state(self) -> Dict[str, Any]: state["last_bbox_mask_fetch"] = None state["last_pose_image_sent"] = datetime.now() state["event_crop_boxes"] = [] + self._init_temporal_state(state) return state + def _init_temporal_state(self, state: Dict[str, Any]) -> None: + state["temporal_frames"] = deque(maxlen=self.temporal_window) + state["temporal_job"] = None + state["temporal_validated"] = False + def _end_event(self, cam_key: str) -> None: """Reset per-event staging state when an alert ends. @@ -214,11 +230,59 @@ def _end_event(self, cam_key: str) -> None: """ state = self._states[cam_key] state["event_crop_boxes"] = [] + # The temporal frame window survives events; only the verdict of this event is dropped. + state["temporal_job"] = None + state["temporal_validated"] = False window = state["last_predictions"] for i, entry in enumerate(window): if entry[4]: # is_staged: belongs to the event that just ended window[i] = (entry[0], entry[1], [], entry[3], True, entry[5]) + def _temporal_gate(self, cam_key: str, cam_id: str) -> bool: + """Return True when this event may be staged: temporal verdict positive, or service unavailable. + + Round N submits the frame window; round N+1 reads the verdict. A negative verdict resubmits + the window (now one frame longer) so the event can still be confirmed later. A transport + error or a failed job fails open: the alert goes out as without temporal validation. + """ + state = self._states[cam_key] + if state["temporal_validated"] or self.temporal is None: + return True + try: + job_id = state["temporal_job"] + if job_id is not None: + result = self.temporal.result(job_id) + if result["status"] == "pending": + logger.info(f"Camera '{cam_id}' - temporal verdict pending, alert held") + return False + state["temporal_job"] = None + if result["status"] == "done": + verdict = result["verdict"] + prob = verdict.get("probability") + prob_str = f"{prob:.2%}" if prob is not None else "n/a" + if verdict["is_positive"]: + logger.info(f"Camera '{cam_id}' - temporal model confirmed the alert (probability: {prob_str})") + state["temporal_validated"] = True + return True + logger.info( + f"Camera '{cam_id}' - temporal model rejected the alert " + f"(probability: {prob_str}, tubes: {verdict.get('n_tubes')}), resubmitting" + ) + else: + logger.error( + f"Camera '{cam_id}' - temporal job failed ({result.get('error')}), alert sent unvalidated" + ) + state["temporal_validated"] = True + return True + window = list(state["temporal_frames"]) + state["temporal_job"] = self.temporal.submit(cam_id, window) + logger.info(f"Camera '{cam_id}' - temporal validation submitted ({len(window)} frames), alert held") + return False + except (RequestException, KeyError, ValueError) as e: + logger.error(f"Camera '{cam_id}' - temporal service unavailable ({e}), alert sent unvalidated") + state["temporal_validated"] = True + return True + def heartbeat(self, cam_id: str) -> Response: """Updates last ping of device""" ip = cam_id.split("_")[0] @@ -341,6 +405,9 @@ def predict( extra_boxes = state["event_crop_boxes"] if state["ongoing"] else None context_crop = self._build_context_crop(original_frame, preds, extra_boxes) conf = self._update_states(context_crop, preds, cam_key, encoded_bytes=encoded_bytes) + if self.temporal is not None and encoded_bytes is not None: + frame_id = f"{cam_key}_{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%S')}" + state["temporal_frames"].append((frame_id, encoded_bytes, np.round(preds[:, :5], 4).tolist())) if not self._states[cam_key]["ongoing"]: self._end_event(cam_key) @@ -354,6 +421,8 @@ def predict( # Alert (use ongoing so hysteresis-relaxed threshold keeps staging frames during a dip) if self._states[cam_key]["ongoing"] and len(self.api_client) > 0 and isinstance(cam_id, str): + if self.temporal is not None and not self._temporal_gate(cam_key, cam_id): + return float(conf) state = self._states[cam_key] # Collect every bbox the predictor emitted across the window; treat these as # tracked locations and backfill missing per-frame bboxes from raw preds with conf=0. diff --git a/pyroengine/temporal_client.py b/pyroengine/temporal_client.py new file mode 100644 index 00000000..87e682d9 --- /dev/null +++ b/pyroengine/temporal_client.py @@ -0,0 +1,42 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""Client for the temporal validation service (pyro_temporal_api).""" + +import json +from typing import Any, Dict, List, Sequence, Tuple + +import requests + +__all__ = ["TemporalClient"] + +# (frame_id, jpeg bytes, boxes as [[x1, y1, x2, y2, conf], ...] normalized xyxy) +WindowFrame = Tuple[str, bytes, List[List[float]]] + + +class TemporalClient: + """Submit a window of frames for one camera pose, then poll the verdict on a later round.""" + + def __init__(self, url: str, timeout: float = 10.0) -> None: + self.url = url.rstrip("/") + self.timeout = timeout + + def submit(self, cam_id: str, window: Sequence[WindowFrame]) -> str: + files = [("frames", (f"{frame_id}.jpg", data, "image/jpeg")) for frame_id, data, _ in window] + boxes = [frame_boxes for _, _, frame_boxes in window] + response = requests.post( + f"{self.url}/jobs", + data={"cam_id": cam_id, "boxes": json.dumps(boxes)}, + files=files, + timeout=self.timeout, + ) + response.raise_for_status() + return str(response.json()["job_id"]) + + def result(self, job_id: str) -> Dict[str, Any]: + """Return the job dict: ``status`` is ``pending``, ``done`` (with ``verdict``) or ``error``.""" + response = requests.get(f"{self.url}/jobs/{job_id}", timeout=self.timeout) + response.raise_for_status() + return dict(response.json()) diff --git a/src/run.py b/src/run.py index 55cabdf4..c70b01ba 100644 --- a/src/run.py +++ b/src/run.py @@ -34,6 +34,8 @@ def main(args): cam_pwd = os.environ.get("CAM_PWD") assert isinstance(cam_user, str) assert isinstance(cam_pwd, str) + # Optional temporal validation service (pyro_temporal_api); unset = alerts sent as before + temporal_api_url = os.environ.get("TEMPORAL_API_URL") or None # Loading camera creds with pathlib.Path(args.creds).open("rb") as json_file: @@ -81,6 +83,7 @@ def main(args): day_time_strategy=args.day_time_strategy, save_captured_frames=args.save_captured_frames, save_detections_frames=args.save_detections_frames, + temporal_api_url=temporal_api_url, ) sys_controller = SystemController(engine, camera_data, args.pyro_camera_api_url) diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py new file mode 100644 index 00000000..7fdd53a4 --- /dev/null +++ b/tests/test_engine_temporal.py @@ -0,0 +1,138 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from unittest.mock import MagicMock + +import numpy as np +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from pyroengine.engine import Engine + +CAM = "192.168.1.10_1" +# fake_pred is (cx, cy, w, h, conf) in model pixel space (imgsz 1024); +# post_process turns it into normalized xyxy [0.4, 0.4, 0.5, 0.5, 0.6] +PRED = np.array([[460.8, 460.8, 102.4, 102.4, 0.6]]) + + +class FakeTemporal: + """Scripted temporal service: `results` are returned in order by result(); submit() records windows.""" + + def __init__(self, results=()) -> None: + self.results = list(results) + self.submitted = [] + self.n = 0 + + def submit(self, cam_id, window): + self.n += 1 + self.submitted.append((cam_id, list(window))) + return f"job{self.n}" + + def result(self, _job_id): + return self.results.pop(0) + + +def _engine(tmp_path, temporal, **kwargs): + engine = Engine( + cache_folder=str(tmp_path), + conf_thresh=0.1, + nb_consecutive_frames=2, + temporal_api_url="http://localhost:8082", + cam_creds={CAM: ("token", 7)}, + **kwargs, + ) + engine.temporal = temporal + engine.api_client = {"192.168.1.10": MagicMock()} # no api_url: a real Client would call the API + return engine + + +def _fire(engine, image, n=1): + for _ in range(n): + engine.predict(image, CAM, fake_pred=PRED.T.copy()) + return engine._states[CAM] + + +def test_window_collects_frames_and_boxes(tmp_path, mock_wildfire_image): + engine = _engine(tmp_path, FakeTemporal([{"status": "pending"}] * 3), temporal_window=3) + state = _fire(engine, mock_wildfire_image, n=4) + window = list(state["temporal_frames"]) + assert len(window) == 3 # bounded ring buffer + frame_id, jpeg, boxes = window[-1] + assert frame_id.startswith(f"{CAM}_20") + assert jpeg[:2] == b"\xff\xd8" # JPEG magic + assert boxes == [[0.4, 0.4, 0.5, 0.5, 0.6]] + + +def test_alert_held_until_positive_verdict(tmp_path, mock_wildfire_image): + done = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} + temporal = FakeTemporal([{"status": "pending"}, done(False), done(True)]) + engine = _engine(tmp_path, temporal) + + state = _fire(engine, mock_wildfire_image) # ongoing on the first frame -> submit + assert state["ongoing"] + assert len(temporal.submitted) == 1 + assert state["temporal_job"] == "job1" + assert len(engine._alerts) == 0 + + _fire(engine, mock_wildfire_image) # pending -> held + assert len(engine._alerts) == 0 + assert state["temporal_job"] == "job1" + + _fire(engine, mock_wildfire_image) # negative -> resubmitted with a longer window + assert len(engine._alerts) == 0 + assert state["temporal_job"] == "job2" + assert len(temporal.submitted[1][1]) == 3 + + _fire(engine, mock_wildfire_image) # positive -> validated, window staged + assert state["temporal_validated"] is True + assert state["temporal_job"] is None + assert len(engine._alerts) > 0 + assert not temporal.results # every scripted verdict was consumed + + n_staged = len(engine._alerts) + _fire(engine, mock_wildfire_image) # validated event: no more calls, keeps staging + assert len(temporal.submitted) == 2 + assert len(engine._alerts) > n_staged + + +@pytest.mark.parametrize( + "temporal", + [ + FakeTemporal([{"status": "error", "verdict": None, "error": "RuntimeError: boom"}]), + MagicMock(submit=MagicMock(side_effect=RequestsConnectionError("down"))), + ], + ids=["job_error", "service_down"], +) +def test_service_failure_fails_open(tmp_path, mock_wildfire_image, temporal): + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image) + if isinstance(temporal, FakeTemporal): + assert len(engine._alerts) == 0 # round N: submitted, held + _fire(engine, mock_wildfire_image) # round N+1: job error + assert state["temporal_validated"] is True + assert len(engine._alerts) > 0 + + +def test_end_of_event_resets_verdict_but_keeps_window(tmp_path, mock_wildfire_image, mock_forest_image): + temporal = FakeTemporal([{"status": "done", "verdict": {"is_positive": True, "probability": 0.9, "n_tubes": 1}}]) + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image, n=3) + assert state["temporal_validated"] is True + n_frames = len(state["temporal_frames"]) + + for _ in range(3): + engine.predict(mock_forest_image, CAM, fake_pred=np.empty((5, 0))) + assert state["ongoing"] is False + assert state["temporal_validated"] is False + assert state["temporal_job"] is None + assert len(state["temporal_frames"]) >= n_frames + + +def test_disabled_without_url(tmp_path, mock_wildfire_image): + engine = Engine(cache_folder=str(tmp_path), conf_thresh=0.1, nb_consecutive_frames=2) + assert engine.temporal is None + state = _fire(engine, mock_wildfire_image, n=2) + assert state["ongoing"] + assert len(state["temporal_frames"]) == 0 From 7fa1d3646e8833c442c6c94d43aa075a231a83e2 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:16:30 +0200 Subject: [PATCH 03/17] chore: wire pyro-temporal-api into compose, Makefile, lint config and docs --- .env.example | 1 + .github/workflows/tests.yml | 2 +- CLAUDE.md | 11 ++++++++--- Makefile | 8 +++++++- README.md | 8 +++++++- docker-compose.yml | 24 ++++++++++++++++++++++++ pyproject.toml | 5 +++-- 7 files changed, 51 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index a40d4113..d4eadcd1 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,4 @@ PYRO_ENGINE_VERSION=latest # Stuck-PTZ detector (auto-reboots a PTZ camera that stops rotating during patrol). # Enabled by default. Set to "false" to disable. ENABLE_STUCK_DETECTOR=true +TEMPORAL_API_URL=http://localhost:8082 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 14928a6c..4c71c668 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,5 +112,5 @@ jobs: license: 'Apache-2.0' owner: 'Pyronear' starting-year: 2022 - folders: 'pyroengine,docs,scripts,.github,src,pyro_camera_api,pyro-predictor/pyro_predictor' + folders: 'pyroengine,docs,scripts,.github,src,pyro_camera_api,pyro_temporal_api,pyro-predictor/pyro_predictor' ignore-files: 'version.py,__init__.py' diff --git a/CLAUDE.md b/CLAUDE.md index ba9f8a5d..7cbde8b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -PyroEngine is a wildfire detection system for edge devices (Raspberry Pi, etc.). It has two main packages: +PyroEngine is a wildfire detection system for edge devices (Raspberry Pi, etc.). It has three packages: - **`pyroengine/`** — Core detection engine: runs YOLO model inference on camera images, manages alert states, communicates with the PyroNear API. - **`pyro_camera_api/`** — FastAPI service: unified REST interface for controlling heterogeneous cameras (Reolink, Linovision/Hikvision, RTSP, HTTP URL, generic REST/JSON snapshot API). +- **`pyro_temporal_api/`** — Optional FastAPI service: validates ongoing alerts with the temporal smoke model (`pyronear/temporal-model`, ONNX runtime, no torch) before the engine sends them to the PyroNear API. -These two services run as separate Docker containers and communicate over localhost (host network mode). The engine calls the camera API to capture frames and manage PTZ patrols. +These services run as separate Docker containers and communicate over localhost (host network mode). The engine calls the camera API to capture frames and manage PTZ patrols. ## Common Commands @@ -81,6 +82,10 @@ pytest tests/test_engine.py -v For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_url}_{pose_id}.json` from a remote URL to get a dict of bounding boxes marking permanently occluded regions. Predictions with IoU > 0.1 against any occlusion box are dropped before confidence scoring. +### Temporal validation (optional) + +When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. + ### Stream-awareness `SystemController.inference_loop` calls `_any_stream_active()` before and during every camera loop. If an active RTSP/SRT pipeline is detected (via `/stream/status`), the entire inference pass is skipped to avoid interfering with live streaming. @@ -91,7 +96,7 @@ Day is determined by IR-channel analysis (`is_day_time(strategy="ir")`): if `max ### Environment Variables (`.env`) -Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`. +Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`, `TEMPORAL_API_URL` (e.g. `http://localhost:8082`, unset = no temporal validation), `TEMPORAL_MODEL_PATH`. ### Legacy direct-camera module diff --git a/Makefile b/Makefile index 201f64ef..95251c12 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,12 @@ build-api: docker build -f pyro_camera_api/Dockerfile pyro_camera_api -t pyronear/pyro-camera-api:latest # Build the engine Docker image +build-temporal: + cd pyro_temporal_api && \ + uv lock && \ + uv export --no-hashes --no-emit-project --no-default-groups --no-dev --format requirements-txt -o requirements.txt + docker build -f pyro_temporal_api/Dockerfile pyro_temporal_api -t pyronear/pyro-temporal-api:latest + build-app: docker build . -t pyronear/pyro-engine:latest @@ -53,7 +59,7 @@ run: docker compose up -d # Build images locally and run the stack -run_local: build-api build-app +run_local: build-api build-temporal build-app docker compose up -d # Get log from engine wrapper diff --git a/README.md b/README.md index 6658c09a..2b9fa039 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,15 @@ CAM_USER=my_dummy_login CAM_PWD=my_dummy_pwd MEDIAMTX_SERVER_IP=1.2.3.4 PYRO_ENGINE_VERSION=latest +TEMPORAL_API_URL=http://localhost:8082 ``` -`PYRO_ENGINE_VERSION` controls which Docker image tag is pulled for both services (defaults to `latest` if unset). +`PYRO_ENGINE_VERSION` controls which Docker image tag is pulled for the services (defaults to `latest` if unset). + +`TEMPORAL_API_URL` enables the optional temporal validation: while an alert is ongoing, the engine sends the last +frames and boxes of that camera pose to `pyro_temporal_api` and only uploads the alert once the temporal smoke +model confirms it. Leave it unset to send alerts as before. The service needs `data/model_onnx.zip`, exported from +a [temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. ### Data directory diff --git a/docker-compose.yml b/docker-compose.yml index 28bd1265..1e658d57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,29 @@ services: max-file: "5" + pyro-temporal-api: + build: + context: ./pyro_temporal_api + image: pyronear/pyro-temporal-api:${PYRO_ENGINE_VERSION:-latest} + container_name: pyro-temporal-api + environment: + TEMPORAL_MODEL_PATH: ${TEMPORAL_MODEL_PATH:-data/model_onnx.zip} + volumes: + - ./data:/usr/src/app/data + restart: always + network_mode: host + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8082/health || exit 1"] + interval: 30s + retries: 3 + start_period: 30s + timeout: 5s + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" + engine: build: context: ./engine @@ -39,6 +62,7 @@ services: API_URL: ${API_URL} CAM_USER: ${CAM_USER} CAM_PWD: ${CAM_PWD} + TEMPORAL_API_URL: ${TEMPORAL_API_URL:-} volumes: - ./data:/usr/src/app/data command: > diff --git a/pyproject.toml b/pyproject.toml index c9737a69..7795dd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ source = ["pyroengine"] [tool.mypy] python_version = "3.11" -files = "pyroengine/,pyro_camera_api/,pyro-predictor/pyro_predictor/" +files = "pyroengine/,pyro_camera_api/,pyro_temporal_api/,pyro-predictor/pyro_predictor/" show_error_codes = true pretty = true warn_unused_ignores = true @@ -135,7 +135,7 @@ exclude = [".git", "pyro-predictor/**", "docs/**"] docstring-quotes = "double" [tool.ruff.lint.isort] -known-first-party = ["pyroengine", "pyro_camera_api"] +known-first-party = ["pyroengine", "pyro_camera_api", "pyro_temporal_api"] known-third-party = ["pillow", "tqdm", "onnxruntime", "huggingface_hub"] [tool.ruff.lint.per-file-ignores] @@ -145,6 +145,7 @@ known-third-party = ["pillow", "tqdm", "onnxruntime", "huggingface_hub"] ".github/**.py" = ["D", "T201", "ANN", "S", "PYI024"] "tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ANN202", "ARG001", "S113"] "pyro_camera_api/**.py" = ["D", "T201", "S101", "ANN", "BLE001", "S113", "S501", "S404", "S603", "S405", "S314", "E402", "RUF029"] +"pyro_temporal_api/**.py" = ["D", "T201", "S101", "ANN", "BLE001", "S113", "S501", "E402", "RUF029"] "pyroengine/core.py" = ["BLE001"] "pyroengine/sensors.py" = ["S113", "S501", "ANN"] "watchdog/**.py" = ["CPY001", "S108", "S310", "S404", "S603", "S607", "BLE001", "LOG015"] From e0dd7de938b44cbd8c3e3f3c8c1300444735c148 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:17:36 +0200 Subject: [PATCH 04/17] fix(temporal): build on bookworm, bullseye-security release file is expired --- pyro_temporal_api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyro_temporal_api/Dockerfile b/pyro_temporal_api/Dockerfile index d70a5b36..4bf7e524 100644 --- a/pyro_temporal_api/Dockerfile +++ b/pyro_temporal_api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.13-slim-bullseye +FROM python:3.11.13-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ From 334f82e873f201deec627c29de22dbf708ac3ec8 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:17:55 +0200 Subject: [PATCH 05/17] chore: pull and tail the temporal API image in Makefile targets --- Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Makefile b/Makefile index 95251c12..048a1ddd 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,7 @@ build-optional-lib: run: docker pull pyronear/pyro-engine:latest docker pull pyronear/pyro-camera-api:latest + docker pull pyronear/pyro-temporal-api:latest docker compose up -d # Build images locally and run the stack @@ -66,6 +67,10 @@ run_local: build-api build-temporal build-app log: docker logs -f --tail 50 engine +# Get log from temporal API wrapper +log-temporal: + docker logs -f --tail 50 pyro-temporal-api + # Get log from camera API wrapper log-api: docker logs -f --tail 50 pyro-camera-api From 42ac53b0fc193a5746af7c449d5cafcade947046 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:26:26 +0200 Subject: [PATCH 06/17] build: move engine and camera API images to bookworm bullseye LTS ended in August 2026: its security repo now serves an expired release file and apt-get update fails in every build. --- Dockerfile | 4 ++-- pyro_camera_api/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 72314de0..44b4364d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ---- Builder: only used for git-based deps (needs git) ---- -FROM python:3.11.13-slim-bullseye AS git-deps +FROM python:3.11.13-slim-bookworm AS git-deps RUN apt-get update && \ apt-get install -y --no-install-recommends git \ @@ -12,7 +12,7 @@ COPY ./requirements-git.txt /tmp/requirements-git.txt RUN uv pip install --no-cache --target=/tmp/git-packages -r /tmp/requirements-git.txt # ---- Runtime ---- -FROM python:3.11.13-slim-bullseye +FROM python:3.11.13-slim-bookworm ENV LANG="C.UTF-8" \ PYTHONUNBUFFERED=1 \ diff --git a/pyro_camera_api/Dockerfile b/pyro_camera_api/Dockerfile index ef8a1dcf..798dda1f 100644 --- a/pyro_camera_api/Dockerfile +++ b/pyro_camera_api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.16-slim +FROM python:3.9-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ From 8a7b0726e3a3a5157249a8acd21d5c66847f6d6c Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 14:44:24 +0200 Subject: [PATCH 07/17] fix(temporal): bound uploads and queue, bind to loopback, publish image, compose profile Per-frame size limit and pending-job cap on POST /jobs, service bound to 127.0.0.1 on the host network, image added to the build-and-push matrix so make run can pull it, and the service placed behind the temporal compose profile so disabled stacks do not start it. --- .env.example | 4 +++- .github/workflows/build-push-image.yml | 3 +++ CLAUDE.md | 4 ++-- README.md | 10 +++++----- docker-compose.yml | 2 ++ pyro_temporal_api/Dockerfile | 3 ++- pyro_temporal_api/pyro_temporal_api/jobs.py | 8 ++++++++ pyro_temporal_api/pyro_temporal_api/main.py | 16 +++++++++++++--- pyro_temporal_api/tests/test_jobs.py | 17 ++++++++++++++++- 9 files changed, 54 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index d4eadcd1..8a547423 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,6 @@ PYRO_ENGINE_VERSION=latest # Stuck-PTZ detector (auto-reboots a PTZ camera that stops rotating during patrol). # Enabled by default. Set to "false" to disable. ENABLE_STUCK_DETECTOR=true -TEMPORAL_API_URL=http://localhost:8082 +# Optional temporal validation of alerts: both lines together, plus data/model_onnx.zip +# COMPOSE_PROFILES=temporal +# TEMPORAL_API_URL=http://localhost:8082 diff --git a/.github/workflows/build-push-image.yml b/.github/workflows/build-push-image.yml index 3e5333ee..7975d44a 100644 --- a/.github/workflows/build-push-image.yml +++ b/.github/workflows/build-push-image.yml @@ -21,6 +21,9 @@ jobs: - image: pyronear/pyro-camera-api context: pyro_camera_api dockerfile: pyro_camera_api/Dockerfile + - image: pyronear/pyro-temporal-api + context: pyro_temporal_api + dockerfile: pyro_temporal_api/Dockerfile steps: - name: Checkout repository diff --git a/CLAUDE.md b/CLAUDE.md index 7cbde8b3..9312d67f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. ### Stream-awareness @@ -96,7 +96,7 @@ Day is determined by IR-channel analysis (`is_day_time(strategy="ir")`): if `max ### Environment Variables (`.env`) -Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`, `TEMPORAL_API_URL` (e.g. `http://localhost:8082`, unset = no temporal validation), `TEMPORAL_MODEL_PATH`. +Key vars used at runtime: `LAT`, `LON`, `API_URL`, `API_TOKEN`, `CAM_USER`, `CAM_PWD`, `MEDIAMTX_SERVER_IP`, `ROUTER_IP`, `ROUTER_USER`, `ROUTER_PASSWORD`, `ENABLE_ROUTER_REBOOT`, `COMPOSE_PROFILES=temporal` + `TEMPORAL_API_URL` (e.g. `http://localhost:8082`; both unset = no temporal validation), `TEMPORAL_MODEL_PATH`. ### Legacy direct-camera module diff --git a/README.md b/README.md index 2b9fa039..5650b8c7 100644 --- a/README.md +++ b/README.md @@ -113,15 +113,15 @@ CAM_USER=my_dummy_login CAM_PWD=my_dummy_pwd MEDIAMTX_SERVER_IP=1.2.3.4 PYRO_ENGINE_VERSION=latest -TEMPORAL_API_URL=http://localhost:8082 ``` `PYRO_ENGINE_VERSION` controls which Docker image tag is pulled for the services (defaults to `latest` if unset). -`TEMPORAL_API_URL` enables the optional temporal validation: while an alert is ongoing, the engine sends the last -frames and boxes of that camera pose to `pyro_temporal_api` and only uploads the alert once the temporal smoke -model confirms it. Leave it unset to send alerts as before. The service needs `data/model_onnx.zip`, exported from -a [temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. +Optional temporal validation of alerts: with `COMPOSE_PROFILES=temporal` and `TEMPORAL_API_URL=http://localhost:8082` +in `.env`, the stack also starts `pyro_temporal_api`. While an alert is ongoing, the engine sends the last frames and +boxes of that camera pose to it and only uploads the alert once the temporal smoke model confirms it. Leave both unset +to send alerts as before. The service needs `data/model_onnx.zip`, exported from a +[temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. ### Data directory diff --git a/docker-compose.yml b/docker-compose.yml index 1e658d57..ba088a44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,9 @@ services: max-file: "5" + # Optional: started only with COMPOSE_PROFILES=temporal (needs data/model_onnx.zip) pyro-temporal-api: + profiles: ["temporal"] build: context: ./pyro_temporal_api image: pyronear/pyro-temporal-api:${PYRO_ENGINE_VERSION:-latest} diff --git a/pyro_temporal_api/Dockerfile b/pyro_temporal_api/Dockerfile index 4bf7e524..66fd73cc 100644 --- a/pyro_temporal_api/Dockerfile +++ b/pyro_temporal_api/Dockerfile @@ -22,4 +22,5 @@ WORKDIR /usr/src/app COPY pyproject.toml ./pyproject.toml COPY pyro_temporal_api ./pyro_temporal_api -CMD ["uvicorn", "pyro_temporal_api.main:app", "--host", "0.0.0.0", "--port", "8082"] +# host network mode: bind to loopback, only the engine on the same box talks to this service +CMD ["uvicorn", "pyro_temporal_api.main:app", "--host", "127.0.0.1", "--port", "8082"] diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py index 054a2894..29fae01b 100644 --- a/pyro_temporal_api/pyro_temporal_api/jobs.py +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -22,6 +22,12 @@ logger = logging.getLogger(__name__) MAX_JOBS_KEPT = 100 +# One job per camera pose per round is the normal load; more means a stuck worker or a flood. +MAX_PENDING = 16 + + +class QueueFullError(RuntimeError): + """Raised by submit() when too many jobs are still waiting to be scored.""" @dataclass @@ -99,6 +105,8 @@ def __init__(self, model: Any) -> None: self._worker.start() def submit(self, cam_id: str, frames: list[tuple[str, bytes]], boxes: list[list[list[float]]]) -> Job: + if self._queue.qsize() >= MAX_PENDING: + raise QueueFullError(f"{MAX_PENDING} jobs already pending") job = Job(job_id=uuid.uuid4().hex, cam_id=cam_id, frames=frames, boxes=boxes) with self._lock: self._jobs[job.job_id] = job diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py index 36a14758..dfa218f0 100644 --- a/pyro_temporal_api/pyro_temporal_api/main.py +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -22,13 +22,15 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from temporal_model.core.onnx_model import OnnxTemporalModel -from pyro_temporal_api.jobs import JobStore +from pyro_temporal_api.jobs import JobStore, QueueFullError logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO) logger = logging.getLogger(__name__) MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) MAX_FRAMES = 20 +# Inference frames are a few hundred KB; anything larger is not an engine frame. +MAX_FRAME_BYTES = 4 * 1024 * 1024 @asynccontextmanager @@ -78,8 +80,16 @@ async def submit_job( if any(not isinstance(b, list) or len(b) != 5 for b in fb): raise HTTPException(status_code=422, detail="each box must be [x1, y1, x2, y2, conf]") - payload = [(Path(f.filename or "frame").stem, await f.read()) for f in frames] - job = request.app.state.jobs.submit(cam_id, payload, parsed) + payload = [] + for f in frames: + data = await f.read(MAX_FRAME_BYTES + 1) + if len(data) > MAX_FRAME_BYTES: + raise HTTPException(status_code=413, detail=f"frame {f.filename} exceeds {MAX_FRAME_BYTES} bytes") + payload.append((Path(f.filename or "frame").stem, data)) + try: + job = request.app.state.jobs.submit(cam_id, payload, parsed) + except QueueFullError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc return {"job_id": job.job_id} diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py index d08f7e6a..6f3c470d 100644 --- a/pyro_temporal_api/tests/test_jobs.py +++ b/pyro_temporal_api/tests/test_jobs.py @@ -11,7 +11,7 @@ from fastapi.testclient import TestClient from PIL import Image -from pyro_temporal_api import main +from pyro_temporal_api import jobs, main from pyro_temporal_api.jobs import JobStore, _to_frame_detections @@ -122,3 +122,18 @@ def test_to_frame_detections_keeps_empty_frames(): dets = _to_frame_detections(frames, [[], [[0, 0, 1, 1, 0.5]]]) assert dets["a"].detections == [] assert len(dets["b"].detections) == 1 + + +def test_oversized_frame_is_rejected(client, monkeypatch): + c, _ = client + monkeypatch.setattr(main, "MAX_FRAME_BYTES", 100) + r = _submit(c, n_frames=1) + assert r.status_code == 413 + + +def test_full_queue_returns_503(client, monkeypatch): + c, _ = client + monkeypatch.setattr(jobs, "MAX_PENDING", 0) + r = _submit(c, n_frames=1) + assert r.status_code == 503 + assert "pending" in r.json()["detail"] From 86aed286354146588fc1e0d8d1dcdb9e8cc09c3f Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 8 Sep 2026 15:25:36 +0200 Subject: [PATCH 08/17] chore(temporal): pin temporal-model-core to the latest PR 76 commit --- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index 1bdc2f5f..e2462364 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index e0d091e9..5697fc8f 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@449e6b049cc988bd7458ba101249680587eb0b33#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index a5e3123c..c00c1620 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=449e6b049cc988bd7458ba101249680587eb0b33#449e6b049cc988bd7458ba101249680587eb0b33" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, From 4228cb4f9bcd54785343e4b0b8e261755b13e887 Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 9 Sep 2026 08:23:20 +0200 Subject: [PATCH 09/17] chore(temporal): bump temporal-model-core pin to PR 76 head (ef76860) --- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index e2462364..0125cb9d 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index 5697fc8f..e9e1900e 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index c00c1620..dc75fc86 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=daaf239792797abe0fe76b5d5d0b5a6812f0d1fe#daaf239792797abe0fe76b5d5d0b5a6812f0d1fe" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da#ef76860863e737dbb67e961e5015c79441e8f9da" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, From a87b779b36a7ab827c41e4219e40b1580f304bfc Mon Sep 17 00:00:00 2001 From: Mateo Date: Sat, 12 Sep 2026 12:43:23 +0200 Subject: [PATCH 10/17] chore(temporal): pin temporal-model-core to the PR 76 merge commit (fb7f6ca) --- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index 0125cb9d..469b3c64 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index e9e1900e..decc3ceb 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@ef76860863e737dbb67e961e5015c79441e8f9da#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index dc75fc86..af62e595 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=ef76860863e737dbb67e961e5015c79441e8f9da#ef76860863e737dbb67e961e5015c79441e8f9da" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, From 131d4ec03758809aabe2fbb53108ff9b42fcf098 Mon Sep 17 00:00:00 2001 From: Mateo Date: Sat, 12 Sep 2026 16:07:11 +0200 Subject: [PATCH 11/17] fix(engine): keep an ended event until its temporal verdict is in An event ending while the temporal job was pending was dropped by _end_event, so a positive verdict never uploaded the frames held during scoring. The state now survives until the verdict: positive stages the held frames, negative drops the event without resubmitting. --- pyroengine/engine.py | 93 ++++++++++++++++++++--------------- tests/test_engine_temporal.py | 33 +++++++++++++ 2 files changed, 87 insertions(+), 39 deletions(-) diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 853bb99e..435a2f31 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -238,11 +238,12 @@ def _end_event(self, cam_key: str) -> None: if entry[4]: # is_staged: belongs to the event that just ended window[i] = (entry[0], entry[1], [], entry[3], True, entry[5]) - def _temporal_gate(self, cam_key: str, cam_id: str) -> bool: + def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bool: """Return True when this event may be staged: temporal verdict positive, or service unavailable. Round N submits the frame window; round N+1 reads the verdict. A negative verdict resubmits - the window (now one frame longer) so the event can still be confirmed later. A transport + the window (now one frame longer) so the event can still be confirmed later; with + ``resubmit`` False (the event already ended) a negative verdict just drops it. A transport error or a failed job fails open: the alert goes out as without temporal validation. """ state = self._states[cam_key] @@ -266,14 +267,19 @@ def _temporal_gate(self, cam_key: str, cam_id: str) -> bool: return True logger.info( f"Camera '{cam_id}' - temporal model rejected the alert " - f"(probability: {prob_str}, tubes: {verdict.get('n_tubes')}), resubmitting" + f"(probability: {prob_str}, tubes: {verdict.get('n_tubes')}), " + + ("resubmitting" if resubmit else "event over, dropped") ) + if not resubmit: + return False else: logger.error( f"Camera '{cam_id}' - temporal job failed ({result.get('error')}), alert sent unvalidated" ) state["temporal_validated"] = True return True + elif not resubmit: + return False window = list(state["temporal_frames"]) state["temporal_job"] = self.temporal.submit(cam_id, window) logger.info(f"Camera '{cam_id}' - temporal validation submitted ({len(window)} frames), alert held") @@ -408,8 +414,6 @@ def predict( if self.temporal is not None and encoded_bytes is not None: frame_id = f"{cam_key}_{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%S')}" state["temporal_frames"].append((frame_id, encoded_bytes, np.round(preds[:, :5], 4).tolist())) - if not self._states[cam_key]["ongoing"]: - self._end_event(cam_key) if self.save_captured_frames: self._local_backup(frame, cam_id, is_alert=False, encoded_bytes=encoded_bytes) @@ -419,43 +423,54 @@ def predict( pred_str = "Wildfire detected" if conf > self.conf_thresh else "No wildfire" logger.info(f"{device_str}{pred_str} (confidence: {conf:.2%})") - # Alert (use ongoing so hysteresis-relaxed threshold keeps staging frames during a dip) - if self._states[cam_key]["ongoing"] and len(self.api_client) > 0 and isinstance(cam_id, str): - if self.temporal is not None and not self._temporal_gate(cam_key, cam_id): - return float(conf) - state = self._states[cam_key] - # Collect every bbox the predictor emitted across the window; treat these as - # tracked locations and backfill missing per-frame bboxes from raw preds with conf=0. - tracked = [b[:4] for _, _, bbs, _, _, _ in state["last_predictions"] for b in bbs] - tracked_arr = np.array(tracked, dtype=np.float64) if tracked else np.empty((0, 4)) - - # Freeze one square crop box per cluster of tracked bboxes so every frame of the - # event is cropped at the same location, even when individual bboxes move. - full_size = next(((cc.full_w, cc.full_h) for cc, *_ in state["last_predictions"] if cc is not None), None) - if tracked and full_size is not None: - self._update_event_crop_boxes(cam_key, tracked, *full_size) - - # Carry the last seen bbox forward onto frames with no detection so the alert keeps a - # crop at the same location instead of a blank/placeholder frame (conf 0 flags the carry). - last_seen: list = [] - for idx, (crop_, preds_, bboxes, ts, is_staged, jpeg_bytes) in enumerate(state["last_predictions"]): - if is_staged: - if bboxes: - last_seen = bboxes - continue - bboxes = self._backfill_bboxes(bboxes, preds_, tracked_arr) - if not bboxes and last_seen: - bboxes = [[b[0], b[1], b[2], b[3], 0.0] for b in last_seen] - if bboxes: - last_seen = bboxes - crop_boxes = ( - self._assign_crop_boxes(bboxes, cam_key, *full_size) if bboxes and full_size is not None else None - ) - self._stage_alert(crop_, cam_id, ts, bboxes, jpeg_bytes, crop_boxes) - state["last_predictions"][idx] = (crop_, preds_, bboxes, ts, True, jpeg_bytes) + # Alert (use ongoing so hysteresis-relaxed threshold keeps staging frames during a dip). + # An event that ended while its temporal verdict is pending keeps its state until the + # verdict is in: a positive one still uploads the frames held while the smoke was scored. + alerting = (state["ongoing"] or state["temporal_job"] is not None) and len(self.api_client) > 0 + if ( + alerting + and isinstance(cam_id, str) + and (self.temporal is None or self._temporal_gate(cam_key, cam_id, resubmit=state["ongoing"])) + ): + self._stage_event(cam_key, cam_id) + if not state["ongoing"] and state["temporal_job"] is None: + self._end_event(cam_key) return float(conf) + def _stage_event(self, cam_key: str, cam_id: str) -> None: + """Stage every not-yet-staged frame of the window as an alert, cropped at the event's fire locations.""" + state = self._states[cam_key] + # Collect every bbox the predictor emitted across the window; treat these as + # tracked locations and backfill missing per-frame bboxes from raw preds with conf=0. + tracked = [b[:4] for _, _, bbs, _, _, _ in state["last_predictions"] for b in bbs] + tracked_arr = np.array(tracked, dtype=np.float64) if tracked else np.empty((0, 4)) + + # Freeze one square crop box per cluster of tracked bboxes so every frame of the + # event is cropped at the same location, even when individual bboxes move. + full_size = next(((cc.full_w, cc.full_h) for cc, *_ in state["last_predictions"] if cc is not None), None) + if tracked and full_size is not None: + self._update_event_crop_boxes(cam_key, tracked, *full_size) + + # Carry the last seen bbox forward onto frames with no detection so the alert keeps a + # crop at the same location instead of a blank/placeholder frame (conf 0 flags the carry). + last_seen: list = [] + for idx, (crop_, preds_, bboxes, ts, is_staged, jpeg_bytes) in enumerate(state["last_predictions"]): + if is_staged: + if bboxes: + last_seen = bboxes + continue + bboxes = self._backfill_bboxes(bboxes, preds_, tracked_arr) + if not bboxes and last_seen: + bboxes = [[b[0], b[1], b[2], b[3], 0.0] for b in last_seen] + if bboxes: + last_seen = bboxes + crop_boxes = ( + self._assign_crop_boxes(bboxes, cam_key, *full_size) if bboxes and full_size is not None else None + ) + self._stage_alert(crop_, cam_id, ts, bboxes, jpeg_bytes, crop_boxes) + state["last_predictions"][idx] = (crop_, preds_, bboxes, ts, True, jpeg_bytes) + @staticmethod def _fit_box( box: Tuple[float, float, float, float], img_w: float, img_h: float diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py index 7fdd53a4..41bd62b3 100644 --- a/tests/test_engine_temporal.py +++ b/tests/test_engine_temporal.py @@ -136,3 +136,36 @@ def test_disabled_without_url(tmp_path, mock_wildfire_image): state = _fire(engine, mock_wildfire_image, n=2) assert state["ongoing"] assert len(state["temporal_frames"]) == 0 + + +EMPTY = np.empty((5, 0)) +DONE = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} + + +def test_event_ending_during_validation_still_uploads_on_positive(tmp_path, mock_wildfire_image, mock_forest_image): + temporal = FakeTemporal([{"status": "pending"}, {"status": "pending"}, DONE(True)]) + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image) # submitted, held + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # still ongoing (hysteresis), pending + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # event over, verdict still pending: kept + assert state["ongoing"] is False + assert state["temporal_job"] == "job1" + assert len(engine._alerts) == 0 + + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # positive verdict: held frames go out + assert len(engine._alerts) > 0 + assert state["temporal_job"] is None + assert state["temporal_validated"] is False # event closed after staging + assert not temporal.results + + +def test_event_ending_during_validation_dropped_on_negative(tmp_path, mock_wildfire_image, mock_forest_image): + temporal = FakeTemporal([{"status": "pending"}, {"status": "pending"}, DONE(False)]) + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image) + for _ in range(3): + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) + assert state["ongoing"] is False + assert state["temporal_job"] is None # no resubmission for an ended event + assert len(temporal.submitted) == 1 + assert len(engine._alerts) == 0 From b37a3a3376cf285d4710b8a4ec439e1c6950ae2d Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 14 Oct 2026 17:54:42 +0200 Subject: [PATCH 12/17] feat(temporal): cache ViT embeddings across jobs, pin temporal-model split export Pins temporal-model-core to 7c1500e (feat/onnx-embedding-cache): the ONNX package is now backbone + head, the runtime embeds only real slots and keeps an LRU of per-slot embeddings keyed by (frame id, crop box). The engine resubmits the same window one frame longer each round, so a job now mostly pays the ViT for the new frame. The verdict reports embeddings_cached / embeddings_new; a fused (format 1) package still loads with a warning. README points at the model 0.6.0 release (pyro-dataset v4.7.0), to be re-exported with temporal-export-onnx for the split graphs. --- CLAUDE.md | 2 +- README.md | 8 ++++++-- pyro_temporal_api/pyproject.toml | 2 +- pyro_temporal_api/pyro_temporal_api/jobs.py | 4 ++++ pyro_temporal_api/pyro_temporal_api/main.py | 20 ++++++++++++++++---- pyro_temporal_api/requirements.txt | 2 +- pyro_temporal_api/tests/test_jobs.py | 8 +++++++- pyro_temporal_api/uv.lock | 4 ++-- 8 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9312d67f..8d60382d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. ### Stream-awareness diff --git a/README.md b/README.md index 6865bae2..a70adfdb 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,12 @@ PYRO_ENGINE_VERSION=latest Optional temporal validation of alerts: with `COMPOSE_PROFILES=temporal` and `TEMPORAL_API_URL=http://localhost:8082` in `.env`, the stack also starts `pyro_temporal_api`. While an alert is ongoing, the engine sends the last frames and boxes of that camera pose to it and only uploads the alert once the temporal smoke model confirms it. Leave both unset -to send alerts as before. The service needs `data/model_onnx.zip`, exported from a -[temporal-model](https://github.com/pyronear/temporal-model) release with `temporal-export-onnx`. +to send alerts as before. The service needs `data/model_onnx.zip`: the [temporal-model](https://github.com/pyronear/temporal-model) +release on Hugging Face (`pyronear/temporal-model`, revision `v0.6.0`, trained on pyro-dataset v4.7.0), re-exported +with `temporal-export-onnx` so the package carries the split backbone/head graphs. With those, the service embeds +only real frames and keeps per-frame ViT embeddings across rounds, so re-scoring a window one frame longer only costs +the new frame (as long as the tube's crop window does not move). A fused single-graph package still loads, without +these savings. ### Data directory diff --git a/pyro_temporal_api/pyproject.toml b/pyro_temporal_api/pyproject.toml index 469b3c64..2a4ebda7 100644 --- a/pyro_temporal_api/pyproject.toml +++ b/pyro_temporal_api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0,<0.31", "python-multipart>=0.0.9,<0.1", "pyyaml>=6.0", - "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core", + "temporal-model-core[onnx] @ git+https://github.com/pyronear/temporal-model.git@7c1500ef9d6538b36666ce7a43f0e08ca175592e#subdirectory=core", ] [dependency-groups] diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py index 29fae01b..368b7bec 100644 --- a/pyro_temporal_api/pyro_temporal_api/jobs.py +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -76,6 +76,7 @@ def _to_frame_detections(frames: list[Any], boxes: list[list[list[float]]]) -> d def score(model: Any, job: Job) -> dict[str, Any]: """Run the temporal model on a job's window; frames live on disk only for the call.""" + hits, misses = getattr(model, "cache_hits", 0), getattr(model, "cache_misses", 0) with tempfile.TemporaryDirectory(prefix="temporal_job_") as td: paths = [] for frame_id, data in job.frames: @@ -90,6 +91,9 @@ def score(model: Any, job: Job) -> dict[str, Any]: "is_positive": bool(out.is_positive), "probability": max(probs) if probs else None, "n_tubes": len(kept), + # ViT slots served from the embedding cache vs embedded for this job (0/0 without a cache) + "embeddings_cached": getattr(model, "cache_hits", 0) - hits, + "embeddings_new": getattr(model, "cache_misses", 0) - misses, } diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py index dfa218f0..1d0b1d74 100644 --- a/pyro_temporal_api/pyro_temporal_api/main.py +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -29,26 +29,38 @@ MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) MAX_FRAMES = 20 +# Per-slot ViT embeddings kept across jobs, keyed by (frame id, crop box): the engine resubmits the +# same window one frame longer every round, so most slots were embedded on the previous job. +# One entry is 1.5 KB (ViT-S); a pose contributes ~10 frames x tubes per job. +EMBEDDING_CACHE_SIZE = 4096 # Inference frames are a few hundred KB; anything larger is not an engine frame. MAX_FRAME_BYTES = 4 * 1024 * 1024 @asynccontextmanager async def lifespan(app: FastAPI): - model = OnnxTemporalModel.from_package(MODEL_PATH) + manifest = read_manifest(MODEL_PATH) + split = manifest.get("format_version", 1) >= 2 + if not split: + logger.warning( + "%s is a format 1 package (fused classifier.onnx): every slot is embedded on every job. " + "Re-export it with temporal-export-onnx to skip padded slots and cache embeddings.", + MODEL_PATH, + ) + model = OnnxTemporalModel.from_package(MODEL_PATH, embedding_cache_size=EMBEDDING_CACHE_SIZE if split else 0) app.state.jobs = JobStore(model) - app.state.model_version = model_version(MODEL_PATH) + app.state.model_version = manifest.get("model_version") logger.info("Loaded temporal model %s (version %s)", MODEL_PATH, app.state.model_version) yield -def model_version(path: Path) -> str | None: +def read_manifest(path: Path) -> dict: import zipfile import yaml with zipfile.ZipFile(path) as zf: - return yaml.safe_load(zf.read("manifest.yaml")).get("model_version") + return dict(yaml.safe_load(zf.read("manifest.yaml"))) app = FastAPI(title="Pyro Temporal API", lifespan=lifespan) diff --git a/pyro_temporal_api/requirements.txt b/pyro_temporal_api/requirements.txt index decc3ceb..ae02645b 100644 --- a/pyro_temporal_api/requirements.txt +++ b/pyro_temporal_api/requirements.txt @@ -77,7 +77,7 @@ pyyaml==6.0.3 # uvicorn starlette==0.37.2 # via fastapi -temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#subdirectory=core +temporal-model-core @ git+https://github.com/pyronear/temporal-model.git@7c1500ef9d6538b36666ce7a43f0e08ca175592e#subdirectory=core # via pyro-temporal-api tqdm==4.70.0 # via huggingface-hub diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py index 6f3c470d..c3d3fe1f 100644 --- a/pyro_temporal_api/tests/test_jobs.py +++ b/pyro_temporal_api/tests/test_jobs.py @@ -70,7 +70,13 @@ def test_job_lifecycle_and_conversion(client): body = c.get(f"/jobs/{job_id}").json() assert body["status"] == "done" assert body["cam_id"] == "192.168.1.10_1" - assert body["verdict"] == {"is_positive": True, "probability": 0.8, "n_tubes": 1} + assert body["verdict"] == { + "is_positive": True, + "probability": 0.8, + "n_tubes": 1, + "embeddings_cached": 0, + "embeddings_new": 0, + } frames, dets = model.calls[0] assert [f.frame_id for f in frames] == [f"192.168.1.10_1_2026-09-08T10-00-0{i}" for i in range(3)] diff --git a/pyro_temporal_api/uv.lock b/pyro_temporal_api/uv.lock index af62e595..37f08025 100644 --- a/pyro_temporal_api/uv.lock +++ b/pyro_temporal_api/uv.lock @@ -419,7 +419,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110.0,<0.111" }, { name = "python-multipart", specifier = ">=0.0.9,<0.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" }, + { name = "temporal-model-core", extras = ["onnx"], git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=7c1500ef9d6538b36666ce7a43f0e08ca175592e" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.31" }, ] @@ -505,7 +505,7 @@ wheels = [ [[package]] name = "temporal-model-core" version = "0.1.0" -source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=fb7f6ca855cafa8a2ca0981ac54487c63892f6ad#fb7f6ca855cafa8a2ca0981ac54487c63892f6ad" } +source = { git = "https://github.com/pyronear/temporal-model.git?subdirectory=core&rev=7c1500ef9d6538b36666ce7a43f0e08ca175592e#7c1500ef9d6538b36666ce7a43f0e08ca175592e" } dependencies = [ { name = "huggingface-hub" }, { name = "numpy" }, From 7c59b65b5b39009d4a65ed655c83117c48f87505 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 11:58:18 +0200 Subject: [PATCH 13/17] fix(temporal): review fixes on the engine gate and the service - clear the job on every fail-open path so a lost job cannot keep an event alive - fail open when a job stays pending longer than temporal_job_timeout (120 s) - submit the engine's own prediction window (widened to 10 frames) instead of a second buffer - service: at most 10 frames per job, box and JPEG validation, duplicate frame ids rejected - service: /health is 503 when the worker died or is stuck, results published under the lock - 5 s client timeout, temporal API tests in CI and make test, make run-no-temporal --- .github/workflows/tests.yml | 5 ++ CLAUDE.md | 2 +- Makefile | 7 +++ pyro_temporal_api/pyro_temporal_api/jobs.py | 30 ++++++++-- pyro_temporal_api/pyro_temporal_api/main.py | 45 ++++++++++++--- pyro_temporal_api/tests/test_jobs.py | 31 ++++++++++- pyroengine/engine.py | 61 ++++++++++++++------ pyroengine/temporal_client.py | 5 +- tests/test_engine_temporal.py | 62 ++++++++++++++++----- 9 files changed, 202 insertions(+), 46 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 99164773..491d0d0f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,6 +51,11 @@ jobs: run: | uv sync --locked --group test uv run pytest tests/ + - name: Run temporal API tests + working-directory: pyro_temporal_api + run: | + uv sync --locked --group dev + uv run pytest tests/ - name: Upload coverage artifact uses: actions/upload-artifact@v4 with: diff --git a/CLAUDE.md b/CLAUDE.md index 8d60382d..649ecdf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` keeps the last `temporal_window` (10) inference JPEGs and their YOLO boxes per `cam_id`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors fail open (alert sent unvalidated). The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` widens its per-`cam_id` prediction window to `temporal_window` (10) frames (JPEG + YOLO boxes); detection still pools the last `nb_consecutive_frames`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately, at most `MAX_FRAMES` = 10 frames) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors, a lost job and a job pending longer than `temporal_job_timeout` (120 s) fail open (alert sent unvalidated). `/health` returns 503 when the scoring worker died or is stuck. The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. ### Stream-awareness diff --git a/Makefile b/Makefile index 7b5e80f5..d9b4c84e 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ style: test: coverage run -m pytest tests/ cd pyro_camera_api && PYTHONPATH=. uv run --group test pytest tests/ + cd pyro_temporal_api && uv run --group dev pytest tests/ # Build documentation for current version single-docs: @@ -60,6 +61,12 @@ run: docker pull pyronear/pyro-temporal-api:latest docker compose up -d +# Pull and run the stack without the temporal validation service, whatever .env says +run-no-temporal: + docker pull pyronear/pyro-engine:latest + docker pull pyronear/pyro-camera-api:latest + COMPOSE_PROFILES= TEMPORAL_API_URL= docker compose up -d + # Build images locally and run the stack run_local: build-api build-temporal build-app docker compose up -d diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py index 368b7bec..e83519a5 100644 --- a/pyro_temporal_api/pyro_temporal_api/jobs.py +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -11,6 +11,7 @@ import queue import tempfile import threading +import time import uuid from collections import OrderedDict from dataclasses import dataclass, field @@ -24,6 +25,8 @@ MAX_JOBS_KEPT = 100 # One job per camera pose per round is the normal load; more means a stuck worker or a flood. MAX_PENDING = 16 +# A job scores in seconds; one running longer than this means the worker is stuck (health goes red). +STUCK_AFTER = 120.0 class QueueFullError(RuntimeError): @@ -105,6 +108,7 @@ def __init__(self, model: Any) -> None: self._jobs: OrderedDict[str, Job] = OrderedDict() self._queue: queue.Queue[Job] = queue.Queue() self._lock = threading.Lock() + self._running_since: float | None = None self._worker = threading.Thread(target=self._run, name="temporal-worker", daemon=True) self._worker.start() @@ -123,6 +127,19 @@ def get(self, job_id: str) -> Job | None: with self._lock: return self._jobs.get(job_id) + def snapshot(self, job_id: str) -> dict[str, Any] | None: + """The job as returned by the API, read under the lock the worker publishes results with.""" + with self._lock: + job = self._jobs.get(job_id) + return job.to_dict() if job is not None else None + + def healthy(self) -> bool: + """False when the worker thread died or has been scoring one job for longer than STUCK_AFTER.""" + if not self._worker.is_alive(): + return False + since = self._running_since + return since is None or time.monotonic() - since < STUCK_AFTER + def wait(self, job_id: str, timeout: float) -> bool: """Block until the job finishes (tests and synchronous callers).""" job = self.get(job_id) @@ -131,14 +148,17 @@ def wait(self, job_id: str, timeout: float) -> bool: def _run(self) -> None: while True: job = self._queue.get() + self._running_since = time.monotonic() try: - job.verdict = score(self._model, job) - job.status = "done" - logger.info("job %s cam %s: %s", job.job_id, job.cam_id, job.verdict) + verdict = score(self._model, job) + with self._lock: # publish verdict and status together: no half-updated reads + job.verdict, job.status = verdict, "done" + logger.info("job %s cam %s: %s", job.job_id, job.cam_id, verdict) except Exception as exc: - job.status = "error" - job.error = f"{type(exc).__name__}: {exc}" + with self._lock: + job.error, job.status = f"{type(exc).__name__}: {exc}", "error" logger.exception("job %s cam %s failed", job.job_id, job.cam_id) finally: + self._running_since = None job.frames = [] # release the JPEGs once scored job._done.set() diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py index 1d0b1d74..c5382c7c 100644 --- a/pyro_temporal_api/pyro_temporal_api/main.py +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -13,13 +13,16 @@ from __future__ import annotations +import io import json import logging +import math import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from PIL import Image from temporal_model.core.onnx_model import OnnxTemporalModel from pyro_temporal_api.jobs import JobStore, QueueFullError @@ -28,7 +31,9 @@ logger = logging.getLogger(__name__) MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) -MAX_FRAMES = 20 +# The engine submits its 10-frame prediction window; the model scores at most that many slots per tube. +MAX_FRAMES = 10 +MAX_BOXES_PER_FRAME = 50 # Per-slot ViT embeddings kept across jobs, keyed by (frame id, crop box): the engine resubmits the # same window one frame longer every round, so most slots were embedded on the previous job. # One entry is 1.5 KB (ViT-S); a pose contributes ~10 frames x tubes per job. @@ -68,9 +73,22 @@ def read_manifest(path: Path) -> dict: @app.get("/health") def health(request: Request): + jobs = getattr(request.app.state, "jobs", None) + if jobs is not None and not jobs.healthy(): + raise HTTPException(status_code=503, detail="temporal worker dead or stuck") return {"status": "ok", "model_version": getattr(request.app.state, "model_version", None)} +def _valid_box(box: object) -> bool: + """[x1, y1, x2, y2, conf]: finite numbers, normalized xyxy with x1 < x2 and y1 < y2, conf in [0, 1].""" + if not isinstance(box, list) or len(box) != 5: + return False + if not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) for v in box): + return False + x1, y1, x2, y2, conf = box + return 0 <= x1 < x2 <= 1 and 0 <= y1 < y2 <= 1 and 0 <= conf <= 1 + + @app.post("/jobs", status_code=202) async def submit_job( request: Request, @@ -89,15 +107,26 @@ async def submit_job( if len(parsed) != len(frames): raise HTTPException(status_code=422, detail=f"{len(frames)} frames but {len(parsed)} box lists") for fb in parsed: - if any(not isinstance(b, list) or len(b) != 5 for b in fb): - raise HTTPException(status_code=422, detail="each box must be [x1, y1, x2, y2, conf]") - - payload = [] + if len(fb) > MAX_BOXES_PER_FRAME: + raise HTTPException(status_code=422, detail=f"more than {MAX_BOXES_PER_FRAME} boxes in one frame") + if not all(_valid_box(b) for b in fb): + raise HTTPException( + status_code=422, detail="each box must be [x1, y1, x2, y2, conf], normalized, x1 < x2, y1 < y2" + ) + + payload: list[tuple[str, bytes]] = [] for f in frames: data = await f.read(MAX_FRAME_BYTES + 1) if len(data) > MAX_FRAME_BYTES: raise HTTPException(status_code=413, detail=f"frame {f.filename} exceeds {MAX_FRAME_BYTES} bytes") - payload.append((Path(f.filename or "frame").stem, data)) + frame_id = Path(f.filename or "frame").stem + if any(frame_id == seen for seen, _ in payload): + raise HTTPException(status_code=422, detail=f"duplicate frame id {frame_id}") + try: + Image.open(io.BytesIO(data)).verify() + except Exception as exc: + raise HTTPException(status_code=422, detail=f"frame {frame_id} is not a valid image: {exc}") from exc + payload.append((frame_id, data)) try: job = request.app.state.jobs.submit(cam_id, payload, parsed) except QueueFullError as exc: @@ -107,7 +136,7 @@ async def submit_job( @app.get("/jobs/{job_id}") def get_job(request: Request, job_id: str): - job = request.app.state.jobs.get(job_id) + job = request.app.state.jobs.snapshot(job_id) if job is None: raise HTTPException(status_code=404, detail="unknown job") - return job.to_dict() + return job diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py index c3d3fe1f..37b28907 100644 --- a/pyro_temporal_api/tests/test_jobs.py +++ b/pyro_temporal_api/tests/test_jobs.py @@ -60,6 +60,16 @@ def test_health(client): assert c.get("/health").json() == {"status": "ok", "model_version": "test"} +def test_health_is_503_when_worker_dead_or_stuck(client, monkeypatch): + c, _ = client + store = c.app.state.jobs + monkeypatch.setattr(store, "_running_since", 0.0) # scoring "since boot": stuck + assert c.get("/health").status_code == 503 + monkeypatch.setattr(store, "_running_since", None) + monkeypatch.setattr(store, "_worker", SimpleNamespace(is_alive=lambda: False)) + assert c.get("/health").status_code == 503 + + def test_job_lifecycle_and_conversion(client): c, model = client r = _submit(c, n_frames=3) @@ -106,7 +116,13 @@ def test_unknown_job_is_404(client): [ ({"n_frames": 2, "boxes": [[]]}, "box lists"), ({"n_frames": 1, "boxes": [[[0.1, 0.1, 0.2]]]}, "each box"), - ({"n_frames": 21}, "expected 1 to 20"), + ({"n_frames": 1, "boxes": [[["0.1", 0.1, 0.2, 0.2, 0.5]]]}, "each box"), + ({"n_frames": 1, "boxes": [[[0.1, 0.1, float("inf"), 0.2, 0.5]]]}, "each box"), + ({"n_frames": 1, "boxes": [[[0.3, 0.1, 0.2, 0.2, 0.5]]]}, "x1 < x2"), + ({"n_frames": 1, "boxes": [[[0.1, 0.1, 1.2, 0.2, 0.5]]]}, "normalized"), + ({"n_frames": 1, "boxes": [[[0.1, 0.1, 0.2, 0.2, 1.5]]]}, "each box"), + ({"n_frames": 1, "boxes": [[[0.1, 0.1, 0.2, 0.2, 0.5]] * 51]}, "more than 50"), + ({"n_frames": 11}, "expected 1 to 10"), ], ) def test_rejects_malformed_submissions(client, kwargs, detail): @@ -116,6 +132,19 @@ def test_rejects_malformed_submissions(client, kwargs, detail): assert detail in r.json()["detail"] +def test_rejects_duplicate_frame_ids_and_corrupt_images(client): + c, _ = client + files = [("frames", ("same.jpg", _jpeg(), "image/jpeg"))] * 2 + r = c.post("/jobs", data={"cam_id": "x", "boxes": json.dumps([[], []])}, files=files) + assert r.status_code == 422 + assert "duplicate" in r.json()["detail"] + + files = [("frames", ("bad.jpg", b"not a jpeg", "image/jpeg"))] + r = c.post("/jobs", data={"cam_id": "x", "boxes": json.dumps([[]])}, files=files) + assert r.status_code == 422 + assert "not a valid image" in r.json()["detail"] + + def test_frames_are_released_after_scoring(client): c, _ = client job_id = _submit(c).json()["job_id"] diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 435a2f31..01484c9e 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -10,7 +10,7 @@ import time from collections import deque from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Never, Optional, Tuple @@ -23,7 +23,7 @@ from requests.exceptions import RequestException from requests.models import Response -from pyroengine.temporal_client import TemporalClient +from pyroengine.temporal_client import TemporalClient, WindowFrame __all__ = ["ContextCrop", "Engine"] @@ -144,14 +144,17 @@ def __init__( last_bbox_mask_fetch_period: int = 3600, # 1H temporal_api_url: Optional[str] = None, temporal_window: int = 10, + temporal_job_timeout: float = 120.0, **kwargs: Any, # noqa: ANN401 ) -> None: cam_ids = list(cam_creds.keys()) if isinstance(cam_creds, dict) else None # Temporal validation: when set, an ongoing alert is only staged once the temporal - # service has confirmed the last `temporal_window` frames of that camera pose. + # service has confirmed the last `temporal_window` frames of that camera pose. A job + # still pending after `temporal_job_timeout` seconds fails open (alert sent unvalidated). # Set before super().__init__, which already builds the per-camera states. self.temporal = TemporalClient(temporal_api_url) if temporal_api_url else None self.temporal_window = temporal_window + self.temporal_job_timeout = temporal_job_timeout super().__init__( model_path=model_path, conf_thresh=conf_thresh, @@ -216,10 +219,30 @@ def _new_state(self) -> Dict[str, Any]: return state def _init_temporal_state(self, state: Dict[str, Any]) -> None: - state["temporal_frames"] = deque(maxlen=self.temporal_window) - state["temporal_job"] = None + # One buffer for detection and temporal validation: the frames the temporal model scores + # are the frames staged on a positive verdict. Detection still pools the last + # nb_consecutive_frames only. + if self.temporal is not None and self.temporal_window > (state["last_predictions"].maxlen or 0): + state["last_predictions"] = deque(state["last_predictions"], maxlen=self.temporal_window) + state["temporal_job"] = None # (job_id, submitted_at) while a verdict is pending state["temporal_validated"] = False + def _temporal_window(self, cam_key: str) -> List[WindowFrame]: + """The prediction window as (frame_id, jpeg, boxes) triples, oldest first. + + The frame id is the capture timestamp (microsecond precision), so a resubmitted frame keeps + its id and the service can reuse its cached embedding. + """ + window = [] + for _, preds, _, ts, _, jpeg in self._states[cam_key]["last_predictions"]: + if jpeg is None: + continue + boxes = np.round(preds[:, :5], 4).tolist() + # The service rejects degenerate boxes (rounded/clipped to zero width or height at the image edge) + boxes = [b for b in boxes if b[0] < b[2] and b[1] < b[3]] + window.append((f"{cam_key}_{ts.replace(':', '-')}", jpeg, boxes)) + return window + def _end_event(self, cam_key: str) -> None: """Reset per-event staging state when an alert ends. @@ -230,7 +253,6 @@ def _end_event(self, cam_key: str) -> None: """ state = self._states[cam_key] state["event_crop_boxes"] = [] - # The temporal frame window survives events; only the verdict of this event is dropped. state["temporal_job"] = None state["temporal_validated"] = False window = state["last_predictions"] @@ -244,18 +266,27 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo Round N submits the frame window; round N+1 reads the verdict. A negative verdict resubmits the window (now one frame longer) so the event can still be confirmed later; with ``resubmit`` False (the event already ended) a negative verdict just drops it. A transport - error or a failed job fails open: the alert goes out as without temporal validation. + error, a failed job or a job pending longer than ``temporal_job_timeout`` fails open: the + alert goes out as without temporal validation. """ state = self._states[cam_key] if state["temporal_validated"] or self.temporal is None: return True try: - job_id = state["temporal_job"] - if job_id is not None: + if state["temporal_job"] is not None: + job_id, submitted_at = state["temporal_job"] result = self.temporal.result(job_id) if result["status"] == "pending": - logger.info(f"Camera '{cam_id}' - temporal verdict pending, alert held") - return False + if time.monotonic() - submitted_at < self.temporal_job_timeout: + logger.info(f"Camera '{cam_id}' - temporal verdict pending, alert held") + return False + logger.error( + f"Camera '{cam_id}' - temporal job still pending after {self.temporal_job_timeout:.0f}s, " + "alert sent unvalidated" + ) + state["temporal_job"] = None + state["temporal_validated"] = True + return True state["temporal_job"] = None if result["status"] == "done": verdict = result["verdict"] @@ -280,12 +311,13 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo return True elif not resubmit: return False - window = list(state["temporal_frames"]) - state["temporal_job"] = self.temporal.submit(cam_id, window) + window = self._temporal_window(cam_key) + state["temporal_job"] = (self.temporal.submit(cam_id, window), time.monotonic()) logger.info(f"Camera '{cam_id}' - temporal validation submitted ({len(window)} frames), alert held") return False except (RequestException, KeyError, ValueError) as e: logger.error(f"Camera '{cam_id}' - temporal service unavailable ({e}), alert sent unvalidated") + state["temporal_job"] = None # a lost job must not keep the event alive forever state["temporal_validated"] = True return True @@ -411,9 +443,6 @@ def predict( extra_boxes = state["event_crop_boxes"] if state["ongoing"] else None context_crop = self._build_context_crop(original_frame, preds, extra_boxes) conf = self._update_states(context_crop, preds, cam_key, encoded_bytes=encoded_bytes) - if self.temporal is not None and encoded_bytes is not None: - frame_id = f"{cam_key}_{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%S')}" - state["temporal_frames"].append((frame_id, encoded_bytes, np.round(preds[:, :5], 4).tolist())) if self.save_captured_frames: self._local_backup(frame, cam_id, is_alert=False, encoded_bytes=encoded_bytes) diff --git a/pyroengine/temporal_client.py b/pyroengine/temporal_client.py index 87e682d9..4b7e9a09 100644 --- a/pyroengine/temporal_client.py +++ b/pyroengine/temporal_client.py @@ -10,7 +10,7 @@ import requests -__all__ = ["TemporalClient"] +__all__ = ["TemporalClient", "WindowFrame"] # (frame_id, jpeg bytes, boxes as [[x1, y1, x2, y2, conf], ...] normalized xyxy) WindowFrame = Tuple[str, bytes, List[List[float]]] @@ -19,7 +19,8 @@ class TemporalClient: """Submit a window of frames for one camera pose, then poll the verdict on a later round.""" - def __init__(self, url: str, timeout: float = 10.0) -> None: + # Localhost service: a longer wait would stall the inference loop of every other camera. + def __init__(self, url: str, timeout: float = 5.0) -> None: self.url = url.rstrip("/") self.timeout = timeout diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py index 41bd62b3..0ab82c8e 100644 --- a/tests/test_engine_temporal.py +++ b/tests/test_engine_temporal.py @@ -8,6 +8,7 @@ import numpy as np import pytest from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import HTTPError from pyroengine.engine import Engine @@ -15,6 +16,8 @@ # fake_pred is (cx, cy, w, h, conf) in model pixel space (imgsz 1024); # post_process turns it into normalized xyxy [0.4, 0.4, 0.5, 0.5, 0.6] PRED = np.array([[460.8, 460.8, 102.4, 102.4, 0.6]]) +EMPTY = np.empty((5, 0)) +DONE = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} class FakeTemporal: @@ -54,15 +57,19 @@ def _fire(engine, image, n=1): return engine._states[CAM] -def test_window_collects_frames_and_boxes(tmp_path, mock_wildfire_image): +def test_window_is_the_prediction_window(tmp_path, mock_wildfire_image): engine = _engine(tmp_path, FakeTemporal([{"status": "pending"}] * 3), temporal_window=3) state = _fire(engine, mock_wildfire_image, n=4) - window = list(state["temporal_frames"]) - assert len(window) == 3 # bounded ring buffer + assert state["last_predictions"].maxlen == 3 # widened from nb_consecutive_frames=2 + window = engine._temporal_window(CAM) + assert len(window) == 3 frame_id, jpeg, boxes = window[-1] assert frame_id.startswith(f"{CAM}_20") + assert len({f for f, _, _ in window}) == 3 # ids unique at microsecond precision assert jpeg[:2] == b"\xff\xd8" # JPEG magic assert boxes == [[0.4, 0.4, 0.5, 0.5, 0.6]] + # resubmission keeps the ids of frames already sent (embedding cache hits on the service) + assert [f for f, _, _ in engine._temporal_window(CAM)] == [f for f, _, _ in window] def test_alert_held_until_positive_verdict(tmp_path, mock_wildfire_image): @@ -73,16 +80,16 @@ def test_alert_held_until_positive_verdict(tmp_path, mock_wildfire_image): state = _fire(engine, mock_wildfire_image) # ongoing on the first frame -> submit assert state["ongoing"] assert len(temporal.submitted) == 1 - assert state["temporal_job"] == "job1" + assert state["temporal_job"][0] == "job1" assert len(engine._alerts) == 0 _fire(engine, mock_wildfire_image) # pending -> held assert len(engine._alerts) == 0 - assert state["temporal_job"] == "job1" + assert state["temporal_job"][0] == "job1" _fire(engine, mock_wildfire_image) # negative -> resubmitted with a longer window assert len(engine._alerts) == 0 - assert state["temporal_job"] == "job2" + assert state["temporal_job"][0] == "job2" assert len(temporal.submitted[1][1]) == 3 _fire(engine, mock_wildfire_image) # positive -> validated, window staged @@ -120,26 +127,55 @@ def test_end_of_event_resets_verdict_but_keeps_window(tmp_path, mock_wildfire_im engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image, n=3) assert state["temporal_validated"] is True - n_frames = len(state["temporal_frames"]) + n_frames = len(state["last_predictions"]) for _ in range(3): engine.predict(mock_forest_image, CAM, fake_pred=np.empty((5, 0))) assert state["ongoing"] is False assert state["temporal_validated"] is False assert state["temporal_job"] is None - assert len(state["temporal_frames"]) >= n_frames + assert len(state["last_predictions"]) >= n_frames def test_disabled_without_url(tmp_path, mock_wildfire_image): - engine = Engine(cache_folder=str(tmp_path), conf_thresh=0.1, nb_consecutive_frames=2) + engine = Engine(cache_folder=str(tmp_path), conf_thresh=0.1, nb_consecutive_frames=2, cam_creds={CAM: ("token", 7)}) + engine.api_client = {"192.168.1.10": MagicMock()} assert engine.temporal is None state = _fire(engine, mock_wildfire_image, n=2) assert state["ongoing"] - assert len(state["temporal_frames"]) == 0 + assert state["last_predictions"].maxlen == 2 # window untouched + assert len(engine._alerts) > 0 # staged on the spot, no gate -EMPTY = np.empty((5, 0)) -DONE = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} +def test_lost_job_fails_open_and_event_can_end(tmp_path, mock_wildfire_image, mock_forest_image): + """Regression: a 404 (service restarted) used to leave temporal_job set, so the event never ended + and every later frame of the pose was staged as an alert.""" + temporal = FakeTemporal() + temporal.result = MagicMock(side_effect=HTTPError("404 unknown job")) + engine = _engine(tmp_path, temporal) + state = _fire(engine, mock_wildfire_image) # submitted + _fire(engine, mock_wildfire_image) # poll fails -> fail open + assert state["temporal_validated"] is True + assert state["temporal_job"] is None + assert len(engine._alerts) > 0 + + for _ in range(3): + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) + assert state["ongoing"] is False + assert state["temporal_validated"] is False # event ended and was reset + n_alerts = len(engine._alerts) + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) + assert len(engine._alerts) == n_alerts # nothing staged once the event is over + + +def test_job_pending_too_long_fails_open(tmp_path, mock_wildfire_image): + temporal = FakeTemporal([{"status": "pending"}]) + engine = _engine(tmp_path, temporal, temporal_job_timeout=0) + state = _fire(engine, mock_wildfire_image) # submitted + _fire(engine, mock_wildfire_image) # still pending past the deadline -> fail open + assert state["temporal_validated"] is True + assert state["temporal_job"] is None + assert len(engine._alerts) > 0 def test_event_ending_during_validation_still_uploads_on_positive(tmp_path, mock_wildfire_image, mock_forest_image): @@ -149,7 +185,7 @@ def test_event_ending_during_validation_still_uploads_on_positive(tmp_path, mock engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # still ongoing (hysteresis), pending engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # event over, verdict still pending: kept assert state["ongoing"] is False - assert state["temporal_job"] == "job1" + assert state["temporal_job"][0] == "job1" assert len(engine._alerts) == 0 engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # positive verdict: held frames go out From 9361cf725942aba36b4a2bd4a2c9c8e7d6dbb173 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:29:51 +0200 Subject: [PATCH 14/17] feat(temporal): send per-box temporal scores, let the API apply the threshold Matches pyro-api PR 723 (temporal_model_scores on POST /detections): - the service verdict lists each scored tube with its union box and probability - the engine scores the window every round while an alert is ongoing, uploads the boxes covered by a tube above temporal_min_score (0.1) with that probability, drops the others - fail open (service down, lost or stuck job) sends the frames without scores so the API scores the sequence itself - pyroclient pinned to the PR 723 commit (repoint to main once merged), requirements regenerated with uv 0.13.4 --- CLAUDE.md | 2 +- README.md | 6 +- pyproject.toml | 2 +- pyro_temporal_api/pyro_temporal_api/jobs.py | 21 +++- pyro_temporal_api/tests/test_jobs.py | 25 +++- pyroengine/engine.py | 127 ++++++++++++++------ requirements-git.txt | 2 +- requirements.txt | 21 +++- tests/test_engine_temporal.py | 99 ++++++++++----- uv.lock | 4 +- 10 files changed, 229 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 649ecdf0..86dfbca3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` widens its per-`cam_id` prediction window to `temporal_window` (10) frames (JPEG + YOLO boxes); detection still pools the last `nb_consecutive_frames`. Once an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` (`POST /jobs`, returns immediately, at most `MAX_FRAMES` = 10 frames) and reads the verdict on the next round (`GET /jobs/{id}`). Frames are only staged for upload after a positive verdict; a negative verdict resubmits the window with the new frame; a pending job holds the alert one more round. Service errors, a lost job and a job pending longer than `temporal_job_timeout` (120 s) fail open (alert sent unvalidated). `/health` returns 503 when the scoring worker died or is stuck. The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` widens its per-`cam_id` prediction window to `temporal_window` (10) frames (JPEG + YOLO boxes); detection still pools the last `nb_consecutive_frames`. While an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` every round (`POST /jobs`, returns immediately, at most `MAX_FRAMES` = 10 frames) and reads the verdict on the next round (`GET /jobs/{id}`). The verdict lists the scored tubes (union box + probability); boxes covered by a tube scoring at least `temporal_min_score` (0.1) are uploaded with that probability as `temporal_model_scores` (pyro-api PR 723 applies its own threshold instead of scoring the sequence), boxes outside every scored tube are dropped. A verdict without such tube stages nothing (frames wait for the next one); a pending job holds the alert one more round. Service errors, a lost job and a job pending longer than `temporal_job_timeout` (120 s) fail open: the rest of the event is sent without scores and the API scores it itself. `/health` returns 503 when the scoring worker died or is stuck. The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. ### Stream-awareness diff --git a/README.md b/README.md index a28720b0..ad6876d9 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,10 @@ PYRO_ENGINE_VERSION=latest Optional temporal validation of alerts: with `COMPOSE_PROFILES=temporal` and `TEMPORAL_API_URL=http://localhost:8082` in `.env`, the stack also starts `pyro_temporal_api`. While an alert is ongoing, the engine sends the last frames and -boxes of that camera pose to it and only uploads the alert once the temporal smoke model confirms it. Leave both unset -to send alerts as before. The service needs `data/model_onnx.zip`: the [temporal-model](https://github.com/pyronear/temporal-model) +boxes of that camera pose to it every round and uploads each box with the smoke probability of the tube covering it +(`temporal_model_scores`); boxes below 0.1 or outside every scored tube are dropped, and the API applies its own +threshold instead of running the temporal model itself. If the service is unreachable the frames go out without +scores and the API scores them. Leave both unset to send alerts as before. The service needs `data/model_onnx.zip`: the [temporal-model](https://github.com/pyronear/temporal-model) release on Hugging Face (`pyronear/temporal-model`, revision `v0.6.0`, trained on pyro-dataset v4.7.0), re-exported with `temporal-export-onnx` so the package carries the split backbone/head graphs. With those, the service embeds only real frames and keeps per-frame ViT embeddings across rounds, so re-scoring a window one frame longer only costs diff --git a/pyproject.toml b/pyproject.toml index d77f2aa2..b384d2e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ dependencies = [ "requests>=2.33.0,<3", "python-dotenv==1.1.0", - "pyroclient @ git+https://github.com/pyronear/pyro-api.git@main#subdirectory=client", + "pyroclient @ git+https://github.com/pyronear/pyro-api.git@658cc6a2a28d9b52fd33167b7affc6ddae4e45b1#subdirectory=client", "pyro_camera_api_client", "pyro_predictor", "onvif-zeep>=0.2.12", diff --git a/pyro_temporal_api/pyro_temporal_api/jobs.py b/pyro_temporal_api/pyro_temporal_api/jobs.py index e83519a5..5056ad7f 100644 --- a/pyro_temporal_api/pyro_temporal_api/jobs.py +++ b/pyro_temporal_api/pyro_temporal_api/jobs.py @@ -77,6 +77,18 @@ def _to_frame_detections(frames: list[Any], boxes: list[list[list[float]]]) -> d return out +def _tube_summary(tube: dict[str, Any]) -> dict[str, Any] | None: + """``{"probability", "box"}`` of a scored tube, the box being the union of its detections (normalized xyxy).""" + boxes = [e["bbox"] for e in tube["entries"] if e.get("bbox") is not None] + if tube.get("probability") is None or not boxes: + return None + x1 = min(cx - w / 2 for cx, _, w, _ in boxes) + y1 = min(cy - h / 2 for _, cy, _, h in boxes) + x2 = max(cx + w / 2 for cx, _, w, _ in boxes) + y2 = max(cy + h / 2 for _, cy, _, h in boxes) + return {"probability": round(float(tube["probability"]), 4), "box": [round(v, 4) for v in (x1, y1, x2, y2)]} + + def score(model: Any, job: Job) -> dict[str, Any]: """Run the temporal model on a job's window; frames live on disk only for the call.""" hits, misses = getattr(model, "cache_hits", 0), getattr(model, "cache_misses", 0) @@ -89,11 +101,18 @@ def score(model: Any, job: Job) -> dict[str, Any]: frames = model.load_sequence(paths) out = model.predict(frames, frame_detections=_to_frame_detections(frames, job.boxes)) kept = out.details["tubes"]["kept"] - probs = [t["probability"] for t in kept if t["probability"] is not None] + tubes: list[dict[str, Any]] = [] + for t in kept: + summary = _tube_summary(t) + if summary is not None: + tubes.append(summary) + probs = [t["probability"] for t in tubes] return { "is_positive": bool(out.is_positive), "probability": max(probs) if probs else None, "n_tubes": len(kept), + # One entry per scored tube: the engine attaches each probability to the boxes it covers. + "tubes": tubes, # ViT slots served from the embedding cache vs embedded for this job (0/0 without a cache) "embeddings_cached": getattr(model, "cache_hits", 0) - hits, "embeddings_new": getattr(model, "cache_misses", 0) - misses, diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py index 37b28907..1c3eef79 100644 --- a/pyro_temporal_api/tests/test_jobs.py +++ b/pyro_temporal_api/tests/test_jobs.py @@ -29,7 +29,17 @@ def predict(self, frames, frame_detections): if self.fail: raise RuntimeError("boom") self.calls.append((frames, frame_detections)) - kept = [{"logit": 1.0, "probability": self.probability}] if frame_detections else [] + kept = ( + [ + { + "logit": 1.0, + "probability": self.probability, + "entries": [{"bbox": (0.45, 0.45, 0.1, 0.1)}, {"bbox": None}], + } + ] + if frame_detections + else [] + ) return SimpleNamespace(is_positive=self.positive, details={"tubes": {"kept": kept}}) @@ -84,6 +94,7 @@ def test_job_lifecycle_and_conversion(client): "is_positive": True, "probability": 0.8, "n_tubes": 1, + "tubes": [{"probability": 0.8, "box": [0.4, 0.4, 0.5, 0.5]}], "embeddings_cached": 0, "embeddings_new": 0, } @@ -152,6 +163,18 @@ def test_frames_are_released_after_scoring(client): assert c.app.state.jobs.get(job_id).frames == [] +def test_tube_summary_skips_unscored_or_empty_tubes(): + assert jobs._tube_summary({"probability": None, "entries": [{"bbox": (0.5, 0.5, 0.1, 0.1)}]}) is None + assert jobs._tube_summary({"probability": 0.9, "entries": [{"bbox": None}]}) is None + assert jobs._tube_summary({ + "probability": 0.9, + "entries": [{"bbox": (0.2, 0.2, 0.2, 0.2)}, {"bbox": (0.5, 0.5, 0.2, 0.2)}], + }) == { + "probability": 0.9, + "box": [0.1, 0.1, 0.6, 0.6], + } + + def test_to_frame_detections_keeps_empty_frames(): frames = [SimpleNamespace(frame_id="a", timestamp=None), SimpleNamespace(frame_id="b", timestamp=None)] dets = _to_frame_detections(frames, [[], [[0, 0, 1, 1, 0.5]]]) diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 834e8ee9..81aae2ff 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple import numpy as np from PIL import Image @@ -127,16 +127,20 @@ def __init__( temporal_api_url: Optional[str] = None, temporal_window: int = 10, temporal_job_timeout: float = 120.0, + temporal_min_score: float = 0.1, **kwargs: Any, # noqa: ANN401 ) -> None: cam_ids = list(cam_creds.keys()) if isinstance(cam_creds, dict) else None - # Temporal validation: when set, an ongoing alert is only staged once the temporal - # service has confirmed the last `temporal_window` frames of that camera pose. A job - # still pending after `temporal_job_timeout` seconds fails open (alert sent unvalidated). + # Temporal validation: when set, the last `temporal_window` frames of an alerting camera + # pose are scored by the temporal service every round. Boxes covered by a tube scoring at + # least `temporal_min_score` are uploaded with that probability, the API applies its own + # threshold; the other boxes are dropped. A job still pending after `temporal_job_timeout` + # seconds fails open (frames sent without scores, the API scores them itself). # Set before super().__init__, which already builds the per-camera states. self.temporal = TemporalClient(temporal_api_url) if temporal_api_url else None self.temporal_window = temporal_window self.temporal_job_timeout = temporal_job_timeout + self.temporal_min_score = temporal_min_score super().__init__( model_path=model_path, conf_thresh=conf_thresh, @@ -209,7 +213,8 @@ def _init_temporal_state(self, state: Dict[str, Any]) -> None: if self.temporal is not None and self.temporal_window > (state["last_predictions"].maxlen or 0): state["last_predictions"] = deque(state["last_predictions"], maxlen=self.temporal_window) state["temporal_job"] = None # (job_id, submitted_at) while a verdict is pending - state["temporal_validated"] = False + state["temporal_tubes"] = [] # [(xyxy box, probability)] of the last verdict, above temporal_min_score + state["temporal_bypass"] = False # fail open: rest of the event goes out without scores def _temporal_window(self, cam_key: str) -> List[WindowFrame]: """The prediction window as (frame_id, jpeg, boxes) triples, oldest first. @@ -238,25 +243,28 @@ def _end_event(self, cam_key: str) -> None: state = self._states[cam_key] state["event_crop_boxes"] = [] state["temporal_job"] = None - state["temporal_validated"] = False + state["temporal_tubes"] = [] + state["temporal_bypass"] = False window = state["last_predictions"] for i, entry in enumerate(window): if entry[4]: # is_staged: belongs to the event that just ended window[i] = (entry[0], entry[1], [], entry[3], True, entry[5]) def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bool: - """Return True when this event may be staged: temporal verdict positive, or service unavailable. - - Round N submits the frame window; round N+1 reads the verdict. A negative verdict resubmits - the window (now one frame longer) so the event can still be confirmed later; with - ``resubmit`` False (the event already ended) a negative verdict just drops it. A transport - error, a failed job or a job pending longer than ``temporal_job_timeout`` fails open: the - alert goes out as without temporal validation. + """Return True when the frames not yet staged may be staged now. + + Round N submits the frame window; round N+1 reads the verdict, stages the pending frames + with the per-tube probabilities (``state["temporal_tubes"]``) and, while the event is + ongoing (``resubmit``), submits the window again so every frame carries a fresh score. A + verdict with no tube above ``temporal_min_score`` stages nothing; once the event ended it + drops the held frames. A transport error, a failed job or a job pending longer than + ``temporal_job_timeout`` fails open: the rest of the event goes out without scores. """ state = self._states[cam_key] - if state["temporal_validated"] or self.temporal is None: + if state["temporal_bypass"] or self.temporal is None: return True try: + stage = False if state["temporal_job"] is not None: job_id, submitted_at = state["temporal_job"] result = self.temporal.result(job_id) @@ -266,45 +274,74 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo return False logger.error( f"Camera '{cam_id}' - temporal job still pending after {self.temporal_job_timeout:.0f}s, " - "alert sent unvalidated" + "alert sent unscored" ) state["temporal_job"] = None - state["temporal_validated"] = True + state["temporal_bypass"] = True return True state["temporal_job"] = None if result["status"] == "done": verdict = result["verdict"] - prob = verdict.get("probability") - prob_str = f"{prob:.2%}" if prob is not None else "n/a" - if verdict["is_positive"]: - logger.info(f"Camera '{cam_id}' - temporal model confirmed the alert (probability: {prob_str})") - state["temporal_validated"] = True - return True - logger.info( - f"Camera '{cam_id}' - temporal model rejected the alert " - f"(probability: {prob_str}, tubes: {verdict.get('n_tubes')}), " - + ("resubmitting" if resubmit else "event over, dropped") - ) - if not resubmit: - return False + tubes = [ + (tuple(t["box"]), float(t["probability"])) + for t in verdict.get("tubes", []) + if t["probability"] >= self.temporal_min_score + ] + state["temporal_tubes"] = tubes + if tubes: + logger.info( + f"Camera '{cam_id}' - temporal model scored {len(tubes)} tube(s) " + f"(max probability: {max(p for _, p in tubes):.2%})" + ) + stage = True + else: + logger.info( + f"Camera '{cam_id}' - temporal model rejected the alert " + f"(tubes: {verdict.get('n_tubes')}), " + + ("resubmitting" if resubmit else "event over, dropped") + ) + if not resubmit: + return False else: logger.error( - f"Camera '{cam_id}' - temporal job failed ({result.get('error')}), alert sent unvalidated" + f"Camera '{cam_id}' - temporal job failed ({result.get('error')}), alert sent unscored" ) - state["temporal_validated"] = True + state["temporal_bypass"] = True return True elif not resubmit: return False - window = self._temporal_window(cam_key) - state["temporal_job"] = (self.temporal.submit(cam_id, window), time.monotonic()) - logger.info(f"Camera '{cam_id}' - temporal validation submitted ({len(window)} frames), alert held") - return False + if resubmit: + window = self._temporal_window(cam_key) + state["temporal_job"] = (self.temporal.submit(cam_id, window), time.monotonic()) + logger.info(f"Camera '{cam_id}' - temporal scoring submitted ({len(window)} frames)") + return stage except (RequestException, KeyError, ValueError) as e: - logger.error(f"Camera '{cam_id}' - temporal service unavailable ({e}), alert sent unvalidated") + logger.error(f"Camera '{cam_id}' - temporal service unavailable ({e}), alert sent unscored") state["temporal_job"] = None # a lost job must not keep the event alive forever - state["temporal_validated"] = True + state["temporal_bypass"] = True return True + def _temporal_scores(self, cam_key: str, bboxes: list) -> Tuple[list, Optional[List[float]]]: + """Keep the boxes covered by a scored tube and return them with one probability each. + + Without temporal scoring (disabled or failed open) every box is kept and no score is sent, + so the API scores the sequence itself. + """ + state = self._states[cam_key] + if self.temporal is None or state["temporal_bypass"]: + return bboxes, None + kept, scores = [], [] + for box in bboxes: + probs = [p for tube, p in state["temporal_tubes"] if self._overlaps(box, tube)] + if probs: + kept.append(box) + scores.append(round(max(probs), 3)) + return kept, scores + + @staticmethod + def _overlaps(a: Sequence[float], b: Sequence[float]) -> bool: + return min(a[2], b[2]) > max(a[0], b[0]) and min(a[3], b[3]) > max(a[1], b[1]) + def heartbeat(self, cam_id: str, timeout: Optional[float] = None) -> Response: """Updates last ping of device""" ip = cam_id.split("_")[0] @@ -491,10 +528,13 @@ def _stage_event(self, cam_key: str, cam_id: str) -> None: bboxes = [[b[0], b[1], b[2], b[3], 0.0] for b in last_seen] if bboxes: last_seen = bboxes + sent_bboxes, scores = self._temporal_scores(cam_key, bboxes) crop_boxes = ( - self._assign_crop_boxes(bboxes, cam_key, *full_size) if bboxes and full_size is not None else None + self._assign_crop_boxes(sent_bboxes, cam_key, *full_size) + if sent_bboxes and full_size is not None + else None ) - self._stage_alert(crop_, cam_id, ts, bboxes, jpeg_bytes, crop_boxes) + self._stage_alert(crop_, cam_id, ts, sent_bboxes, jpeg_bytes, crop_boxes, scores) state["last_predictions"][idx] = (crop_, preds_, bboxes, ts, True, jpeg_bytes) @staticmethod @@ -769,6 +809,7 @@ def _stage_alert( bboxes: list, jpeg_bytes: Optional[bytes] = None, crop_boxes: Optional[list] = None, + temporal_scores: Optional[List[float]] = None, ) -> None: # Store information in the queue self._alerts.append({ @@ -780,6 +821,7 @@ def _stage_alert( "bboxes": bboxes, "jpeg_bytes": jpeg_bytes, "crop_boxes": crop_boxes, + "temporal_scores": temporal_scores, # one per bbox, None when the API must score itself }) def _process_alerts(self) -> None: @@ -815,7 +857,12 @@ def _process_alerts(self) -> None: _, pose_id = self.cam_creds[cam_id] ip = cam_id.split("_")[0] response = self.api_client[ip].create_detection( - jpeg_bytes, bboxes, pose_id, crops=crops, recorded_at=frame_info["ts"] + jpeg_bytes, + bboxes, + pose_id, + crops=crops, + recorded_at=frame_info["ts"], + temporal_model_scores=frame_info.get("temporal_scores"), ) if response.status_code >= 500 or response.status_code in RETRYABLE_STATUS: diff --git a/requirements-git.txt b/requirements-git.txt index 6d6685aa..df49f45f 100644 --- a/requirements-git.txt +++ b/requirements-git.txt @@ -1 +1 @@ -pyroclient @ git+https://github.com/pyronear/pyro-api.git@fba2e2c8d2e89dbac7d34dddc603e21fa3b5bfb5#subdirectory=client +pyroclient @ git+https://github.com/pyronear/pyro-api.git@658cc6a2a28d9b52fd33167b7affc6ddae4e45b1#subdirectory=client diff --git a/requirements.txt b/requirements.txt index e3f7594c..61dd1286 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,9 @@ # uv export --no-hashes --no-emit-project --no-default-groups --no-dev --no-emit-package pyro_predictor --no-emit-package pyro_camera_api_client --format requirements-txt -o requirements-all.txt anyio==4.14.2 # via httpx -certifi==2026.2.25 +attrs==26.1.0 + # via zeep +certifi==2026.10.19 # via # httpcore # httpx @@ -38,6 +40,10 @@ idna==3.11 # anyio # httpx # requests +isodate==0.7.2 + # via zeep +lxml==6.1.3 + # via zeep mpmath==1.3.0 # via sympy ncnn==1.0.20240410 @@ -51,6 +57,8 @@ numpy==2.4.4 # pyro-predictor onnxruntime==1.22.1 # via pyro-predictor +onvif-zeep==0.2.12 + # via pyroengine # via ncnn opencv-python-headless==4.11.0.86 # via pyro-predictor @@ -62,6 +70,8 @@ pillow==11.0.0 # via # pyro-camera-api-client # pyro-predictor +platformdirs==4.9.6 + # via zeep portalocker==3.2.0 # via ncnn protobuf==7.34.1 @@ -81,6 +91,13 @@ requests==2.34.2 # pyro-camera-api-client # pyroclient # pyroengine + # requests-file + # requests-toolbelt + # zeep +requests-file==3.0.1 + # via zeep +requests-toolbelt==1.0.0 + # via zeep sympy==1.14.0 # via onnxruntime tqdm==4.67.1 @@ -94,3 +111,5 @@ typing-extensions==4.15.0 # huggingface-hub urllib3==2.6.3 # via requests +zeep==4.3.3 + # via onvif-zeep diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py index 0ab82c8e..6889b632 100644 --- a/tests/test_engine_temporal.py +++ b/tests/test_engine_temporal.py @@ -17,7 +17,26 @@ # post_process turns it into normalized xyxy [0.4, 0.4, 0.5, 0.5, 0.6] PRED = np.array([[460.8, 460.8, 102.4, 102.4, 0.6]]) EMPTY = np.empty((5, 0)) -DONE = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} +BOX = [0.4, 0.4, 0.5, 0.5] +FAR_BOX = [0.8, 0.8, 0.9, 0.9] + + +def done(*tubes): + """A done verdict with one (box, probability) tube each.""" + tubes = [{"box": box, "probability": prob} for box, prob in tubes] + probs = [t["probability"] for t in tubes] + return { + "status": "done", + "verdict": { + "is_positive": any(p > 0.5 for p in probs), + "probability": max(probs, default=None), + "n_tubes": len(tubes), + "tubes": tubes, + }, + } + + +PENDING = {"status": "pending"} class FakeTemporal: @@ -57,8 +76,12 @@ def _fire(engine, image, n=1): return engine._states[CAM] +def _scores(engine): + return [a["temporal_scores"] for a in engine._alerts] + + def test_window_is_the_prediction_window(tmp_path, mock_wildfire_image): - engine = _engine(tmp_path, FakeTemporal([{"status": "pending"}] * 3), temporal_window=3) + engine = _engine(tmp_path, FakeTemporal([PENDING] * 3), temporal_window=3) state = _fire(engine, mock_wildfire_image, n=4) assert state["last_predictions"].maxlen == 3 # widened from nb_consecutive_frames=2 window = engine._temporal_window(CAM) @@ -72,9 +95,8 @@ def test_window_is_the_prediction_window(tmp_path, mock_wildfire_image): assert [f for f, _, _ in engine._temporal_window(CAM)] == [f for f, _, _ in window] -def test_alert_held_until_positive_verdict(tmp_path, mock_wildfire_image): - done = lambda positive: {"status": "done", "verdict": {"is_positive": positive, "probability": 0.5, "n_tubes": 1}} - temporal = FakeTemporal([{"status": "pending"}, done(False), done(True)]) +def test_frames_scored_every_round_and_sent_with_tube_scores(tmp_path, mock_wildfire_image): + temporal = FakeTemporal([PENDING, done((BOX, 0.05)), done((BOX, 0.3)), done((BOX, 0.9))]) engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image) # ongoing on the first frame -> submit @@ -83,25 +105,37 @@ def test_alert_held_until_positive_verdict(tmp_path, mock_wildfire_image): assert state["temporal_job"][0] == "job1" assert len(engine._alerts) == 0 - _fire(engine, mock_wildfire_image) # pending -> held + _fire(engine, mock_wildfire_image) # pending -> held, no new job assert len(engine._alerts) == 0 assert state["temporal_job"][0] == "job1" - _fire(engine, mock_wildfire_image) # negative -> resubmitted with a longer window + _fire(engine, mock_wildfire_image) # below temporal_min_score -> nothing staged, resubmitted assert len(engine._alerts) == 0 assert state["temporal_job"][0] == "job2" assert len(temporal.submitted[1][1]) == 3 - _fire(engine, mock_wildfire_image) # positive -> validated, window staged - assert state["temporal_validated"] is True - assert state["temporal_job"] is None - assert len(engine._alerts) > 0 - assert not temporal.results # every scripted verdict was consumed + _fire(engine, mock_wildfire_image) # 0.3 >= 0.1 -> held frames staged with that score, resubmitted + assert len(engine._alerts) == 4 + assert all(s == [0.3] for s in _scores(engine)) + assert state["temporal_job"][0] == "job3" + + _fire(engine, mock_wildfire_image) # next verdict: only the new frame, with the fresh score + assert len(engine._alerts) == 5 + assert _scores(engine)[-1] == [0.9] + assert len(temporal.submitted) == 4 # one job per round while ongoing + assert not temporal.results + - n_staged = len(engine._alerts) - _fire(engine, mock_wildfire_image) # validated event: no more calls, keeps staging - assert len(temporal.submitted) == 2 - assert len(engine._alerts) > n_staged +def test_boxes_outside_scored_tubes_are_dropped(tmp_path, mock_wildfire_image): + engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9))])) + _fire(engine, mock_wildfire_image, n=2) + assert len(engine._alerts) == 2 # frames still go out (continuity) ... + assert all(a["bboxes"] == [] and a["temporal_scores"] == [] for a in engine._alerts) # ... without the box + + engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9), (BOX, 0.2))])) + _fire(engine, mock_wildfire_image, n=2) + assert [[b[:4] for b in a["bboxes"]] for a in engine._alerts] == [[BOX]] * 2 + assert _scores(engine) == [[0.2], [0.2]] @pytest.mark.parametrize( @@ -112,27 +146,29 @@ def test_alert_held_until_positive_verdict(tmp_path, mock_wildfire_image): ], ids=["job_error", "service_down"], ) -def test_service_failure_fails_open(tmp_path, mock_wildfire_image, temporal): +def test_service_failure_fails_open_without_scores(tmp_path, mock_wildfire_image, temporal): engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image) if isinstance(temporal, FakeTemporal): assert len(engine._alerts) == 0 # round N: submitted, held _fire(engine, mock_wildfire_image) # round N+1: job error - assert state["temporal_validated"] is True + assert state["temporal_bypass"] is True assert len(engine._alerts) > 0 + assert all(a["bboxes"] and a["temporal_scores"] is None for a in engine._alerts) def test_end_of_event_resets_verdict_but_keeps_window(tmp_path, mock_wildfire_image, mock_forest_image): - temporal = FakeTemporal([{"status": "done", "verdict": {"is_positive": True, "probability": 0.9, "n_tubes": 1}}]) + temporal = FakeTemporal([done((BOX, 0.9))] * 6) engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image, n=3) - assert state["temporal_validated"] is True + assert state["temporal_tubes"] n_frames = len(state["last_predictions"]) for _ in range(3): - engine.predict(mock_forest_image, CAM, fake_pred=np.empty((5, 0))) + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) assert state["ongoing"] is False - assert state["temporal_validated"] is False + assert state["temporal_tubes"] == [] + assert state["temporal_bypass"] is False assert state["temporal_job"] is None assert len(state["last_predictions"]) >= n_frames @@ -145,6 +181,7 @@ def test_disabled_without_url(tmp_path, mock_wildfire_image): assert state["ongoing"] assert state["last_predictions"].maxlen == 2 # window untouched assert len(engine._alerts) > 0 # staged on the spot, no gate + assert all(a["temporal_scores"] is None for a in engine._alerts) def test_lost_job_fails_open_and_event_can_end(tmp_path, mock_wildfire_image, mock_forest_image): @@ -155,31 +192,31 @@ def test_lost_job_fails_open_and_event_can_end(tmp_path, mock_wildfire_image, mo engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image) # submitted _fire(engine, mock_wildfire_image) # poll fails -> fail open - assert state["temporal_validated"] is True + assert state["temporal_bypass"] is True assert state["temporal_job"] is None assert len(engine._alerts) > 0 for _ in range(3): engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) assert state["ongoing"] is False - assert state["temporal_validated"] is False # event ended and was reset + assert state["temporal_bypass"] is False # event ended and was reset n_alerts = len(engine._alerts) engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) assert len(engine._alerts) == n_alerts # nothing staged once the event is over def test_job_pending_too_long_fails_open(tmp_path, mock_wildfire_image): - temporal = FakeTemporal([{"status": "pending"}]) + temporal = FakeTemporal([PENDING]) engine = _engine(tmp_path, temporal, temporal_job_timeout=0) state = _fire(engine, mock_wildfire_image) # submitted _fire(engine, mock_wildfire_image) # still pending past the deadline -> fail open - assert state["temporal_validated"] is True + assert state["temporal_bypass"] is True assert state["temporal_job"] is None assert len(engine._alerts) > 0 def test_event_ending_during_validation_still_uploads_on_positive(tmp_path, mock_wildfire_image, mock_forest_image): - temporal = FakeTemporal([{"status": "pending"}, {"status": "pending"}, DONE(True)]) + temporal = FakeTemporal([PENDING, PENDING, done((BOX, 0.7))]) engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image) # submitted, held engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # still ongoing (hysteresis), pending @@ -188,15 +225,17 @@ def test_event_ending_during_validation_still_uploads_on_positive(tmp_path, mock assert state["temporal_job"][0] == "job1" assert len(engine._alerts) == 0 - engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # positive verdict: held frames go out + engine.predict(mock_forest_image, CAM, fake_pred=EMPTY) # verdict in: held frames go out, no new job assert len(engine._alerts) > 0 + assert _scores(engine)[0] == [0.7] assert state["temporal_job"] is None - assert state["temporal_validated"] is False # event closed after staging + assert len(temporal.submitted) == 1 + assert state["temporal_tubes"] == [] # event closed after staging assert not temporal.results def test_event_ending_during_validation_dropped_on_negative(tmp_path, mock_wildfire_image, mock_forest_image): - temporal = FakeTemporal([{"status": "pending"}, {"status": "pending"}, DONE(False)]) + temporal = FakeTemporal([PENDING, PENDING, done((BOX, 0.02))]) engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image) for _ in range(3): diff --git a/uv.lock b/uv.lock index 6ea102a4..b57ff222 100644 --- a/uv.lock +++ b/uv.lock @@ -1392,7 +1392,7 @@ requires-dist = [ [[package]] name = "pyroclient" version = "0.2.0.dev0" -source = { git = "https://github.com/pyronear/pyro-api.git?subdirectory=client&rev=main#fba2e2c8d2e89dbac7d34dddc603e21fa3b5bfb5" } +source = { git = "https://github.com/pyronear/pyro-api.git?subdirectory=client&rev=658cc6a2a28d9b52fd33167b7affc6ddae4e45b1#658cc6a2a28d9b52fd33167b7affc6ddae4e45b1" } dependencies = [ { name = "requests" }, ] @@ -1442,7 +1442,7 @@ requires-dist = [ { name = "onvif-zeep", specifier = ">=0.2.12" }, { name = "pyro-camera-api-client", editable = "pyro_camera_api/client" }, { name = "pyro-predictor", editable = "pyro-predictor" }, - { name = "pyroclient", git = "https://github.com/pyronear/pyro-api.git?subdirectory=client&rev=main" }, + { name = "pyroclient", git = "https://github.com/pyronear/pyro-api.git?subdirectory=client&rev=658cc6a2a28d9b52fd33167b7affc6ddae4e45b1" }, { name = "python-dotenv", specifier = "==1.1.0" }, { name = "requests", specifier = ">=2.33.0,<3" }, ] From 3c70979393e6a8f43cd67ea23f1e3415df09af8a Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 23 Oct 2026 10:52:14 +0200 Subject: [PATCH 15/17] fix(temporal): stage only the frames a verdict scored, drop rejected frames for good - a job remembers the frame ids it submitted; its verdict stages those frames only, frames captured afterwards wait for the next verdict - a verdict rejecting an ended event marks the held frames as consumed so a later event cannot upload them under its own score - window of 11 frames, all submitted (service MAX_FRAMES=11) --- CLAUDE.md | 2 +- pyro_temporal_api/pyro_temporal_api/main.py | 4 +- pyro_temporal_api/tests/test_jobs.py | 2 +- pyroengine/engine.py | 52 ++++++++++++++------- tests/test_engine_temporal.py | 39 ++++++++++++---- 5 files changed, 71 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 86dfbca3..fa026370 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` widens its per-`cam_id` prediction window to `temporal_window` (10) frames (JPEG + YOLO boxes); detection still pools the last `nb_consecutive_frames`. While an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` every round (`POST /jobs`, returns immediately, at most `MAX_FRAMES` = 10 frames) and reads the verdict on the next round (`GET /jobs/{id}`). The verdict lists the scored tubes (union box + probability); boxes covered by a tube scoring at least `temporal_min_score` (0.1) are uploaded with that probability as `temporal_model_scores` (pyro-api PR 723 applies its own threshold instead of scoring the sequence), boxes outside every scored tube are dropped. A verdict without such tube stages nothing (frames wait for the next one); a pending job holds the alert one more round. Service errors, a lost job and a job pending longer than `temporal_job_timeout` (120 s) fail open: the rest of the event is sent without scores and the API scores it itself. `/health` returns 503 when the scoring worker died or is stuck. The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` widens its per-`cam_id` prediction window to `temporal_window` (11) frames (JPEG + YOLO boxes); detection still pools the last `nb_consecutive_frames`. While an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` every round (`POST /jobs`, returns immediately, at most `MAX_FRAMES` = 11 frames) and reads the verdict on the next round (`GET /jobs/{id}`). The verdict lists the scored tubes (union box + probability); boxes covered by a tube scoring at least `temporal_min_score` (0.1) are uploaded with that probability as `temporal_model_scores` (pyro-api PR 723 applies its own threshold instead of scoring the sequence), boxes outside every scored tube are dropped. Only the frames a verdict scored are uploaded on it, later frames wait for the next verdict; a verdict without such tube stages nothing (frames wait for the next one); a pending job holds the alert one more round; frames of an ended event rejected by the model are dropped for good. Service errors, a lost job and a job pending longer than `temporal_job_timeout` (120 s) fail open: the rest of the event is sent without scores and the API scores it itself. `/health` returns 503 when the scoring worker died or is stuck. The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. ### Stream-awareness diff --git a/pyro_temporal_api/pyro_temporal_api/main.py b/pyro_temporal_api/pyro_temporal_api/main.py index c5382c7c..7fc2cf06 100644 --- a/pyro_temporal_api/pyro_temporal_api/main.py +++ b/pyro_temporal_api/pyro_temporal_api/main.py @@ -31,8 +31,8 @@ logger = logging.getLogger(__name__) MODEL_PATH = Path(os.environ.get("TEMPORAL_MODEL_PATH", "data/model_onnx.zip")) -# The engine submits its 10-frame prediction window; the model scores at most that many slots per tube. -MAX_FRAMES = 10 +# The engine submits its 11-frame prediction window; the model scores at most that many slots per tube. +MAX_FRAMES = 11 MAX_BOXES_PER_FRAME = 50 # Per-slot ViT embeddings kept across jobs, keyed by (frame id, crop box): the engine resubmits the # same window one frame longer every round, so most slots were embedded on the previous job. diff --git a/pyro_temporal_api/tests/test_jobs.py b/pyro_temporal_api/tests/test_jobs.py index 1c3eef79..5ab80c72 100644 --- a/pyro_temporal_api/tests/test_jobs.py +++ b/pyro_temporal_api/tests/test_jobs.py @@ -133,7 +133,7 @@ def test_unknown_job_is_404(client): ({"n_frames": 1, "boxes": [[[0.1, 0.1, 1.2, 0.2, 0.5]]]}, "normalized"), ({"n_frames": 1, "boxes": [[[0.1, 0.1, 0.2, 0.2, 1.5]]]}, "each box"), ({"n_frames": 1, "boxes": [[[0.1, 0.1, 0.2, 0.2, 0.5]] * 51]}, "more than 50"), - ({"n_frames": 11}, "expected 1 to 10"), + ({"n_frames": 12}, "expected 1 to 11"), ], ) def test_rejects_malformed_submissions(client, kwargs, detail): diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 81aae2ff..b3ec5c9c 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -125,7 +125,7 @@ def __init__( heartbeat_period: int = 60, # 1 min last_bbox_mask_fetch_period: int = 3600, # 1H temporal_api_url: Optional[str] = None, - temporal_window: int = 10, + temporal_window: int = 11, temporal_job_timeout: float = 120.0, temporal_min_score: float = 0.1, **kwargs: Any, # noqa: ANN401 @@ -212,16 +212,19 @@ def _init_temporal_state(self, state: Dict[str, Any]) -> None: # nb_consecutive_frames only. if self.temporal is not None and self.temporal_window > (state["last_predictions"].maxlen or 0): state["last_predictions"] = deque(state["last_predictions"], maxlen=self.temporal_window) - state["temporal_job"] = None # (job_id, submitted_at) while a verdict is pending + state["temporal_job"] = None # (job_id, submitted_at, frame ids) while a verdict is pending state["temporal_tubes"] = [] # [(xyxy box, probability)] of the last verdict, above temporal_min_score + state["temporal_scored"] = frozenset() # frame ids scored by that verdict: the only ones it may stage state["temporal_bypass"] = False # fail open: rest of the event goes out without scores - def _temporal_window(self, cam_key: str) -> List[WindowFrame]: - """The prediction window as (frame_id, jpeg, boxes) triples, oldest first. + @staticmethod + def _frame_id(cam_key: str, ts: str) -> str: + """Frame id sent to the temporal service: the capture timestamp (microsecond precision), so a + resubmitted frame keeps its id and the service can reuse its cached embedding.""" + return f"{cam_key}_{ts.replace(':', '-')}" - The frame id is the capture timestamp (microsecond precision), so a resubmitted frame keeps - its id and the service can reuse its cached embedding. - """ + def _temporal_window(self, cam_key: str) -> List[WindowFrame]: + """The prediction window as (frame_id, jpeg, boxes) triples, oldest first.""" window = [] for _, preds, _, ts, _, jpeg in self._states[cam_key]["last_predictions"]: if jpeg is None: @@ -229,9 +232,17 @@ def _temporal_window(self, cam_key: str) -> List[WindowFrame]: boxes = np.round(preds[:, :5], 4).tolist() # The service rejects degenerate boxes (rounded/clipped to zero width or height at the image edge) boxes = [b for b in boxes if b[0] < b[2] and b[1] < b[3]] - window.append((f"{cam_key}_{ts.replace(':', '-')}", jpeg, boxes)) + window.append((self._frame_id(cam_key, ts), jpeg, boxes)) return window + def _consume_window(self, cam_key: str) -> None: + """Mark every frame still waiting for a verdict as staged without upload: the event was rejected + for good, so a later event must not upload them under its own score.""" + window = self._states[cam_key]["last_predictions"] + for i, entry in enumerate(window): + if not entry[4]: + window[i] = (entry[0], entry[1], [], entry[3], True, entry[5]) + def _end_event(self, cam_key: str) -> None: """Reset per-event staging state when an alert ends. @@ -244,6 +255,7 @@ def _end_event(self, cam_key: str) -> None: state["event_crop_boxes"] = [] state["temporal_job"] = None state["temporal_tubes"] = [] + state["temporal_scored"] = frozenset() state["temporal_bypass"] = False window = state["last_predictions"] for i, entry in enumerate(window): @@ -253,12 +265,13 @@ def _end_event(self, cam_key: str) -> None: def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bool: """Return True when the frames not yet staged may be staged now. - Round N submits the frame window; round N+1 reads the verdict, stages the pending frames - with the per-tube probabilities (``state["temporal_tubes"]``) and, while the event is - ongoing (``resubmit``), submits the window again so every frame carries a fresh score. A - verdict with no tube above ``temporal_min_score`` stages nothing; once the event ended it - drops the held frames. A transport error, a failed job or a job pending longer than - ``temporal_job_timeout`` fails open: the rest of the event goes out without scores. + Round N submits the frame window; round N+1 reads the verdict, stages the frames of that + window still pending with the per-tube probabilities (``state["temporal_tubes"]``) and, while + the event is ongoing (``resubmit``), submits the window again so every frame carries a fresh + score. Frames captured after a submission wait for the next verdict. A verdict with no tube + above ``temporal_min_score`` stages nothing; once the event ended it drops the held frames. A + transport error, a failed job or a job pending longer than ``temporal_job_timeout`` fails + open: the rest of the event goes out without scores. """ state = self._states[cam_key] if state["temporal_bypass"] or self.temporal is None: @@ -266,7 +279,7 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo try: stage = False if state["temporal_job"] is not None: - job_id, submitted_at = state["temporal_job"] + job_id, submitted_at, frame_ids = state["temporal_job"] result = self.temporal.result(job_id) if result["status"] == "pending": if time.monotonic() - submitted_at < self.temporal_job_timeout: @@ -288,6 +301,7 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo if t["probability"] >= self.temporal_min_score ] state["temporal_tubes"] = tubes + state["temporal_scored"] = frame_ids if tubes: logger.info( f"Camera '{cam_id}' - temporal model scored {len(tubes)} tube(s) " @@ -301,6 +315,7 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo + ("resubmitting" if resubmit else "event over, dropped") ) if not resubmit: + self._consume_window(cam_key) return False else: logger.error( @@ -312,7 +327,8 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo return False if resubmit: window = self._temporal_window(cam_key) - state["temporal_job"] = (self.temporal.submit(cam_id, window), time.monotonic()) + job_id = self.temporal.submit(cam_id, window) + state["temporal_job"] = (job_id, time.monotonic(), frozenset(f for f, _, _ in window)) logger.info(f"Camera '{cam_id}' - temporal scoring submitted ({len(window)} frames)") return stage except (RequestException, KeyError, ValueError) as e: @@ -517,12 +533,16 @@ def _stage_event(self, cam_key: str, cam_id: str) -> None: # Carry the last seen bbox forward onto frames with no detection so the alert keeps a # crop at the same location instead of a blank/placeholder frame (conf 0 flags the carry). + # With temporal scoring, only the frames the verdict actually scored go out; later ones wait. + scored_only = self.temporal is not None and not state["temporal_bypass"] last_seen: list = [] for idx, (crop_, preds_, bboxes, ts, is_staged, jpeg_bytes) in enumerate(state["last_predictions"]): if is_staged: if bboxes: last_seen = bboxes continue + if scored_only and self._frame_id(cam_key, ts) not in state["temporal_scored"]: + continue bboxes = self._backfill_bboxes(bboxes, preds_, tracked_arr) if not bboxes and last_seen: bboxes = [[b[0], b[1], b[2], b[3], 0.0] for b in last_seen] diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py index 6889b632..7082b276 100644 --- a/tests/test_engine_temporal.py +++ b/tests/test_engine_temporal.py @@ -114,26 +114,41 @@ def test_frames_scored_every_round_and_sent_with_tube_scores(tmp_path, mock_wild assert state["temporal_job"][0] == "job2" assert len(temporal.submitted[1][1]) == 3 - _fire(engine, mock_wildfire_image) # 0.3 >= 0.1 -> held frames staged with that score, resubmitted - assert len(engine._alerts) == 4 + _fire(engine, mock_wildfire_image) # 0.3 >= 0.1 -> the 3 frames job2 scored go out, the 4th waits + assert len(engine._alerts) == 3 assert all(s == [0.3] for s in _scores(engine)) assert state["temporal_job"][0] == "job3" - _fire(engine, mock_wildfire_image) # next verdict: only the new frame, with the fresh score - assert len(engine._alerts) == 5 + _fire(engine, mock_wildfire_image) # next verdict (job3 scored frames 1-4): frame 4 goes out, frame 5 waits + assert len(engine._alerts) == 4 assert _scores(engine)[-1] == [0.9] assert len(temporal.submitted) == 4 # one job per round while ongoing assert not temporal.results +def test_only_frames_of_the_scored_job_are_staged(tmp_path, mock_wildfire_image): + """Regression: with a full window, a verdict must not stage the frame captured after its job + (unscored) nor lose the oldest scored frame.""" + temporal = FakeTemporal([PENDING, done((BOX, 0.5)), done((BOX, 0.6))]) + engine = _engine(tmp_path, temporal, temporal_window=3) + ts = [] + for _ in range(4): + state = _fire(engine, mock_wildfire_image) + ts.append(state["last_predictions"][-1][3]) + # job1 = {f1}, job2 = {f1, f2, f3}; f4 arrived after job2 and f1 left the window before its verdict + assert [a["ts"] for a in engine._alerts] == ts[:3] + assert _scores(engine) == [[0.5], [0.6], [0.6]] + assert state["last_predictions"][-1][4] is False # f4 waits for job3 + + def test_boxes_outside_scored_tubes_are_dropped(tmp_path, mock_wildfire_image): - engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9))])) - _fire(engine, mock_wildfire_image, n=2) + engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9))] * 2)) + _fire(engine, mock_wildfire_image, n=3) assert len(engine._alerts) == 2 # frames still go out (continuity) ... assert all(a["bboxes"] == [] and a["temporal_scores"] == [] for a in engine._alerts) # ... without the box - engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9), (BOX, 0.2))])) - _fire(engine, mock_wildfire_image, n=2) + engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9), (BOX, 0.2))] * 2)) + _fire(engine, mock_wildfire_image, n=3) assert [[b[:4] for b in a["bboxes"]] for a in engine._alerts] == [[BOX]] * 2 assert _scores(engine) == [[0.2], [0.2]] @@ -244,3 +259,11 @@ def test_event_ending_during_validation_dropped_on_negative(tmp_path, mock_wildf assert state["temporal_job"] is None # no resubmission for an ended event assert len(temporal.submitted) == 1 assert len(engine._alerts) == 0 + rejected_ts = [e[3] for e in state["last_predictions"]] + assert all(e[4] for e in state["last_predictions"]) # held frames consumed, never uploaded + + # Regression: a new event on the same pose must not upload the rejected frames under its own score + temporal.results.extend([PENDING, done((BOX, 0.9))]) + _fire(engine, mock_wildfire_image, n=3) + assert len(engine._alerts) > 0 + assert not {a["ts"] for a in engine._alerts} & set(rejected_ts) From 3193c7538a0d36dee6e216287f38af78fc8a070f Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 27 Oct 2026 11:50:44 +0100 Subject: [PATCH 16/17] fix(temporal): send each distinct frame once to the temporal service The camera API serves the last capture again when the engine reads a pose before the patrol refreshed it. Repeated frames push the temporal score of a false positive from 0.003 to 0.75. pyro-api scores distinct frames only. The job still covers every frame of the window, so the repeats go out with the tube scores. --- pyroengine/engine.py | 20 ++++++++++++++++++-- tests/test_engine_temporal.py | 19 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/pyroengine/engine.py b/pyroengine/engine.py index b3ec5c9c..8a99584b 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -235,6 +235,20 @@ def _temporal_window(self, cam_key: str) -> List[WindowFrame]: window.append((self._frame_id(cam_key, ts), jpeg, boxes)) return window + @staticmethod + def _distinct_frames(window: List[WindowFrame]) -> List[WindowFrame]: + """The window without the frames whose JPEG repeats an earlier one, as pyro-api scores distinct + frames only. The camera API serves the last capture again when a pose is read before the patrol + refreshed it, and repeated frames push the temporal score up (a false positive went from 0.003 + to 0.75 with one frame read three times).""" + seen = set() + distinct = [] + for frame in window: + if frame[1] not in seen: + seen.add(frame[1]) + distinct.append(frame) + return distinct + def _consume_window(self, cam_key: str) -> None: """Mark every frame still waiting for a verdict as staged without upload: the event was rejected for good, so a later event must not upload them under its own score.""" @@ -327,9 +341,11 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo return False if resubmit: window = self._temporal_window(cam_key) - job_id = self.temporal.submit(cam_id, window) + distinct = self._distinct_frames(window) + job_id = self.temporal.submit(cam_id, distinct) + # Every frame of the window is scored by this job, repeats included: tube scores apply by box. state["temporal_job"] = (job_id, time.monotonic(), frozenset(f for f, _, _ in window)) - logger.info(f"Camera '{cam_id}' - temporal scoring submitted ({len(window)} frames)") + logger.info(f"Camera '{cam_id}' - temporal scoring submitted ({len(distinct)} frames)") return stage except (RequestException, KeyError, ValueError) as e: logger.error(f"Camera '{cam_id}' - temporal service unavailable ({e}), alert sent unscored") diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py index 7082b276..b18f3dfd 100644 --- a/tests/test_engine_temporal.py +++ b/tests/test_engine_temporal.py @@ -112,7 +112,7 @@ def test_frames_scored_every_round_and_sent_with_tube_scores(tmp_path, mock_wild _fire(engine, mock_wildfire_image) # below temporal_min_score -> nothing staged, resubmitted assert len(engine._alerts) == 0 assert state["temporal_job"][0] == "job2" - assert len(temporal.submitted[1][1]) == 3 + assert len(temporal.submitted[1][1]) == 1 # the same image three times is scored once _fire(engine, mock_wildfire_image) # 0.3 >= 0.1 -> the 3 frames job2 scored go out, the 4th waits assert len(engine._alerts) == 3 @@ -126,6 +126,23 @@ def test_frames_scored_every_round_and_sent_with_tube_scores(tmp_path, mock_wild assert not temporal.results +def test_repeated_frames_are_scored_once_and_still_sent(tmp_path, mock_wildfire_image, mock_forest_image): + temporal = FakeTemporal([PENDING, done((BOX, 0.8)), done((BOX, 0.8))]) + engine = _engine(tmp_path, temporal) + state = engine._states[CAM] + # job1 = {f1}; the pose is then read twice before the patrol refreshed it: job2 = {f1, f2, f2 again} + for image in (mock_wildfire_image, mock_forest_image, mock_forest_image): + _fire(engine, image) + window = engine._temporal_window(CAM) + assert len(window) == 3 + assert [f for f, _, _ in temporal.submitted[-1][1]] == [window[0][0], window[1][0]] # the repeat is not sent + assert state["temporal_job"][2] == frozenset(f for f, _, _ in window) # but job2 scores it + + _fire(engine, mock_forest_image) # job2 verdict: f2 and its repeat go out with the tube score + assert [a["ts"] for a in engine._alerts] == [e[3] for e in list(state["last_predictions"])[:3]] + assert _scores(engine) == [[0.8]] * 3 + + def test_only_frames_of_the_scored_job_are_staged(tmp_path, mock_wildfire_image): """Regression: with a full window, a verdict must not stage the frame captured after its job (unscored) nor lose the oldest scored frame.""" From a1cc9615f1bcab3322932eae9afd08cd7713dc8f Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 27 Oct 2026 17:16:07 +0100 Subject: [PATCH 17/17] feat(temporal): upload only boxes of tubes scoring above 0.2 temporal_min_score goes from 0.1 to 0.2 and becomes strict: a box is uploaded with its tube probability only when that probability is above 0.2. pyro-api keeps applying TEMPORAL_MODEL_THRESHOLD (0.45) to the engine score, the same threshold as for its own model. --- CLAUDE.md | 2 +- README.md | 2 +- pyroengine/engine.py | 8 ++++---- tests/test_engine_temporal.py | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fa026370..302fdbb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ For each `cam_id`, the engine periodically fetches a JSON file at `{bbox_mask_ur ### Temporal validation (optional) -The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` widens its per-`cam_id` prediction window to `temporal_window` (11) frames (JPEG + YOLO boxes); detection still pools the last `nb_consecutive_frames`. While an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` every round (`POST /jobs`, returns immediately, at most `MAX_FRAMES` = 11 frames) and reads the verdict on the next round (`GET /jobs/{id}`). The verdict lists the scored tubes (union box + probability); boxes covered by a tube scoring at least `temporal_min_score` (0.1) are uploaded with that probability as `temporal_model_scores` (pyro-api PR 723 applies its own threshold instead of scoring the sequence), boxes outside every scored tube are dropped. Only the frames a verdict scored are uploaded on it, later frames wait for the next verdict; a verdict without such tube stages nothing (frames wait for the next one); a pending job holds the alert one more round; frames of an ended event rejected by the model are dropped for good. Service errors, a lost job and a job pending longer than `temporal_job_timeout` (120 s) fail open: the rest of the event is sent without scores and the API scores it itself. `/health` returns 503 when the scoring worker died or is stuck. The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. +The service is behind the compose profile `temporal` (`COMPOSE_PROFILES=temporal` in `.env`). When `TEMPORAL_API_URL` is set, `Engine` widens its per-`cam_id` prediction window to `temporal_window` (11) frames (JPEG + YOLO boxes); detection still pools the last `nb_consecutive_frames`. While an alert is ongoing, `Engine._temporal_gate` submits that window to `pyro_temporal_api` every round (`POST /jobs`, returns immediately, at most `MAX_FRAMES` = 11 frames) and reads the verdict on the next round (`GET /jobs/{id}`). The verdict lists the scored tubes (union box + probability); boxes covered by a tube scoring above `temporal_min_score` (0.2) are uploaded with that probability as `temporal_model_scores` (pyro-api PR 723 applies its own threshold instead of scoring the sequence), boxes outside every scored tube are dropped. Only the frames a verdict scored are uploaded on it, later frames wait for the next verdict; a verdict without such tube stages nothing (frames wait for the next one); a pending job holds the alert one more round; frames of an ended event rejected by the model are dropped for good. Service errors, a lost job and a job pending longer than `temporal_job_timeout` (120 s) fail open: the rest of the event is sent without scores and the API scores it itself. `/health` returns 503 when the scoring worker died or is stuck. The service loads `data/model_onnx.zip` (`TEMPORAL_MODEL_PATH`), exported from a temporal-model release with `temporal-export-onnx`. A format 2 package (split `backbone.onnx` + `head.onnx`) lets the service embed only real frames and cache per-slot ViT embeddings across jobs (`EMBEDDING_CACHE_SIZE` in `main.py`, keyed by frame id + crop box); the verdict reports `embeddings_cached` / `embeddings_new` per job. ### Stream-awareness diff --git a/README.md b/README.md index ad6876d9..d853aec4 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ PYRO_ENGINE_VERSION=latest Optional temporal validation of alerts: with `COMPOSE_PROFILES=temporal` and `TEMPORAL_API_URL=http://localhost:8082` in `.env`, the stack also starts `pyro_temporal_api`. While an alert is ongoing, the engine sends the last frames and boxes of that camera pose to it every round and uploads each box with the smoke probability of the tube covering it -(`temporal_model_scores`); boxes below 0.1 or outside every scored tube are dropped, and the API applies its own +(`temporal_model_scores`); boxes at or below 0.2 or outside every scored tube are dropped, and the API applies its own threshold instead of running the temporal model itself. If the service is unreachable the frames go out without scores and the API scores them. Leave both unset to send alerts as before. The service needs `data/model_onnx.zip`: the [temporal-model](https://github.com/pyronear/temporal-model) release on Hugging Face (`pyronear/temporal-model`, revision `v0.6.0`, trained on pyro-dataset v4.7.0), re-exported diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 8a99584b..f3644d4b 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -127,13 +127,13 @@ def __init__( temporal_api_url: Optional[str] = None, temporal_window: int = 11, temporal_job_timeout: float = 120.0, - temporal_min_score: float = 0.1, + temporal_min_score: float = 0.2, **kwargs: Any, # noqa: ANN401 ) -> None: cam_ids = list(cam_creds.keys()) if isinstance(cam_creds, dict) else None # Temporal validation: when set, the last `temporal_window` frames of an alerting camera - # pose are scored by the temporal service every round. Boxes covered by a tube scoring at - # least `temporal_min_score` are uploaded with that probability, the API applies its own + # pose are scored by the temporal service every round. Boxes covered by a tube scoring + # above `temporal_min_score` are uploaded with that probability, the API applies its own # threshold; the other boxes are dropped. A job still pending after `temporal_job_timeout` # seconds fails open (frames sent without scores, the API scores them itself). # Set before super().__init__, which already builds the per-camera states. @@ -312,7 +312,7 @@ def _temporal_gate(self, cam_key: str, cam_id: str, resubmit: bool = True) -> bo tubes = [ (tuple(t["box"]), float(t["probability"])) for t in verdict.get("tubes", []) - if t["probability"] >= self.temporal_min_score + if t["probability"] > self.temporal_min_score ] state["temporal_tubes"] = tubes state["temporal_scored"] = frame_ids diff --git a/tests/test_engine_temporal.py b/tests/test_engine_temporal.py index b18f3dfd..f4ca5fb1 100644 --- a/tests/test_engine_temporal.py +++ b/tests/test_engine_temporal.py @@ -96,7 +96,7 @@ def test_window_is_the_prediction_window(tmp_path, mock_wildfire_image): def test_frames_scored_every_round_and_sent_with_tube_scores(tmp_path, mock_wildfire_image): - temporal = FakeTemporal([PENDING, done((BOX, 0.05)), done((BOX, 0.3)), done((BOX, 0.9))]) + temporal = FakeTemporal([PENDING, done((BOX, 0.2)), done((BOX, 0.3)), done((BOX, 0.9))]) engine = _engine(tmp_path, temporal) state = _fire(engine, mock_wildfire_image) # ongoing on the first frame -> submit @@ -109,12 +109,12 @@ def test_frames_scored_every_round_and_sent_with_tube_scores(tmp_path, mock_wild assert len(engine._alerts) == 0 assert state["temporal_job"][0] == "job1" - _fire(engine, mock_wildfire_image) # below temporal_min_score -> nothing staged, resubmitted + _fire(engine, mock_wildfire_image) # 0.2 is not above temporal_min_score -> nothing staged, resubmitted assert len(engine._alerts) == 0 assert state["temporal_job"][0] == "job2" assert len(temporal.submitted[1][1]) == 1 # the same image three times is scored once - _fire(engine, mock_wildfire_image) # 0.3 >= 0.1 -> the 3 frames job2 scored go out, the 4th waits + _fire(engine, mock_wildfire_image) # 0.3 > 0.2 -> the 3 frames job2 scored go out, the 4th waits assert len(engine._alerts) == 3 assert all(s == [0.3] for s in _scores(engine)) assert state["temporal_job"][0] == "job3" @@ -164,10 +164,10 @@ def test_boxes_outside_scored_tubes_are_dropped(tmp_path, mock_wildfire_image): assert len(engine._alerts) == 2 # frames still go out (continuity) ... assert all(a["bboxes"] == [] and a["temporal_scores"] == [] for a in engine._alerts) # ... without the box - engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9), (BOX, 0.2))] * 2)) + engine = _engine(tmp_path, FakeTemporal([done((FAR_BOX, 0.9), (BOX, 0.3))] * 2)) _fire(engine, mock_wildfire_image, n=3) assert [[b[:4] for b in a["bboxes"]] for a in engine._alerts] == [[BOX]] * 2 - assert _scores(engine) == [[0.2], [0.2]] + assert _scores(engine) == [[0.3], [0.3]] @pytest.mark.parametrize(