Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
672f906
feat: add a fake Reolink camera driven by scenarios
MateoLostanlen Oct 22, 2026
0330a5d
feat: run the engine against the fake cameras
MateoLostanlen Oct 22, 2026
4dc44d8
ci: start the fake cameras in the compose workflow
MateoLostanlen Oct 22, 2026
3e2c613
docs: document the fake cameras and scenarios
MateoLostanlen Oct 22, 2026
471b9c0
fix: let init_script exit cleanly when the data already exists
MateoLostanlen Oct 22, 2026
6e6039b
chore: ignore the engine model cache under data/
MateoLostanlen Oct 22, 2026
151018b
fix: pass API_URL to the camera API so it fetches pose azimuths
MateoLostanlen Oct 22, 2026
62307ae
fix: keep init_script strict when camera tokens are missing
MateoLostanlen Oct 22, 2026
3eb070f
fix: drop stale frames when rebuilding a scenario
MateoLostanlen Oct 22, 2026
a12729f
chore: drop the false-positive scenario
MateoLostanlen Oct 22, 2026
7e8624a
fix: keep the committed scenario.json when fetching scenario images
MateoLostanlen Oct 22, 2026
778fd6e
fix: harden the fetched-scenario and completed-init checks
MateoLostanlen Oct 23, 2026
10c5b43
fix: mark a completed init with a file written at the end
MateoLostanlen Oct 23, 2026
e86f135
ci: build and push the fake-camera image
MateoLostanlen Oct 23, 2026
87bd12a
feat: embed the default scenario in the fake camera image
MateoLostanlen Oct 23, 2026
100efba
fix: require the camera tokens as well as the init marker
MateoLostanlen Oct 23, 2026
56ec5f4
chore: reach a host MediaMTX from the compose network
MateoLostanlen Oct 23, 2026
83e1a61
ci: start the engine profile and fail on unhealthy or stopped services
MateoLostanlen Oct 26, 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: 2 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
API_URL=http://pyro_api:5050
CAM_USER=my_dummy_login
CAM_PWD=my_dummy_pwd
MEDIAMTX_SERVER_IP=localhost
# MediaMTX on the host, seen from the compose network (streams are not set up here yet)
MEDIAMTX_SERVER_IP=host.docker.internal

LOKI_URL=http://my-loki-service.com
PROMTAIL_DEVICE_SCOPE=tower_scope
Expand Down
29 changes: 15 additions & 14 deletions .github/workflows/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,29 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2

- name: Wait for containers to start
- name: Create .env from .env.test
run: make init

- name: Build local images
run: make build

- name: Run docker-compose up -d
run: make run

- name: Start the engine, camera API and fake cameras
run: make run-engine

# Wait for containers to start up (adjust the sleep time as needed)
- name: Wait for containers to start
run: sleep 120

- name: Check container health
run: docker compose ps --filter "status=unhealthy" --quiet
continue-on-error: true
id: check_health

- name: Print container health status
env:
COMPOSE_PROFILES: front,tools,engine
run: |-
if [ -z "${{ steps.check_health.outputs.stdout }}" ]; then
echo "All containers are healthy"
exit 0
else
echo "Some containers are unhealthy"
exit 1
fi
docker compose ps -a
test -z "$(docker compose ps --filter status=unhealthy --quiet)" || { echo "Some containers are unhealthy"; exit 1; }
test -n "$(docker compose ps --status running --quiet engine)" || { echo "The engine is not running"; exit 1; }

- name: load tests requirements
run: pip install -r tests/requirements.txt
Expand All @@ -51,9 +50,11 @@ jobs:
run: pytest tests/*

- name: Capture logs
run: docker compose logs --tail=40 > logs.txt
if: always()
run: COMPOSE_PROFILES=front,tools,engine docker compose logs --tail=40 > logs.txt

- name: Show logs
if: always()
run: cat logs.txt

- name: Filter logs
Expand Down
63 changes: 63 additions & 0 deletions .github/workflows/fake-camera-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Build and Push fake-camera image

# The image embeds the default scenario, fetched from the `scenarios` release.
# latest on main, X.Y.Z on a `fake-camera-vX.Y.Z` tag, build only on pull requests.
on:
push:
branches: [main]
tags: ['fake-camera-v*']
paths:
- containers/fake_camera/**
- data/scenarios/*/scenario.json
- .github/workflows/fake-camera-image.yml
pull_request:
paths:
- containers/fake_camera/**
- data/scenarios/*/scenario.json
- .github/workflows/fake-camera-image.yml

