From 672f906cede9a295fd5dd61ad42e65966815c911 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:01:05 +0200 Subject: [PATCH 01/19] feat: add a fake Reolink camera driven by scenarios A stdlib HTTPS server answering the Reolink CGI API (Snap, PtzCtrl, GetPtzPreset, SetPtzPreset, zoom, focus, reboot) from a folder of frames, one per interval from the first Snap, last frame held. Three scenario definitions and a script to build frames from a replayed alert. --- containers/fake_camera/Dockerfile | 13 + containers/fake_camera/server.py | 235 ++++++++++++++++++ data/scenarios/false-positive/scenario.json | 8 + data/scenarios/never-smoke/scenario.json | 8 + data/scenarios/smoke-then-clear/scenario.json | 8 + scripts/make_scenario.py | 58 +++++ tests/test_fake_camera.py | 73 ++++++ 7 files changed, 403 insertions(+) create mode 100644 containers/fake_camera/Dockerfile create mode 100644 containers/fake_camera/server.py create mode 100644 data/scenarios/false-positive/scenario.json create mode 100644 data/scenarios/never-smoke/scenario.json create mode 100644 data/scenarios/smoke-then-clear/scenario.json create mode 100644 scripts/make_scenario.py create mode 100644 tests/test_fake_camera.py diff --git a/containers/fake_camera/Dockerfile b/containers/fake_camera/Dockerfile new file mode 100644 index 0000000..3b330a9 --- /dev/null +++ b/containers/fake_camera/Dockerfile @@ -0,0 +1,13 @@ +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 . + +EXPOSE 443 + +CMD ["python", "server.py"] diff --git a/containers/fake_camera/server.py b/containers/fake_camera/server.py new file mode 100644 index 0000000..6e3e64e --- /dev/null +++ b/containers/fake_camera/server.py @@ -0,0 +1,235 @@ +"""Fake Reolink camera. + +Answers the Reolink HTTP API (``/cgi-bin/api.cgi?cmd=...``) the way the real +``pyro_camera_api`` Reolink adapter expects, serving the frames of a scenario. +One process is one camera: ``SCENARIO`` and ``CAMERA`` select +``///*.jpg`` (sorted, one frame per +``interval`` seconds from the first Snap, last frame held). + +``GET /scenario`` is not part of the Reolink API: it reports the camera state +(preset, azimuth, frame, finished) so tests know where the scenario is. +""" + +import json +import logging +import os +import ssl +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +# A Reolink PTZ camera exposes 64 preset slots, ids 0..63. +PRESET_SLOTS = 64 +# PTZ operations that leave the current preset; Stop and zoom/focus do not pan. +NO_PAN_OPS = {"ToPos", "Stop", "ZoomInc", "ZoomDec", "FocusInc", "FocusDec"} + +CAM_USER = os.environ.get("CAM_USER", "") +CAM_PWD = os.environ.get("CAM_PWD", "") + + +def frame_index(elapsed, interval, n_frames): + """Frame served `elapsed` seconds after the first Snap, last one held.""" + return min(n_frames - 1, int(elapsed // interval)) + + +def reply(cmd, value=None, error=None): + if error: + return [{"cmd": cmd, "code": 1, "error": {"detail": error, "rspCode": -9}}] + return [ + {"cmd": cmd, "code": 0, "value": {"rspCode": 200} if value is None else value} + ] + + +class Camera: + def __init__(self, scenario_dir, name): + meta = json.loads((scenario_dir / "scenario.json").read_text()) + self.name = name + self.interval = meta.get("interval", 30) + # preset id -> real-world azimuth, informational only (see /scenario) + self.azimuths = { + int(k): float(v) for k, v in meta["cameras"][name]["poses"].items() + } + # ponytail: every preset serves the same frames; per-preset folders if needed + self.frames = sorted((scenario_dir / name).glob("*.jpg")) + if not self.frames: + raise SystemExit( + f"no frames in {scenario_dir / name}, run `make fetch-scenario` first" + ) + self.lock = threading.Lock() + self.started = None # monotonic time of the first Snap + self.preset = None # unknown until a ToPos + self.enabled = set(self.azimuths) + self.zoom, self.focus, self.autofocus_disabled = 0, 720, 0 + + def _elapsed(self): + return 0.0 if self.started is None else time.monotonic() - self.started + + def _frame(self): + return frame_index(self._elapsed(), self.interval, len(self.frames)) + + def snap(self): + with self.lock: + if self.started is None: + self.started = time.monotonic() + return self.frames[self._frame()].read_bytes() + + def status(self): + with self.lock: + return { + "camera": self.name, + "preset": self.preset, + "azimuth": self.azimuths.get(self.preset), + "elapsed": round(self._elapsed(), 1), + "frame": self._frame(), + "frames": len(self.frames), + "finished": self.started is not None + and self._frame() == len(self.frames) - 1, + } + + def command(self, cmd, param): + with self.lock: + if cmd == "PtzCtrl": + op = param.get("op") + if op == "ToPos": + idx = int(param.get("id", 0)) + if idx not in self.enabled: + return reply(cmd, error=f"preset {idx} is not set") + self.preset = idx + elif op not in NO_PAN_OPS: + self.preset = None + return reply(cmd) + if cmd == "GetPtzPreset": + presets = [ + { + "channel": 0, + "enable": int(i in self.enabled), + "id": i, + "name": f"pos{i}", + } + for i in range(PRESET_SLOTS) + ] + return reply(cmd, {"PtzPreset": presets}) + if cmd == "SetPtzPreset": + preset = param.get("PtzPreset", {}) + idx = int(preset.get("id", 0)) + if preset.get("enable", 1): + self.enabled.add(idx) + else: + self.enabled.discard(idx) + return reply(cmd) + if cmd == "GetZoomFocus": + zoom_focus = { + "channel": 0, + "focus": {"pos": self.focus}, + "zoom": {"pos": self.zoom}, + } + return reply(cmd, {"ZoomFocus": zoom_focus}) + if cmd == "StartZoomFocus": + zoom_focus = param.get("ZoomFocus", {}) + if zoom_focus.get("op") == "FocusPos": + self.focus = int(zoom_focus.get("pos", self.focus)) + else: + self.zoom = int(zoom_focus.get("pos", self.zoom)) + return reply(cmd) + if cmd == "GetAutoFocus": + return reply( + cmd, + {"AutoFocus": {"channel": 0, "disable": self.autofocus_disabled}}, + ) + if cmd == "SetAutoFocus": + self.autofocus_disabled = int( + param.get("AutoFocus", {}).get("disable", 0) + ) + return reply(cmd) + if cmd == "Reboot": + return reply(cmd) + return reply(cmd, error=f"unsupported command {cmd}") + + +camera = None + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + # The default logs the full URL, which carries the camera password. + logging.info("%s %s", self.command, urlparse(self.path).path) + + def _send(self, status, body, content_type="application/json"): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _json(self, data): + self._send(200, json.dumps(data).encode()) + + def do_GET(self): + self._dispatch() + + def do_POST(self): + self._dispatch() + + def _dispatch(self): + url = urlparse(self.path) + query = parse_qs(url.query) + if url.path == "/scenario": + return self._json(camera.status()) + if url.path != "/cgi-bin/api.cgi": + return self._send(404, b"not found", "text/plain") + cmd = query.get("cmd", [""])[0] + if (query.get("user", [""])[0], query.get("password", [""])[0]) != ( + CAM_USER, + CAM_PWD, + ): + return self._json( + [ + { + "cmd": cmd, + "code": 1, + "error": {"detail": "please login first", "rspCode": -6}, + } + ] + ) + if cmd == "Snap": + return self._send(200, camera.snap(), "image/jpeg") + length = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(length) or b"[]") + param = body[0].get("param", {}) if body else {} + return self._json(camera.command(cmd, param)) + + +def main(): + global camera + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") + scenario = os.environ["SCENARIO"] + camera = Camera( + Path(os.environ.get("SCENARIOS_DIR", "/scenarios")) / scenario, + os.environ["CAMERA"], + ) + port = int(os.environ.get("PORT", "443")) + here = Path(__file__).parent + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain( + os.environ.get("CERT_FILE", here / "cert.pem"), + os.environ.get("KEY_FILE", here / "key.pem"), + ) + httpd = ThreadingHTTPServer(("", port), Handler) + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) + logging.info( + "fake camera %s plays scenario %s: " + "%d frames, one every %ss, presets %s, port %d", + camera.name, + scenario, + len(camera.frames), + camera.interval, + sorted(camera.enabled), + port, + ) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/data/scenarios/false-positive/scenario.json b/data/scenarios/false-positive/scenario.json new file mode 100644 index 0000000..6069821 --- /dev/null +++ b/data/scenarios/false-positive/scenario.json @@ -0,0 +1,8 @@ +{ + "description": "False positive (prod alert 54194) on nemours-02: 40 frames, then a clear view. croix-augas-01 stays clear. Expected: at most one single-camera alert, no triangulation.", + "interval": 30, + "cameras": { + "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, + "nemours-02": {"poses": {"0": 245.9, "1": 292.4, "2": 339.4, "3": 25.4}} + } +} diff --git a/data/scenarios/never-smoke/scenario.json b/data/scenarios/never-smoke/scenario.json new file mode 100644 index 0000000..223f4aa --- /dev/null +++ b/data/scenarios/never-smoke/scenario.json @@ -0,0 +1,8 @@ +{ + "description": "Both cameras hold a clear view. Expected: no alert.", + "interval": 30, + "cameras": { + "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, + "nemours-02": {"poses": {"0": 245.9, "1": 292.4, "2": 339.4, "3": 25.4}} + } +} diff --git a/data/scenarios/smoke-then-clear/scenario.json b/data/scenarios/smoke-then-clear/scenario.json new file mode 100644 index 0000000..5e55036 --- /dev/null +++ b/data/scenarios/smoke-then-clear/scenario.json @@ -0,0 +1,8 @@ +{ + "description": "Real wildfire (prod alert 49767) seen by two cameras: 40 frames of smoke, then a clear view held forever. Expected: one alert with two sequences, triangulated near 48.498, 2.946.", + "interval": 30, + "cameras": { + "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, + "nemours-02": {"poses": {"0": 245.9, "1": 292.4, "2": 339.4, "3": 25.4}} + } +} diff --git a/scripts/make_scenario.py b/scripts/make_scenario.py new file mode 100644 index 0000000..4be04b3 --- /dev/null +++ b/scripts/make_scenario.py @@ -0,0 +1,58 @@ +"""Write the frames of one fake camera from a replayed alert zip and clear images. + + python scripts/make_scenario.py data/scenarios/smoke-then-clear/croix-augas-01 \ + --alert data/replay_alerts/alert_49767.zip --frames 40 --clear clean/40-xxx.jpg + +Frames are written as 000.jpg, 001.jpg, ... in time order: the alert frames of +the camera named like the output folder, then the clear images. Without +--alert only the clear images are written (a camera that never sees smoke). +The scenario.json next to the camera folders is written by hand. +""" + +import argparse +import json +import zipfile +from pathlib import Path + + +def alert_frames(alert_zip, camera_name, count): + zf = zipfile.ZipFile(alert_zip) + alert = json.loads(zf.read("alert.json")) + camera_id = next( + k for k, cam in alert["cameras"].items() if cam["name"] == camera_name + ) + sequence = next(s for s in alert["sequences"] if str(s["camera_id"]) == camera_id) + detections = sorted(sequence["detections"], key=lambda d: d["recorded_at"])[:count] + return [zf.read(d["image"]) for d in detections] + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "out", type=Path, help="data/scenarios//" + ) + parser.add_argument("--alert", type=Path, help="alert zip from `make fetch-alerts`") + parser.add_argument( + "--frames", type=int, default=40, help="alert frames to keep (default 40)" + ) + parser.add_argument( + "--clear", + type=Path, + nargs="*", + default=[], + help="clear images appended at the end", + ) + args = parser.parse_args() + + frames = alert_frames(args.alert, args.out.name, args.frames) if args.alert else [] + frames += [path.read_bytes() for path in args.clear] + args.out.mkdir(parents=True, exist_ok=True) + for i, data in enumerate(frames): + (args.out / f"{i:03d}.jpg").write_bytes(data) + print(f"{len(frames)} frames written to {args.out}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_fake_camera.py b/tests/test_fake_camera.py new file mode 100644 index 0000000..ac55f48 --- /dev/null +++ b/tests/test_fake_camera.py @@ -0,0 +1,73 @@ +import json +import os +import ssl +import sys +import urllib.request +from pathlib import Path + +from dotenv import load_dotenv + +sys.path.insert( + 0, str(Path(__file__).resolve().parents[1] / "containers" / "fake_camera") +) +from server import frame_index # noqa: E402 + +load_dotenv() + +# fakecam-1 (croix-augas-01), published on the host by docker-compose.yml +BASE_URL = "https://127.0.0.1:8443" +AUTH = f"user={os.getenv('CAM_USER')}&password={os.getenv('CAM_PWD')}&channel=0" +# The fake camera uses a self-signed certificate, like pyro_camera_api (verify=False). +CTX = ssl._create_unverified_context() + + +def call(cmd, param=None, auth=AUTH): + data = ( + None + if param is None + else json.dumps([{"cmd": cmd, "action": 0, "param": param}]).encode() + ) + url = f"{BASE_URL}/cgi-bin/api.cgi?cmd={cmd}&{auth}" + with urllib.request.urlopen(url, data=data, context=CTX, timeout=5) as resp: + body = resp.read() + return ( + body if resp.headers["Content-Type"] == "image/jpeg" else json.loads(body) + ) + + +def test_frame_index(): + assert frame_index(0, 30, 3) == 0 + assert frame_index(59.9, 30, 3) == 1 + assert frame_index(600, 30, 3) == 2 # last frame held + + +def test_snap_returns_a_jpeg(): + assert call("Snap")[:2] == b"\xff\xd8" + + +def test_wrong_password_is_rejected(): + assert call("Snap", auth="user=nobody&password=wrong&channel=0")[0]["code"] == 1 + + +def test_ptz_follows_presets(): + assert ( + call("PtzCtrl", {"channel": 0, "op": "ToPos", "id": 3, "speed": 50})[0]["code"] + == 0 + ) + with urllib.request.urlopen(f"{BASE_URL}/scenario", context=CTX, timeout=5) as resp: + status = json.load(resp) + assert status["preset"] == 3 + assert status["azimuth"] == 59.8 + # Preset 42 is not set on this camera + assert ( + call("PtzCtrl", {"channel": 0, "op": "ToPos", "id": 42, "speed": 50})[0]["code"] + == 1 + ) + presets = call("GetPtzPreset", {"channel": 0})[0]["value"]["PtzPreset"] + assert [p["id"] for p in presets if p["enable"]] == [1, 2, 3] + + +def test_zoom_focus_values(): + zoom_focus = call("GetZoomFocus", {"channel": 0})[0]["value"]["ZoomFocus"] + assert zoom_focus["focus"]["pos"] == 720 + assert zoom_focus["zoom"]["pos"] == 0 From 0330a5d6af2dedb16e563c83848ec8d2d5cc35b9 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:01:05 +0200 Subject: [PATCH 02/19] feat: run the engine against the fake cameras engine and pyro_camera_api move from host networking to the compose network so the fake cameras are reachable by name. The camera API waits for init_script to write the camera tokens. The stuck detector is off since every preset serves the same frames. Poses of the two scenario cameras take the production presets and azimuths. --- Makefile | 30 ++++++++++---- data/credentials.json | 24 ++++++++---- data/csv/API_DATA_DEV - poses.csv | 14 +++---- docker-compose.yml | 65 +++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index 1d6526e..7407071 100644 --- a/Makefile +++ b/Makefile @@ -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" @@ -35,6 +37,7 @@ init: build: 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 -f containers/fake_camera/Dockerfile -t pyronear/fake-camera:latest containers/fake_camera/ # ------------------------------------------------------------------- # Build images from sibling repositories @@ -55,8 +58,21 @@ 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//scenario.json, +# images come from the "scenarios" GitHub release. +SCENARIO ?= smoke-then-clear +SCENARIOS_URL = https://github.com/pyronear/pyro-envdev/releases/download/scenarios +fetch-scenario: + @test -n "$$(ls data/scenarios/$(SCENARIO)/*/*.jpg 2>/dev/null)" || \ + (curl -fsSL $(SCENARIOS_URL)/$(SCENARIO).zip -o /tmp/$(SCENARIO).zip && \ + unzip -oq /tmp/$(SCENARIO).zip -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) @@ -84,13 +100,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 diff --git a/data/credentials.json b/data/credentials.json index 66a096e..8a17371 100644 --- a/data/credentials.json +++ b/data/credentials.json @@ -1,13 +1,21 @@ { - "mock_camera_1": { - "name": "brison-01", - "adapter": "mock", - "type": "static", - "pose_ids": [ - 29 - ], + "fakecam-1": { + "name": "croix-augas-01", + "adapter": "reolink", + "type": "ptz", + "poses": [1, 2, 3], + "pose_ids": [8, 9, 10], + "id": "", + "bbox_mask_url": "", + "token": "" + }, + "fakecam-2": { + "name": "nemours-02", + "adapter": "reolink", + "type": "ptz", + "poses": [0, 1, 2, 3], + "pose_ids": [25, 26, 27, 28], "id": "", - "poses": [], "bbox_mask_url": "", "token": "" } diff --git a/data/csv/API_DATA_DEV - poses.csv b/data/csv/API_DATA_DEV - poses.csv index 9b0c6d6..3cd721f 100644 --- a/data/csv/API_DATA_DEV - poses.csv +++ b/data/csv/API_DATA_DEV - poses.csv @@ -6,9 +6,9 @@ id,camera_id,azimuth,patrol_id 5,2,150,2 6,2,240,3 7,2,330,4 -8,3,0,1 -9,3,120,2 -10,3,240,3 +8,3,326.5,1 +9,3,13.2,2 +10,3,59.8,3 11,4,60,1 12,4,150,2 13,4,240,3 @@ -23,10 +23,10 @@ id,camera_id,azimuth,patrol_id 22,7,0,1 23,7,120,2 24,7,240,3 -25,8,60,1 -26,8,150,2 -27,8,240,3 -28,8,330,4 +25,8,245.9,0 +26,8,292.4,1 +27,8,339.4,2 +28,8,25.4,3 29,9,0,1 30,10,90,2 31,11,180,3 diff --git a/docker-compose.yml b/docker-compose.yml index b184cac..56aa097 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,44 @@ services: interval: 10s timeout: 10s retries: 5 + fakecam-1: + image: pyronear/fake-camera:latest + profiles: + - engine + - camera + environment: + - CAMERA=croix-augas-01 + - SCENARIO=${SCENARIO:-smoke-then-clear} + - CAM_USER=${CAM_USER} + - CAM_PWD=${CAM_PWD} + volumes: + - ./data/scenarios:/scenarios:ro + ports: + - "127.0.0.1:8443:443" + healthcheck: + test: ["CMD", "python", "-c", "import ssl, urllib.request; urllib.request.urlopen('https://localhost/scenario', context=ssl._create_unverified_context())"] + interval: 10s + timeout: 5s + retries: 5 + fakecam-2: + image: pyronear/fake-camera:latest + profiles: + - engine + - camera + environment: + - CAMERA=nemours-02 + - SCENARIO=${SCENARIO:-smoke-then-clear} + - CAM_USER=${CAM_USER} + - CAM_PWD=${CAM_PWD} + volumes: + - ./data/scenarios:/scenarios:ro + ports: + - "127.0.0.1:8444:443" + healthcheck: + test: ["CMD", "python", "-c", "import ssl, urllib.request; urllib.request.urlopen('https://localhost/scenario', context=ssl._create_unverified_context())"] + interval: 10s + timeout: 5s + retries: 5 pyro_camera_api: image: pyronear/pyro-camera-api:latest profiles: @@ -73,9 +111,26 @@ services: - CAM_USER=${CAM_USER} - CAM_PWD=${CAM_PWD} - MEDIAMTX_SERVER_IP=${MEDIAMTX_SERVER_IP} + # Fake cameras serve the same frames on every preset, which the detector reads as a stuck camera. + - ENABLE_STUCK_DETECTOR=false volumes: - ./data:/usr/src/app/data - network_mode: host + ports: + - "127.0.0.1:8081:8081" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8081/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + depends_on: + # Camera tokens are written to data/credentials.json by init_script and read once at startup. + init_script: + condition: service_completed_successfully + fakecam-1: + condition: service_healthy + fakecam-2: + condition: service_healthy logging: driver: json-file options: @@ -86,13 +141,15 @@ services: profiles: - engine environment: - - API_URL=http://localhost:5050 + - API_URL=http://pyro_api:5050 - CAM_USER=${CAM_USER} - CAM_PWD=${CAM_PWD} - command: python run.py + command: python run.py --pyro_camera_api_url http://pyro_camera_api:8081 volumes: - ./data:/usr/src/app/data - network_mode: host + depends_on: + pyro_camera_api: + condition: service_healthy logging: driver: json-file options: From 4dc44d8548252c9ec3d63c77544de79dbf788547 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:01:05 +0200 Subject: [PATCH 03/19] ci: start the fake cameras in the compose workflow --- .github/workflows/docker-compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker-compose.yaml b/.github/workflows/docker-compose.yaml index 068bfeb..208cfca 100644 --- a/.github/workflows/docker-compose.yaml +++ b/.github/workflows/docker-compose.yaml @@ -25,6 +25,9 @@ jobs: - name: Run docker-compose up -d run: make run + - name: Start the fake cameras + run: make run-camera + # Wait for containers to start up (adjust the sleep time as needed) - name: Wait for containers to start run: sleep 120 From 3e2c6139b2f8868146fe176b254d14268f95bc3b Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:01:05 +0200 Subject: [PATCH 04/19] docs: document the fake cameras and scenarios --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 70fab60..6c533b7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -111,6 +111,78 @@ 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=false-positive 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 +* `false-positive`: a false positive on `nemours-02`, `croix-augas-01` stays clear +* `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. + +### Scenario format + +`data/scenarios//scenario.json` describes the scenario (committed), the images +live next to it and are downloaded by `make fetch-scenario SCENARIO=` from the +[`scenarios` release](https://github.com/pyronear/pyro-envdev/releases/tag/scenarios): + +``` +data/scenarios/smoke-then-clear/ + scenario.json # {"interval": 30, "cameras": {"": {"poses": {"": }}}} + 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//croix-augas-01 \ + --alert data/replay_alerts/alert_49767.zip --frames 40 --clear clear.jpg +``` + +Then zip the scenario folder (`cd data/scenarios && zip -r .zip `) and attach +it to the `scenarios` release. + +### 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 From 471b9c0ee55a10c6080cf723e3a39a4348085013 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:06:40 +0200 Subject: [PATCH 05/19] fix: let init_script exit cleanly when the data already exists docker compose up restarts the exited init container, which failed on the duplicate organizations and blocked the services that wait for it. --- containers/init_script/init_script.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/containers/init_script/init_script.py b/containers/init_script/init_script.py index 94f0594..87ed539 100644 --- a/containers/init_script/init_script.py +++ b/containers/init_script/init_script.py @@ -49,6 +49,14 @@ cameras = cameras.fillna("") poses = pd.read_csv(f"data/csv/API_DATA{sub_path} - poses.csv") +# `docker compose up` re-runs this one-shot container: skip when the data is +# already there, so services waiting on it (service_completed_successfully) +# still start. +existing_orgs = api_request("get", f"{api_url}/organizations/", superuser_auth) +if set(organizations.name) <= {org["name"] for org in existing_orgs}: + logging.info("Organizations already exist, nothing to do") + sys.exit(0) + # ============================================================================ # ORGAS CREATION # ============================================================================ From 6e6039b1a9417891b27e5aaecbde368691878ad0 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:13:54 +0200 Subject: [PATCH 06/19] chore: ignore the engine model cache under data/ --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 50c0312..43e72fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.onnx *.jpg data/images/ +# Model cache written by the engine container (./data is mounted) +data/models/ *.pem # Byte-compiled / optimized / DLL files __pycache__/ From 151018bd4d95786949d3e35e47c1e267907efbd2 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:19:15 +0200 Subject: [PATCH 07/19] fix: pass API_URL to the camera API so it fetches pose azimuths --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 56aa097..cceaafd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,6 +108,8 @@ services: profiles: - engine environment: + # Pose azimuths are fetched from the platform API. + - API_URL=http://pyro_api:5050 - CAM_USER=${CAM_USER} - CAM_PWD=${CAM_PWD} - MEDIAMTX_SERVER_IP=${MEDIAMTX_SERVER_IP} From 62307ae3261dda6c53eeb7f99049508e76c3b56e Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:19:15 +0200 Subject: [PATCH 08/19] fix: keep init_script strict when camera tokens are missing A database initialized before this branch has the organizations but no token for the new fake cameras: do not exit early in that case. --- containers/init_script/init_script.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/containers/init_script/init_script.py b/containers/init_script/init_script.py index 87ed539..702b4c4 100644 --- a/containers/init_script/init_script.py +++ b/containers/init_script/init_script.py @@ -54,8 +54,11 @@ # still start. existing_orgs = api_request("get", f"{api_url}/organizations/", superuser_auth) if set(organizations.name) <= {org["name"] for org in existing_orgs}: - logging.info("Organizations already exist, nothing to do") - sys.exit(0) + if all(cam["token"] for cam in read_json_file(credentials_path).values()): + logging.info("Organizations and camera tokens already exist, nothing to do") + sys.exit(0) + logging.warning("Database already initialized but camera tokens are missing") + logging.warning("Run `make stop` to start from a fresh database") # ============================================================================ # ORGAS CREATION From 3eb070f27c4a6305e26f843d618c819da3f68425 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:19:15 +0200 Subject: [PATCH 09/19] fix: drop stale frames when rebuilding a scenario Also state in the scenario descriptions that every preset serves the same frames for now. --- data/scenarios/false-positive/scenario.json | 2 +- data/scenarios/smoke-then-clear/scenario.json | 2 +- scripts/make_scenario.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/data/scenarios/false-positive/scenario.json b/data/scenarios/false-positive/scenario.json index 6069821..6974f74 100644 --- a/data/scenarios/false-positive/scenario.json +++ b/data/scenarios/false-positive/scenario.json @@ -1,5 +1,5 @@ { - "description": "False positive (prod alert 54194) on nemours-02: 40 frames, then a clear view. croix-augas-01 stays clear. Expected: at most one single-camera alert, no triangulation.", + "description": "False positive (prod alert 54194) on nemours-02: 40 frames, then a clear view. croix-augas-01 stays clear. Expect sequences on nemours-02 only (one per pose for now), none on croix-augas-01.", "interval": 30, "cameras": { "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, diff --git a/data/scenarios/smoke-then-clear/scenario.json b/data/scenarios/smoke-then-clear/scenario.json index 5e55036..c19df99 100644 --- a/data/scenarios/smoke-then-clear/scenario.json +++ b/data/scenarios/smoke-then-clear/scenario.json @@ -1,5 +1,5 @@ { - "description": "Real wildfire (prod alert 49767) seen by two cameras: 40 frames of smoke, then a clear view held forever. Expected: one alert with two sequences, triangulated near 48.498, 2.946.", + "description": "Real wildfire (prod alert 49767) seen by two cameras: 40 frames of smoke, then a clear view held forever. All presets serve the same frames for now, so expect one sequence per pose of both cameras, then no more detections.", "interval": 30, "cameras": { "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, diff --git a/scripts/make_scenario.py b/scripts/make_scenario.py index 4be04b3..b9f9d58 100644 --- a/scripts/make_scenario.py +++ b/scripts/make_scenario.py @@ -49,6 +49,8 @@ def main(): frames = alert_frames(args.alert, args.out.name, args.frames) if args.alert else [] frames += [path.read_bytes() for path in args.clear] args.out.mkdir(parents=True, exist_ok=True) + for stale in args.out.glob("*.jpg"): + stale.unlink() for i, data in enumerate(frames): (args.out / f"{i:03d}.jpg").write_bytes(data) print(f"{len(frames)} frames written to {args.out}") From a12729fe2be43c5d8de9caa3dc2a34eb06a527c1 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:33:52 +0200 Subject: [PATCH 10/19] chore: drop the false-positive scenario The fake camera only serves frames, so for the engine a false positive is the same as a wildfire. Keep fire and no fire. --- README.md | 3 +-- data/scenarios/false-positive/scenario.json | 8 -------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 data/scenarios/false-positive/scenario.json diff --git a/README.md b/README.md index 6c533b7..53fb1c0 100644 --- a/README.md +++ b/README.md @@ -122,14 +122,13 @@ the last image being held forever. Every preset of a camera serves the same imag ```bash make run-engine # engine + camera API + fake cameras -SCENARIO=false-positive make run-engine # pick another scenario +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 -* `false-positive`: a false positive on `nemours-02`, `croix-augas-01` stays clear * `never-smoke`: both cameras hold a clear view `GET https://localhost:8443/scenario` (8444 for `fakecam-2`) reports the camera state: diff --git a/data/scenarios/false-positive/scenario.json b/data/scenarios/false-positive/scenario.json deleted file mode 100644 index 6974f74..0000000 --- a/data/scenarios/false-positive/scenario.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "description": "False positive (prod alert 54194) on nemours-02: 40 frames, then a clear view. croix-augas-01 stays clear. Expect sequences on nemours-02 only (one per pose for now), none on croix-augas-01.", - "interval": 30, - "cameras": { - "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, - "nemours-02": {"poses": {"0": 245.9, "1": 292.4, "2": 339.4, "3": 25.4}} - } -} From 7e8624a068079b97b960290d7f76fec2473928da Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 22 Oct 2026 19:34:21 +0200 Subject: [PATCH 11/19] fix: keep the committed scenario.json when fetching scenario images --- Makefile | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 7407071..ff6100d 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ SCENARIOS_URL = https://github.com/pyronear/pyro-envdev/releases/download/scenar fetch-scenario: @test -n "$$(ls data/scenarios/$(SCENARIO)/*/*.jpg 2>/dev/null)" || \ (curl -fsSL $(SCENARIOS_URL)/$(SCENARIO).zip -o /tmp/$(SCENARIO).zip && \ - unzip -oq /tmp/$(SCENARIO).zip -d data/scenarios && rm /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 diff --git a/README.md b/README.md index 53fb1c0..5d9defc 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,8 @@ python scripts/make_scenario.py data/scenarios//croix-augas-01 \ --alert data/replay_alerts/alert_49767.zip --frames 40 --clear clear.jpg ``` -Then zip the scenario folder (`cd data/scenarios && zip -r .zip `) and attach -it to the `scenarios` release. +Then zip the images (`cd data/scenarios && zip -r .zip -x '*/scenario.json'`) +and attach the zip to the `scenarios` release. `scenario.json` itself is committed. ### Working on pyro-engine without a camera From 778fd6e71853bbf5a77e2d953c1f9f6967b9c108 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 23 Oct 2026 11:47:18 +0200 Subject: [PATCH 12/19] fix: harden the fetched-scenario and completed-init checks fetch-scenario downloads unless every camera of the scenario has frames. init_script skips a re-run only when a marker written at the end of a successful run exists, so a partial init is not mistaken for a complete one. --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ff6100d..5de2976 100644 --- a/Makefile +++ b/Makefile @@ -62,8 +62,10 @@ run-backend: # 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: - @test -n "$$(ls data/scenarios/$(SCENARIO)/*/*.jpg 2>/dev/null)" || \ + @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) From 10c5b43766fde0447cf9b9047b231411581f4d63 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 23 Oct 2026 11:47:46 +0200 Subject: [PATCH 13/19] fix: mark a completed init with a file written at the end The previous commit only changed fetch-scenario; this is the init_script part: a re-run exits early only when data/.init_done exists, so a partial init is not mistaken for a complete one. --- .gitignore | 2 ++ containers/init_script/init_script.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 43e72fa..7cae47b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ 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__/ diff --git a/containers/init_script/init_script.py b/containers/init_script/init_script.py index 702b4c4..ae5d71d 100644 --- a/containers/init_script/init_script.py +++ b/containers/init_script/init_script.py @@ -49,15 +49,17 @@ cameras = cameras.fillna("") poses = pd.read_csv(f"data/csv/API_DATA{sub_path} - poses.csv") -# `docker compose up` re-runs this one-shot container: skip when the data is -# already there, so services waiting on it (service_completed_successfully) -# still start. +# `docker compose up` re-runs this one-shot container: skip when a previous +# run completed on this database, so services waiting on it +# (service_completed_successfully) still start. The marker alone is not +# enough: `make stop` drops the database but keeps data/ on the host. +done_marker = "data/.init_done" existing_orgs = api_request("get", f"{api_url}/organizations/", superuser_auth) if set(organizations.name) <= {org["name"] for org in existing_orgs}: - if all(cam["token"] for cam in read_json_file(credentials_path).values()): - logging.info("Organizations and camera tokens already exist, nothing to do") + if os.path.exists(done_marker): + logging.info("Database already initialized, nothing to do") sys.exit(0) - logging.warning("Database already initialized but camera tokens are missing") + logging.warning("Database already initialized but the last init did not complete") logging.warning("Run `make stop` to start from a fresh database") # ============================================================================ @@ -275,6 +277,8 @@ logging.info("All camera and pose updates completed") logging.info("Initialization script completed successfully") +with open(done_marker, "w") as marker: + marker.write("ok\n") # Load environment variables from .env file # load_dotenv() From e86f135f7dda2e32b418e28d21bad4e2c043ae0d Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 23 Oct 2026 11:57:52 +0200 Subject: [PATCH 14/19] ci: build and push the fake-camera image latest on main, X.Y.Z on a fake-camera-vX.Y.Z tag, build only on pull requests. The default scenario is fetched from the release and embedded. --- .github/workflows/fake-camera-image.yml | 63 +++++++++++++++++++++++++ README.md | 4 ++ 2 files changed, 67 insertions(+) create mode 100644 .github/workflows/fake-camera-image.yml diff --git a/.github/workflows/fake-camera-image.yml b/.github/workflows/fake-camera-image.yml new file mode 100644 index 0000000..50f5433 --- /dev/null +++ b/.github/workflows/fake-camera-image.yml @@ -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 diff --git a/README.md b/README.md index 5d9defc..d710e11 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ Scenarios: `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 `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//scenario.json` describes the scenario (committed), the images From 87bd12a4814d99b52284e5fcbf04df99339c10d9 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 23 Oct 2026 12:14:54 +0200 Subject: [PATCH 15/19] feat: embed the default scenario in the fake camera image So the published image runs alone, e.g. on a test station; a volume on /scenarios still overrides it. The build takes data/scenarios as a named context, which the image workflow already passes. --- Makefile | 5 +++-- README.md | 3 +++ containers/fake_camera/Dockerfile | 5 +++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 5de2976..e45d68a 100644 --- a/Makefile +++ b/Makefile @@ -34,10 +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 -f containers/fake_camera/Dockerfile -t pyronear/fake-camera:latest containers/fake_camera/ + 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 diff --git a/README.md b/README.md index d710e11..1cd7445 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,9 @@ Scenarios: `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. diff --git a/containers/fake_camera/Dockerfile b/containers/fake_camera/Dockerfile index 3b330a9..8912f57 100644 --- a/containers/fake_camera/Dockerfile +++ b/containers/fake_camera/Dockerfile @@ -8,6 +8,11 @@ RUN openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj "/CN=fakecam" \ 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"] From 100efbac58ffc462aff8608ee31a9f9c6d393f06 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 23 Oct 2026 12:14:54 +0200 Subject: [PATCH 16/19] fix: require the camera tokens as well as the init marker A restored credentials.json with blank tokens must not be skipped, and a full init starts by dropping the marker. --- containers/init_script/init_script.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/containers/init_script/init_script.py b/containers/init_script/init_script.py index ae5d71d..d382fa6 100644 --- a/containers/init_script/init_script.py +++ b/containers/init_script/init_script.py @@ -56,11 +56,16 @@ done_marker = "data/.init_done" existing_orgs = api_request("get", f"{api_url}/organizations/", superuser_auth) if set(organizations.name) <= {org["name"] for org in existing_orgs}: - if os.path.exists(done_marker): + tokens_present = all( + cam["token"] for cam in read_json_file(credentials_path).values() + ) + if os.path.exists(done_marker) and tokens_present: logging.info("Database already initialized, nothing to do") sys.exit(0) logging.warning("Database already initialized but the last init did not complete") logging.warning("Run `make stop` to start from a fresh database") +if os.path.exists(done_marker): + os.remove(done_marker) # ============================================================================ # ORGAS CREATION From 56ec5f4178e812464eb6e44f557948cd75d3f949 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 23 Oct 2026 12:14:54 +0200 Subject: [PATCH 17/19] chore: reach a host MediaMTX from the compose network --- .env.test | 3 ++- docker-compose.yml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.env.test b/.env.test index b087a71..0e37215 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index cceaafd..774ab8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -115,6 +115,9 @@ services: - MEDIAMTX_SERVER_IP=${MEDIAMTX_SERVER_IP} # Fake cameras serve the same frames on every preset, which the detector reads as a stuck camera. - ENABLE_STUCK_DETECTOR=false + # Lets MEDIAMTX_SERVER_IP=host.docker.internal reach a MediaMTX running on the host. + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./data:/usr/src/app/data ports: From 83e1a6178b2ce680986a430dd7eec478c6cbb755 Mon Sep 17 00:00:00 2001 From: Mateo Date: Mon, 26 Oct 2026 19:14:19 +0100 Subject: [PATCH 18/19] ci: start the engine profile and fail on unhealthy or stopped services --- .github/workflows/docker-compose.yaml | 30 +++++++++++++-------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docker-compose.yaml b/.github/workflows/docker-compose.yaml index 208cfca..51f280a 100644 --- a/.github/workflows/docker-compose.yaml +++ b/.github/workflows/docker-compose.yaml @@ -19,33 +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 fake cameras - run: make run-camera + - 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 @@ -54,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 From ca6bf4dce9e8d85f83826dd5f698757166b540ed Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 27 Oct 2026 11:53:13 +0100 Subject: [PATCH 19/19] fix: move each preset to its next frame on its next visit, not on a clock With a clock, the camera API patrol and the scenario drifted apart: a preset could get the same frame twice or skip one, and the autofocus snaps advanced the scenario too. Now the first Snap after a ToPos takes the next frame of that preset, snaps in place keep it, and a static camera moves on every Snap. The scenario interval is gone. --- README.md | 12 +++-- containers/fake_camera/server.py | 44 +++++++++---------- data/scenarios/never-smoke/scenario.json | 1 - data/scenarios/smoke-then-clear/scenario.json | 1 - tests/test_fake_camera.py | 42 ++++++++++++++++-- 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1cd7445..76c8b98 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,11 @@ docker compose logs -f engine 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. +images shown one after the other, the last image being held forever. Each preset moves to +its next image on its next visit by the patrol (the first `Snap` after a `ToPos`), so the +engine sees every image once per preset, in order, whatever its timing; snaps taken in place, +like the autofocus ones, see the same image. Every preset of a camera serves the same images +for now. ```bash make run-engine # engine + camera API + fake cameras @@ -132,7 +135,8 @@ Scenarios: * `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. +current preset and azimuth, its frame index and `finished` once every visited preset shows the +last frame. 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). @@ -149,7 +153,7 @@ live next to it and are downloaded by `make fetch-scenario SCENARIO=` from ``` data/scenarios/smoke-then-clear/ - scenario.json # {"interval": 30, "cameras": {"": {"poses": {"": }}}} + scenario.json # {"cameras": {"": {"poses": {"": }}}} croix-augas-01/ # frames of fakecam-1, in order: 000.jpg, 001.jpg, ... nemours-02/ # frames of fakecam-2 ``` diff --git a/containers/fake_camera/server.py b/containers/fake_camera/server.py index 6e3e64e..c9ad6d2 100644 --- a/containers/fake_camera/server.py +++ b/containers/fake_camera/server.py @@ -3,8 +3,10 @@ Answers the Reolink HTTP API (``/cgi-bin/api.cgi?cmd=...``) the way the real ``pyro_camera_api`` Reolink adapter expects, serving the frames of a scenario. One process is one camera: ``SCENARIO`` and ``CAMERA`` select -``///*.jpg`` (sorted, one frame per -``interval`` seconds from the first Snap, last frame held). +``///*.jpg`` (sorted, last frame held). +Each preset moves to its next frame on its next visit: the first Snap after a +ToPos, so snaps taken in place (autofocus) see the same frame. A camera never +positioned (static) moves on every Snap. ``GET /scenario`` is not part of the Reolink API: it reports the camera state (preset, azimuth, frame, finished) so tests know where the scenario is. @@ -15,7 +17,6 @@ import os import ssl import threading -import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -29,9 +30,9 @@ CAM_PWD = os.environ.get("CAM_PWD", "") -def frame_index(elapsed, interval, n_frames): - """Frame served `elapsed` seconds after the first Snap, last one held.""" - return min(n_frames - 1, int(elapsed // interval)) +def frame_index(visits, n_frames): + """Frame served on the `visits`-th visit of a preset (1 = first), last one held.""" + return min(n_frames - 1, max(0, visits - 1)) def reply(cmd, value=None, error=None): @@ -46,7 +47,6 @@ class Camera: def __init__(self, scenario_dir, name): meta = json.loads((scenario_dir / "scenario.json").read_text()) self.name = name - self.interval = meta.get("interval", 30) # preset id -> real-world azimuth, informational only (see /scenario) self.azimuths = { int(k): float(v) for k, v in meta["cameras"][name]["poses"].items() @@ -58,22 +58,21 @@ def __init__(self, scenario_dir, name): f"no frames in {scenario_dir / name}, run `make fetch-scenario` first" ) self.lock = threading.Lock() - self.started = None # monotonic time of the first Snap - self.preset = None # unknown until a ToPos + self.preset = None # unknown until a ToPos; stays None on a static camera + self.visits = {} # preset -> visits that took a Snap + self.arrived = True # the next Snap is the first at the current position self.enabled = set(self.azimuths) self.zoom, self.focus, self.autofocus_disabled = 0, 720, 0 - def _elapsed(self): - return 0.0 if self.started is None else time.monotonic() - self.started - - def _frame(self): - return frame_index(self._elapsed(), self.interval, len(self.frames)) + def _frame(self, preset): + return frame_index(self.visits.get(preset, 0), len(self.frames)) def snap(self): with self.lock: - if self.started is None: - self.started = time.monotonic() - return self.frames[self._frame()].read_bytes() + if self.arrived or self.preset is None: + self.visits[self.preset] = self.visits.get(self.preset, 0) + 1 + self.arrived = False + return self.frames[self._frame(self.preset)].read_bytes() def status(self): with self.lock: @@ -81,11 +80,10 @@ def status(self): "camera": self.name, "preset": self.preset, "azimuth": self.azimuths.get(self.preset), - "elapsed": round(self._elapsed(), 1), - "frame": self._frame(), + "frame": self._frame(self.preset), "frames": len(self.frames), - "finished": self.started is not None - and self._frame() == len(self.frames) - 1, + "finished": bool(self.visits) + and all(self._frame(p) == len(self.frames) - 1 for p in self.visits), } def command(self, cmd, param): @@ -97,6 +95,7 @@ def command(self, cmd, param): if idx not in self.enabled: return reply(cmd, error=f"preset {idx} is not set") self.preset = idx + self.arrived = True elif op not in NO_PAN_OPS: self.preset = None return reply(cmd) @@ -220,11 +219,10 @@ def main(): httpd.socket = context.wrap_socket(httpd.socket, server_side=True) logging.info( "fake camera %s plays scenario %s: " - "%d frames, one every %ss, presets %s, port %d", + "%d frames, one per preset visit, presets %s, port %d", camera.name, scenario, len(camera.frames), - camera.interval, sorted(camera.enabled), port, ) diff --git a/data/scenarios/never-smoke/scenario.json b/data/scenarios/never-smoke/scenario.json index 223f4aa..156f4f5 100644 --- a/data/scenarios/never-smoke/scenario.json +++ b/data/scenarios/never-smoke/scenario.json @@ -1,6 +1,5 @@ { "description": "Both cameras hold a clear view. Expected: no alert.", - "interval": 30, "cameras": { "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, "nemours-02": {"poses": {"0": 245.9, "1": 292.4, "2": 339.4, "3": 25.4}} diff --git a/data/scenarios/smoke-then-clear/scenario.json b/data/scenarios/smoke-then-clear/scenario.json index c19df99..ae84725 100644 --- a/data/scenarios/smoke-then-clear/scenario.json +++ b/data/scenarios/smoke-then-clear/scenario.json @@ -1,6 +1,5 @@ { "description": "Real wildfire (prod alert 49767) seen by two cameras: 40 frames of smoke, then a clear view held forever. All presets serve the same frames for now, so expect one sequence per pose of both cameras, then no more detections.", - "interval": 30, "cameras": { "croix-augas-01": {"poses": {"1": 326.5, "2": 13.2, "3": 59.8}}, "nemours-02": {"poses": {"0": 245.9, "1": 292.4, "2": 339.4, "3": 25.4}} diff --git a/tests/test_fake_camera.py b/tests/test_fake_camera.py index ac55f48..6638aab 100644 --- a/tests/test_fake_camera.py +++ b/tests/test_fake_camera.py @@ -10,7 +10,7 @@ sys.path.insert( 0, str(Path(__file__).resolve().parents[1] / "containers" / "fake_camera") ) -from server import frame_index # noqa: E402 +from server import Camera, frame_index # noqa: E402 load_dotenv() @@ -36,9 +36,43 @@ def call(cmd, param=None, auth=AUTH): def test_frame_index(): - assert frame_index(0, 30, 3) == 0 - assert frame_index(59.9, 30, 3) == 1 - assert frame_index(600, 30, 3) == 2 # last frame held + assert frame_index(0, 3) == 0 # never visited + assert frame_index(1, 3) == 0 # first visit + assert frame_index(2, 3) == 1 + assert frame_index(20, 3) == 2 # last frame held + + +def test_one_frame_per_preset_visit(tmp_path): + (tmp_path / "scenario.json").write_text( + json.dumps({"cameras": {"cam": {"poses": {"1": 0.0, "2": 90.0}}}}) + ) + (tmp_path / "cam").mkdir() + for i in range(3): + (tmp_path / "cam" / f"{i:03d}.jpg").write_bytes(b"frame%d" % i) + cam = Camera(tmp_path, "cam") + + def visit(preset, snaps=1): + cam.command("PtzCtrl", {"op": "ToPos", "id": preset}) + return [cam.snap() for _ in range(snaps)] + + assert visit(1, snaps=3) == [b"frame0"] * 3 # autofocus snaps in place: same frame + assert visit(2) == [b"frame0"] # each preset starts at the first frame + assert visit(1) == [b"frame1"] + assert visit(1) == [b"frame2"] + assert visit(1) == [b"frame2"] # last frame held + assert not cam.status()["finished"] # preset 2 is still on its first frame + + +def test_static_camera_moves_on_every_snap(tmp_path): + (tmp_path / "scenario.json").write_text( + json.dumps({"cameras": {"cam": {"poses": {}}}}) + ) + (tmp_path / "cam").mkdir() + for i in range(2): + (tmp_path / "cam" / f"{i:03d}.jpg").write_bytes(b"frame%d" % i) + cam = Camera(tmp_path, "cam") + assert [cam.snap() for _ in range(3)] == [b"frame0", b"frame1", b"frame1"] + assert cam.status()["finished"] def test_snap_returns_a_jpeg():