Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6877d87
feat(temporal): validation service scoring alert windows with the ONN…
MateoLostanlen Sep 8, 2026
1b88764
feat(engine): hold alerts until the temporal service confirms them
MateoLostanlen Sep 8, 2026
7fa1d36
chore: wire pyro-temporal-api into compose, Makefile, lint config and…
MateoLostanlen Sep 8, 2026
e0dd7de
fix(temporal): build on bookworm, bullseye-security release file is e…
MateoLostanlen Sep 8, 2026
334f82e
chore: pull and tail the temporal API image in Makefile targets
MateoLostanlen Sep 8, 2026
42ac53b
build: move engine and camera API images to bookworm
MateoLostanlen Sep 8, 2026
8a7b072
fix(temporal): bound uploads and queue, bind to loopback, publish ima…
MateoLostanlen Sep 8, 2026
86aed28
chore(temporal): pin temporal-model-core to the latest PR 76 commit
MateoLostanlen Sep 8, 2026
4228cb4
chore(temporal): bump temporal-model-core pin to PR 76 head (ef76860)
MateoLostanlen Sep 9, 2026
a87b779
chore(temporal): pin temporal-model-core to the PR 76 merge commit (f…
MateoLostanlen Sep 12, 2026
131d4ec
fix(engine): keep an ended event until its temporal verdict is in
MateoLostanlen Sep 12, 2026
327265d
Merge remote-tracking branch 'origin/develop' into HEAD
MateoLostanlen Sep 29, 2026
b37a3a3
feat(temporal): cache ViT embeddings across jobs, pin temporal-model …
Oct 14, 2026
7c59b65
fix(temporal): review fixes on the engine gate and the service
MateoLostanlen Oct 22, 2026
bf36eaa
chore: merge develop into feat/temporal-validator
MateoLostanlen Oct 22, 2026
9361cf7
feat(temporal): send per-box temporal scores, let the API apply the t…
MateoLostanlen Oct 22, 2026
3c70979
fix(temporal): stage only the frames a verdict scored, drop rejected …
MateoLostanlen Oct 23, 2026
3193c75
fix(temporal): send each distinct frame once to the temporal service
MateoLostanlen Oct 27, 2026
a1cc961
feat(temporal): upload only boxes of tubes scoring above 0.2
MateoLostanlen Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +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
# Optional temporal validation of alerts: both lines together, plus data/model_onnx.zip
# COMPOSE_PROFILES=temporal
# TEMPORAL_API_URL=http://localhost:8082
3 changes: 3 additions & 0 deletions .github/workflows/build-push-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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:
Expand Down Expand Up @@ -112,5 +117,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'
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

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

`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.
Expand All @@ -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`, `COMPOSE_PROFILES=temporal` + `TEMPORAL_API_URL` (e.g. `http://localhost:8082`; both unset = no temporal validation), `TEMPORAL_MODEL_PATH`.

### Legacy direct-camera module

Expand Down
20 changes: 19 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -38,6 +39,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

Expand All @@ -51,16 +58,27 @@ 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

# 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-app
run_local: build-api build-temporal build-app
docker compose up -d

# Get log from engine wrapper
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
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,19 @@ MEDIAMTX_SERVER_IP=1.2.3.4
PYRO_ENGINE_VERSION=latest
```

`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).

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 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
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

Expand Down
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ 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}
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
Expand All @@ -39,6 +64,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: >
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -83,7 +83,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
Expand Down Expand Up @@ -138,7 +138,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]
Expand All @@ -148,6 +148,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"]
"pyro_camera_api/tests/**.py" = ["N802", "S105", "S106", "S108"]
"pyroengine/core.py" = ["BLE001"]
"pyroengine/sensors.py" = ["S113", "S501", "ANN"]
Expand Down
26 changes: 26 additions & 0 deletions pyro_temporal_api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM python:3.11.13-slim-bookworm

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

# 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"]
25 changes: 25 additions & 0 deletions pyro_temporal_api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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@7c1500ef9d6538b36666ce7a43f0e08ca175592e#subdirectory=core",
]

[dependency-groups]
dev = [
"pytest>=8.0,<9",
"httpx>=0.27,<1",
]

[tool.uv]
package = false

[tool.pytest.ini_options]
pythonpath = ["."]
4 changes: 4 additions & 0 deletions pyro_temporal_api/pyro_temporal_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright (C) 2022-2026, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.
Loading
Loading