jobs:
build-and-push:
name: Build and push pyronear/fake-camera
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Fetch the default scenario images
run: make fetch-scenario

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_LOGIN }}
password: ${{ secrets.DOCKERHUB_PW }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: pyronear/fake-camera
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=match,pattern=fake-camera-v(.*),group=1

- name: Build and push
uses: docker/build-push-action@v6
with:
context: containers/fake_camera
file: containers/fake_camera/Dockerfile
build-contexts: scenarios=data/scenarios
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
*.onnx
*.jpg
data/images/
# Model cache written by the engine container (./data is mounted)
data/models/
# Written by init_script at the end of a successful run
data/.init_done
*.pem
# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
35 changes: 27 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@ help:
@echo " build Build local images in this repo"
@echo " build-external Build images in sibling repos"
@echo " build-all Build local and external images"
@echo " fetch-scenario Download scenario images (SCENARIO=smoke-then-clear)"
@echo " run-backend Start base services only"
@echo " run-camera Start only the fake cameras, to work on pyro-engine"
@echo " run-engine Start base services plus engine profile"
@echo " run-tools-and-engine Start base services plus tools and engine profiles"
@echo " run-tools Start base services plus tools profile"
@echo " run Start base services plus front and tools profiles"
@echo " run-temporal Same as run (front + tools) plus temporal model API"
@echo " stop-temporal Stop only the temporal model API"
@echo " stop-engine Stop only engine services (engine + pyro_camera_api)"
@echo " restart-engine Restart engine services without re-running init_script"
@echo " stop-engine Stop only engine services (engine, pyro_camera_api, fake cameras)"
@echo " restart-engine Restart engine services and replay the scenario from the start"
@echo " stop Stop and remove all services and volumes"
@echo " ps Show compose status"
@echo " logs Follow logs"
Expand All @@ -32,9 +34,11 @@ init:
# Build images in this repo
# -------------------------------------------------------------------

build:
# The fake camera image embeds the default scenario, fetched first.
build: fetch-scenario
docker build -f containers/init_script/Dockerfile -t pyronear/pyro-api-init:latest containers/init_script/
docker build -f containers/notebooks/Dockerfile -t pyronear/notebooks:latest containers/notebooks/
docker build --build-context scenarios=data/scenarios -f containers/fake_camera/Dockerfile -t pyronear/fake-camera:latest containers/fake_camera/

# -------------------------------------------------------------------
# Build images from sibling repositories
Expand All @@ -55,8 +59,23 @@ build-all: build build-external
run-backend:
docker compose up -d

# Engine profile adds engine + pyro_camera_api
run-engine:
# Fake camera scenarios: definitions are in data/scenarios/<name>/scenario.json,
# images come from the "scenarios" GitHub release.
SCENARIO ?= smoke-then-clear
SCENARIOS_URL = https://github.com/pyronear/pyro-envdev/releases/download/scenarios
# Downloads unless every camera of scenario.json already has frames.
fetch-scenario:
@python3 -c "import glob, json, sys; d = 'data/scenarios/$(SCENARIO)'; \
sys.exit(any(not glob.glob(f'{d}/{c}/*.jpg') for c in json.load(open(f'{d}/scenario.json'))['cameras']))" || \
(curl -fsSL $(SCENARIOS_URL)/$(SCENARIO).zip -o /tmp/$(SCENARIO).zip && \
unzip -oq /tmp/$(SCENARIO).zip -x '*/scenario.json' -d data/scenarios && rm /tmp/$(SCENARIO).zip)

# Fake cameras alone, for a pyro-engine running outside compose (see README)
run-camera: fetch-scenario
docker compose up -d fakecam-1 fakecam-2

# Engine profile adds engine + pyro_camera_api + fake cameras
run-engine: fetch-scenario
docker compose --profile engine up -d

# Tools + engine (tools must be up first so the API is ready when engine starts)
Expand Down Expand Up @@ -84,13 +103,13 @@ stop-temporal:
docker compose --profile temporal stop temporal_model_api

stop-engine:
docker compose --profile engine stop engine pyro_camera_api
docker compose --profile engine stop engine pyro_camera_api fakecam-1 fakecam-2

restart-engine: stop-engine
docker compose --profile engine up -d engine pyro_camera_api --no-deps
docker compose --profile engine up -d fakecam-1 fakecam-2 pyro_camera_api engine --no-deps

stop:
docker compose --profile front --profile engine --profile tools --profile temporal down -v
docker compose --profile front --profile engine --profile camera --profile tools --profile temporal down -v

ps:
docker compose ps
Expand Down
88 changes: 83 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ make run
* **db**: PostgreSQL database
* **rustfs**: S3-compatible storage (via RustFS)
* **frontend**: Web app (React, [pyro-platform](https://github.com/pyronear/pyro-platform))
* **pyro-engine**: Engine service (requires cameras, optional)
* **reolinkdev1 / reolinkdev2**: Fake Reolink cameras sending test images
* **pyro_camera_api / engine**: the real camera API and engine from [pyro-engine](https://github.com/pyronear/pyro-engine) (optional)
* **fakecam-1 / fakecam-2**: fake Reolink cameras playing a scenario (see below)
* **notebooks**: Jupyter server to run helper notebooks
* **db-ui**: pgAdmin to browse/manage the database

Expand All @@ -60,12 +60,12 @@ make build
make run-all
```

This launches everything including the engine and simulated alerts.
You can check health with:
This launches everything, including the engine and the fake cameras playing the
`smoke-then-clear` scenario. You can check health with:

```bash
docker logs init
docker logs engine
docker compose logs -f engine
```

### Partial runs
Expand Down Expand Up @@ -111,6 +111,84 @@ docker logs engine

---

## 📷 Fake cameras and scenarios

`fakecam-1` (`croix-augas-01`) and `fakecam-2` (`nemours-02`) answer the Reolink HTTP
API (`/cgi-bin/api.cgi?cmd=Snap|PtzCtrl|GetPtzPreset|...`) over HTTPS with a self-signed
certificate, so the real `pyro_camera_api` and `engine` run against them unchanged
(`data/credentials.json`, adapter `reolink`). Each one plays a scenario: a list of
images shown one after the other, one every `interval` seconds from the first `Snap`,
the last image being held forever. Every preset of a camera serves the same images for now.

```bash
make run-engine # engine + camera API + fake cameras
SCENARIO=never-smoke make run-engine # pick another scenario
make restart-engine # replay the scenario from the start
```

Scenarios:

* `smoke-then-clear` (default): real wildfire seen by both cameras, 40 frames of smoke then a clear view
* `never-smoke`: both cameras hold a clear view

`GET https://localhost:8443/scenario` (8444 for `fakecam-2`) reports the camera state:
current preset and azimuth, frame index and `finished` once the last frame is shown.

The image embeds `smoke-then-clear`, so it also runs alone, without the `data/scenarios`
volume (used by pi-manager-template on a test station).

The image `pyronear/fake-camera` is published by the `fake-camera-image` workflow: `latest`
on every change to `containers/fake_camera/` on `main`, and `X.Y.Z` on a `fake-camera-vX.Y.Z`
tag (used by pi-manager-template). It needs the `DOCKERHUB_LOGIN` / `DOCKERHUB_PW` secrets.

### Scenario format

`data/scenarios/<name>/scenario.json` describes the scenario (committed), the images
live next to it and are downloaded by `make fetch-scenario SCENARIO=<name>` from the
[`scenarios` release](https://github.com/pyronear/pyro-envdev/releases/tag/scenarios):

```
data/scenarios/smoke-then-clear/
scenario.json # {"interval": 30, "cameras": {"<name>": {"poses": {"<preset>": <azimuth>}}}}
croix-augas-01/ # frames of fakecam-1, in order: 000.jpg, 001.jpg, ...
nemours-02/ # frames of fakecam-2
```

Camera names and presets match `data/csv/API_DATA_DEV - cameras.csv` / `poses.csv`, so
the init script gives each fake camera its token and the engine finds its poses.

To build the frames of a camera from a replayed alert zip (see `make fetch-alerts`) and
a clear image for the end of the scenario:

```bash
python scripts/make_scenario.py data/scenarios/<name>/croix-augas-01 \
--alert data/replay_alerts/alert_49767.zip --frames 40 --clear clear.jpg
```

Then zip the images (`cd data/scenarios && zip -r <name>.zip <name> -x '*/scenario.json'`)
and attach the zip to the `scenarios` release. `scenario.json` itself is committed.

### Working on pyro-engine without a camera

Start only the fake cameras:

```bash
make run-camera
```

They are published on `https://localhost:8443` and `https://localhost:8444`. In your
pyro-engine checkout, point `credentials.json` at them with the host and port as key:

```json
{
"localhost:8443": {"name": "croix-augas-01", "adapter": "reolink", "type": "ptz", "poses": [1, 2, 3], ...}
}
```

and use the same `CAM_USER` / `CAM_PWD` as in this repo's `.env`.

---

## 📂 Data Usage

### Add more images to Reolink Dev
Expand Down
18 changes: 18 additions & 0 deletions containers/fake_camera/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM python:3.12-slim

WORKDIR /app

# Self-signed certificate: pyro_camera_api talks HTTPS to Reolink cameras without verifying it.
RUN openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj "/CN=fakecam" \
-keyout key.pem -out cert.pem

COPY server.py .

# Default scenario, so the image runs alone (e.g. on a test station); a volume on
# /scenarios overrides it. Built with --build-context scenarios=data/scenarios (Makefile).
# hadolint ignore=DL3022
COPY --from=scenarios smoke-then-clear /scenarios/smoke-then-clear

EXPOSE 443

CMD ["python", "server.py"]
Loading
Loading