From 8db774c221786e3bab79bb7734672fa2becfe3b5 Mon Sep 17 00:00:00 2001 From: Majyles <123649730+Majyles@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:25:58 +0200 Subject: [PATCH] feat(notebooks): replay coherent demo scenarios Rewrite send_real_alerts.ipynb around a scenarios.py module so that every alert of the dev environment replays real footage with the camera that recorded it: images, camera names and cones on the map now agree. - Cameras are referenced by name (the DB ids do not match the seed CSV ids). - Three scenarios: a fire triangulated by three towers (sdis-77), a fire seen by a single camera (sdis-77) and a fire for sdis-07. - Quick start cell sending every scenario in about 12 s, and a Start over cell deleting every alert, sequence and detection through the API. - Detections keep the real capture times of the samples (shifted to now), so the alert timeline replays the real pace of the fire; the leading burst of ~1 s gaps found in the samples is spread at the typical gap. - Each detection comes with a close-up per box, and each camera gets its last image, so the "close-up" button and the dashboard show real views. - send_sequences() replays any sequence folder, and can keep an unrelated fire out of other fires' alerts. - The scenarios can also be sent from a shell: python scenarios.py - README: document the scenarios, fix the users CSV path and an unclosed code block. Closes #26 Co-Authored-By: Claude Opus 5.5 --- README.md | 24 +- containers/notebooks/app/scenarios.py | 357 +++++++++++++++++ .../notebooks/app/send_real_alerts.ipynb | 361 ++++++------------ 3 files changed, 490 insertions(+), 252 deletions(-) create mode 100644 containers/notebooks/app/scenarios.py diff --git a/README.md b/README.md index 70fab60..92c17e7 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,10 @@ make build make run ``` -* Send an alert by opening [http://0.0.0.0:8889/notebooks/notebooks/send_real_alerts.ipynb](http://0.0.0.0:8889/notebooks/notebooks/send_real_alerts.ipynb) -* Observe the alert on the frontend at [http://localhost:8080/](http://localhost:8080/) -* Use credentials from `data/csv/API_DATA_DEV/users.csv` +* Send realistic alerts by running the notebook [http://0.0.0.0:8889/notebooks/notebooks/send_real_alerts.ipynb](http://0.0.0.0:8889/notebooks/notebooks/send_real_alerts.ipynb) + (one cell per scenario: a fire seen by three towers, a fire seen by a single camera, etc.) +* Observe the alerts on the frontend at [http://localhost:8080/](http://localhost:8080/) +* Use credentials from `data/csv/API_DATA_DEV - users.csv` (`test77` / `test` or `test07` / `test`) * Or check directly on the API at [http://0.0.0.0:5050/docs](http://0.0.0.0:5050/docs) with the same creds --- @@ -117,13 +118,20 @@ docker logs engine Create a directory `data/images` before starting the environment and put your images inside. -### Send custom alerts +### Send alerts -Use Jupyter notebooks (e.g., `notebooks/send_real_alerts.ipynb`). -When running notebooks **inside Docker**, set: +The notebook `send_real_alerts.ipynb` replays real wildfire footage with the camera that +recorded it, so images, camera names and the map agree. Its last section shows how to send +your own sequence folder with `send_sequences`. The same scenarios can be sent from a shell: -```python -API_URL = "http://api:5050" +```bash +docker exec -w /app/notebooks pyro-envdev-notebooks-1 python scenarios.py # all scenarios +docker exec -w /app/notebooks pyro-envdev-notebooks-1 python scenarios.py triangulated-fire-77 +docker exec -w /app/notebooks pyro-envdev-notebooks-1 python scenarios.py --delete-all-alerts # start over +``` + +The *Alerts* page only shows today's alerts: send the scenarios again on another day. +When writing your own notebooks **inside Docker**, use `API_URL = "http://api:5050"`. ### Update the last image for a camera diff --git a/containers/notebooks/app/scenarios.py b/containers/notebooks/app/scenarios.py new file mode 100644 index 0000000..db089be --- /dev/null +++ b/containers/notebooks/app/scenarios.py @@ -0,0 +1,357 @@ +"""Send realistic demo alerts to the dev API. + +Each scenario replays real wildfire footage with the camera that recorded it, so +camera names, images and the cones drawn on the map agree. Cameras are referenced by +name, never by id (the ids in the database do not match the ids of the seed CSV). + +From a notebook: + from scenarios import run_scenario + run_scenario("triangulated-fire-77") + +From a shell, inside the notebooks container: + python scenarios.py # every scenario + python scenarios.py single-camera-fire-77 # selected scenarios + python scenarios.py --delete-all-alerts # start over +""" + +import ast +import io +import os +import re +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import urljoin + +import requests +from dotenv import load_dotenv +from PIL import Image +from pyroclient import Client + +from api import get_camera_token, get_token +from utils import dl_seqs_from_url, get_or_create_pose_id_for_azimuth + +HERE = Path(__file__).resolve().parent +load_dotenv(HERE.parent / ".env") +API_URL = os.environ.get("API_URL", "http://api:5050") + +RELEASE_URL = "https://github.com/pyronear/pyro-envdev/releases/download/v0.0.1" +# local folder -> archive of the v0.0.1 release +SAMPLES = { + HERE.parent / "data" / "alert_samples": f"{RELEASE_URL}/alert_samples.zip", + HERE / "selection-true-positives": f"{RELEASE_URL}/selection-true-positives.zip", +} +TRIANGULATED = HERE.parent / "data" / "alert_samples" / "triangulated_sequences" +SINGLE = HERE / "selection-true-positives" +# Capture time in the sample file names: 20250714112431 or 2025-05-20T11-38-20 +CAPTURE_TIME = re.compile(r"(\d{4})-?(\d{2})-?(\d{2})T?(\d{2})-?(\d{2})-?(\d{2})") +# Spacing of frames whose file name has no capture time +FRAME_INTERVAL = timedelta(seconds=30) +# Samples start with frames ~1 s apart, likely buffered frames uploaded in a burst when +# the smoke was detected: such a leading burst is spread at the typical gap. +BURST_GAP = timedelta(seconds=5) +# Smallest side of the close-up sent with each detection, in pixels +CROP_MIN_SIZE = 160 +# Ground-truth labels carry no confidence: use a plausible model score so that +# boxes are displayed. +GROUND_TRUTH_CONF = 0.6 + +SCENARIOS = { + "triangulated-fire-77": { + "description": ( + "Real fire of 2025-07-14 seen by three towers: " + "one alert, three cameras, located on the map." + ), + # (camera name, pose azimuth, sequence folder) + "shots": [ + ( + "croix-augas-02", + 190, + TRIANGULATED / "41_croix-augas-02_2025-07-14_11-24-31", + ), + ("nemours-02", 25, TRIANGULATED / "65_nemours-02_2025-07-14_11-24-10"), + ( + "moret-sur-loing-01", + 280, + TRIANGULATED / "66_moret-sur-loing-01_2025-07-14_11-22-05", + ), + ], + }, + "single-camera-fire-77": { + "description": ( + "Real fire of 2025-05-20 seen by a single camera: " + "one alert with a direction but no location." + ), + # The folder name says 300, but the landscape matches the map at 120. + "shots": [ + ( + "videlles-02", + 120, + SINGLE / "2025-05-20T11-38-20_videlles-02-300_sequence-6438", + ) + ], + # Its cone crosses the 2025-07-14 fire: detach it, as an operator would. + "standalone": True, + }, + "single-camera-fire-07": { + "description": ( + "Real fire of 2025-09-29 seen from Serre-de-Barre, " + "for the sdis-07 organization." + ), + # Brison-03 saw it too, but its sample has a zero-confidence box and an azimuth + # that puts the fire 600 m from Serre-de-Barre, while the image shows it several + # km away. + "shots": [ + ( + "serre-de-barre-01", + 173, + TRIANGULATED / "22_serre-de-barre-01_2025-09-29_13-56-58", + ) + ], + }, +} + + +def download_samples() -> None: + """Fetch the sample sequences of the release, once.""" + for folder, url in SAMPLES.items(): + if not folder.is_dir(): + print(f"Downloading {url} ...") + dl_seqs_from_url(url, str(folder)) + + +def load_frames(folder: Path): + """Return (image path, bboxes, capture time or None) for frames with a box.""" + frames = [] + for img in sorted((folder / "images").glob("*.jpg")): + pred = folder / "labels_predictions" / f"{img.stem}.txt" + gt = folder / "labels_ground_truth" / f"{img.stem}.txt" + bboxes = [] + if pred.is_file() and pred.read_text().lstrip().startswith("["): + # pyro-engine format: [(xmin, ymin, xmax, ymax, conf), ...] + bboxes = ast.literal_eval(pred.read_text()) + elif gt.is_file(): + # YOLO format: class cx cy w h + for line in gt.read_text().splitlines(): + _, cx, cy, w, h = map(float, line.split()[:5]) + bboxes.append( + (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2, GROUND_TRUTH_CONF) + ) + if bboxes: + match = CAPTURE_TIME.search(img.stem) + captured = datetime(*map(int, match.groups())) if match else None + frames.append( + (img, [tuple(round(v, 3) for v in b) for b in bboxes], captured) + ) + if not frames: + raise FileNotFoundError(f"No usable frames in {folder}") + times = [t for *_, t in frames] + if all(times): + frames = [(i, b, t) for (i, b, _), t in zip(frames, _spread_bursts(times))] + return frames + + +def _spread_bursts(times: list) -> list: + """Spread a burst of short gaps at the start of a sequence at its typical gap. + + Sequences without a leading burst, or made of short gaps only, are kept as is. + """ + gaps = [b - a for a, b in zip(times, times[1:])] + burst = next((i for i, gap in enumerate(gaps) if gap >= BURST_GAP), 0) + if not burst: + return times + normal = sorted(gap for gap in gaps[burst:] if gap >= BURST_GAP) + typical = normal[len(normal) // 2] + return [times[burst] - (burst - i) * typical for i in range(burst)] + times[burst:] + + +def jpeg_bytes(image) -> bytes: + """Encode a PIL image, or the image file at this path, as JPEG.""" + if isinstance(image, Path): + image = Image.open(image) + stream = io.BytesIO() + image.save(stream, format="JPEG", quality=80) + return stream.getvalue() + + +def crop_bytes(image, bbox) -> bytes: + """Close-up on a box, shown by the platform: a square around it, 3 times larger.""" + width, height = image.size + xmin, ymin, xmax, ymax = bbox[:4] + side = max(3 * (xmax - xmin) * width, 3 * (ymax - ymin) * height, CROP_MIN_SIZE) + cx, cy = (xmin + xmax) / 2 * width, (ymin + ymax) / 2 * height + box = (cx - side / 2, cy - side / 2, cx + side / 2, cy + side / 2) + box = (max(0, box[0]), max(0, box[1]), min(width, box[2]), min(height, box[3])) + return jpeg_bytes(image.crop(tuple(round(v) for v in box))) + + +def _camera_ids(headers: dict) -> dict: + cameras = requests.get( + urljoin(API_URL, "/api/v1/cameras/"), headers=headers, timeout=5 + ).json() + return {cam["name"]: cam["id"] for cam in cameras} + + +def _unmatch(headers: dict, alert_id: int, sequence_id: int) -> None: + requests.post( + urljoin(API_URL, f"/api/v1/alerts/{alert_id}/sequences/{sequence_id}/unmatch"), + headers=headers, + timeout=5, + ).raise_for_status() + print(f" sequence {sequence_id} detached from alert {alert_id}") + + +def _detach_from_other_fires( + headers: dict, poses: set, timeout_seconds: float = 60 +) -> None: + """Separate the sequences of these (camera id, pose id) from other fires' alerts.""" + deadline = time.monotonic() + timeout_seconds + while True: + # Sequences are attached to alerts asynchronously, once validated. + alerts = requests.get( + urljoin(API_URL, "/api/v1/alerts/unlabeled/latest"), + headers=headers, + timeout=5, + ).json() + sequences = [s for a in alerts for s in a["sequences"]] + ours = {s["id"] for s in sequences if (s["camera_id"], s["pose_id"]) in poses} + seen = {(s["camera_id"], s["pose_id"]) for s in sequences if s["id"] in ours} + if seen >= poses or time.monotonic() > deadline: + break + time.sleep(2) + for alert in alerts: + mine = [s["id"] for s in alert["sequences"] if s["id"] in ours] + others = [s["id"] for s in alert["sequences"] if s["id"] not in ours] + if not mine or not others: + continue + elsewhere = { + s["id"] for a in alerts if a["id"] != alert["id"] for s in a["sequences"] + } + # If the other fires already have their own alert, this one only exists because + # the cones cross: give it to our fire. Otherwise our fire gets its own alert. + detached = others if elsewhere.issuperset(others) else mine + for sequence_id in detached: + _unmatch(headers, alert["id"], sequence_id) + + +def send_sequences(shots, standalone: bool = False, sleep_seconds: float = 0.1) -> None: + """Replay sequence folders as detections of the given cameras. + + Args: + shots: list of (camera name, pose azimuth in degrees, sequence folder) + standalone: keep this fire out of alerts shared with other cameras + sleep_seconds: delay between two detections + """ + download_samples() + admin_token = get_token( + API_URL, os.environ["SUPERADMIN_LOGIN"], os.environ["SUPERADMIN_PWD"] + ) + headers = {"Authorization": f"Bearer {admin_token}"} + camera_ids = _camera_ids(headers) + shots_frames, poses = [], set() + for cam_name, azimuth, folder in shots: + if cam_name not in camera_ids: + raise ValueError( + f"Unknown camera {cam_name!r}, " + f"available: {', '.join(sorted(camera_ids))}" + ) + cam_id = camera_ids[cam_name] + client = Client(get_camera_token(API_URL, cam_id, admin_token), API_URL) + pose_id = get_or_create_pose_id_for_azimuth(client, cam_id, azimuth) + poses.add((cam_id, pose_id)) + frames = load_frames(Path(folder)) + # Dashboard: show a real view of this camera and mark it as alive. + client.update_last_image(jpeg_bytes(frames[0][0])).raise_for_status() + client.heartbeat().raise_for_status() + print(f" {cam_name} (azimuth {azimuth}): {len(frames)} frames") + shots_frames.append((client, pose_id, frames)) + + # Keep the real capture times, shifted so that the last frame is captured now: the + # alert replays the real pace of the fire, without waiting for it. + now = datetime.now(timezone.utc).replace(tzinfo=None) + captured = [t for *_, frames in shots_frames for *_, t in frames] + timed = all(captured) + shift = now - max(captured) if timed else None + detections = [] + for client, pose_id, frames in shots_frames: + for i, (img, bboxes, captured_at) in enumerate(frames): + if timed: + recorded_at = captured_at + shift + else: + recorded_at = now - (len(frames) - 1 - i) * FRAME_INTERVAL + detections.append((recorded_at, client, pose_id, img, bboxes)) + + for recorded_at, client, pose_id, img, bboxes in sorted( + detections, key=lambda d: d[0] + ): + image = Image.open(img) + client.create_detection( + jpeg_bytes(image), + bboxes, + pose_id=pose_id, + crops=[crop_bytes(image, bbox) for bbox in bboxes], + recorded_at=recorded_at.isoformat(timespec="seconds"), + ).raise_for_status() + time.sleep(sleep_seconds) + + if standalone: + _detach_from_other_fires(headers, poses) + + +def run_scenario(name: str) -> None: + """Send the alerts of one scenario of SCENARIOS.""" + scenario = SCENARIOS[name] + print(f"== {name}: {scenario['description']}") + send_sequences(scenario["shots"], standalone=scenario.get("standalone", False)) + + +def run_all() -> None: + for name in SCENARIOS: + run_scenario(name) + + +def delete_all_alerts() -> None: + """Delete every alert, sequence and detection (with its images) of the dev API. + + Organizations, users, cameras and poses are kept. + """ + admin_token = get_token( + API_URL, os.environ["SUPERADMIN_LOGIN"], os.environ["SUPERADMIN_PWD"] + ) + headers = {"Authorization": f"Bearer {admin_token}"} + detections, page_size = [], 500 + while True: + page = requests.get( + urljoin(API_URL, "/api/v1/detections/"), + headers=headers, + params={"limit": page_size, "offset": len(detections)}, + timeout=5, + ).json() + detections += page + if len(page) < page_size: + break + # Deleting the sequences of an alert deletes the alert itself. + sequence_ids = {d["sequence_id"] for d in detections if d["sequence_id"]} + for sequence_id in sequence_ids: + requests.delete( + urljoin(API_URL, f"/api/v1/sequences/{sequence_id}"), + headers=headers, + timeout=5, + ).raise_for_status() + for detection in detections: + requests.delete( + urljoin(API_URL, f"/api/v1/detections/{detection['id']}"), + headers=headers, + timeout=5, + ).raise_for_status() + print(f"Deleted {len(sequence_ids)} sequences and {len(detections)} detections.") + + +if __name__ == "__main__": + if sys.argv[1:] == ["--delete-all-alerts"]: + delete_all_alerts() + else: + for scenario_name in sys.argv[1:] or SCENARIOS: + run_scenario(scenario_name) + print("Done.") diff --git a/containers/notebooks/app/send_real_alerts.ipynb b/containers/notebooks/app/send_real_alerts.ipynb index 07f3bb5..6124231 100644 --- a/containers/notebooks/app/send_real_alerts.ipynb +++ b/containers/notebooks/app/send_real_alerts.ipynb @@ -2,353 +2,226 @@ "cells": [ { "cell_type": "markdown", + "id": "cell-00", "metadata": {}, "source": [ - "# Intro\n", + "# Send demo alerts\n", "\n", - "This notebook is designed to generate alerts so that you can work on the development environment.\n", + "This notebook fills the dev environment with realistic alerts. Each scenario replays real wildfire footage\n", + "with the camera that recorded it, so images, camera names and the map agree.\n", "\n", - "It works as it is (just run blocks from the setup section), then you can run any section.\n", + "**How to use it**\n", + "1. Run the *Quick start* cell below. It sends every scenario (sample images are downloaded on first use).\n", + "2. Open the platform at [http://localhost:8080](http://localhost:8080) and log in with `test77` / `test`\n", + " (organization sdis-77) or `test07` / `test` (sdis-07).\n", "\n", - "It is also possible to run the entire notebook at once: To do so, in the menu, click ‘Run’ then ‘Run All Cells’." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import ast\n", - "import pandas as pd\n", - "from dotenv import load_dotenv\n", - "import os\n", - "from api import get_token, get_camera_token\n", - "from pyroclient import Client\n", - "import glob\n", - "from PIL import Image\n", - "import numpy as np\n", - "import io\n", - "import itertools\n", - "import requests\n", - "import random\n", - "import shutil\n", - "from utils import dl_seqs_from_url, read_pred_file, send_triangulated_alerts\n", - "import time" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "API_URL = \"http://api:5050\"\n", - "load_dotenv(\"../.env\")\n", - "SUPERADMIN_LOGIN = os.environ.get(\"SUPERADMIN_LOGIN\")\n", - "SUPERADMIN_PWD = os.environ.get(\"SUPERADMIN_PWD\")\n", + "To understand what each scenario shows, or send only some of them, use the sections below instead.\n", "\n", - "# Get access token\n", - "admin_access_token = get_token(API_URL, SUPERADMIN_LOGIN, SUPERADMIN_PWD)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "users = pd.read_csv(\"../data/csv/API_DATA_DEV - users.csv\")\n", - "cameras = pd.read_csv(\"../data/csv/API_DATA_DEV - cameras.csv\")\n", - "users" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cameras" + "**Good to know**\n", + "- The *Alerts* page only shows today's alerts. Running the quick start again within two hours extends the\n", + " same alerts; later, it creates new ones. The previous days' alerts stay in *History*, in your local database,\n", + " until you use *Start over*.\n", + "- Fires seen within 30 minutes whose cones cross are merged into one alert, as in production.\n", + " Cameras on the same tower are always grouped together.\n", + "- To start from a clean database, run `docker compose --profile front --profile tools down -v`,\n", + " then start the environment again (this deletes all data).\n", + "- The same scenarios can be sent from a shell:\n", + " `docker exec -w /app/notebooks pyro-envdev-notebooks-1 python scenarios.py`" ] }, { "cell_type": "markdown", + "id": "quick-start-md", "metadata": {}, "source": [ - "#### Download alerts sequences" + "## Quick start\n", + "\n", + "Already sent today? Running it again adds the same images to the same alerts, and alerts you have\n", + "labelled stay labelled (so they don't come back to the *Alerts* page): use *Start over* first." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, + "id": "quick-start", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "== triangulated-fire-77: Real fire of 2025-07-14 seen by three towers: one alert, three cameras, located on the map.\n", + " croix-augas-02 (azimuth 190): 10 frames\n", + " nemours-02 (azimuth 25): 10 frames\n", + " moret-sur-loing-01 (azimuth 280): 10 frames\n", + "== single-camera-fire-77: Real fire of 2025-05-20 seen by a single camera: one alert with a direction but no location.\n", + " videlles-02 (azimuth 120): 6 frames\n", + " sequence 39 detached from alert 24\n", + "== single-camera-fire-07: Real fire of 2025-09-29 seen from Serre-de-Barre, for the sdis-07 organization.\n", + " serre-de-barre-01 (azimuth 173): 10 frames\n" + ] + } + ], "source": [ - "BASE_DIRECTORY = \"../data\"\n", + "from scenarios import run_all\n", "\n", - "SAMPLE_PATH = \"alert_samples\"\n", - "url = \"https://github.com/pyronear/pyro-envdev/releases/download/v0.0.1/alert_samples.zip\"\n", - "\n", - "if not os.path.isdir(os.path.join(BASE_DIRECTORY, SAMPLE_PATH)):\n", - " print(\"Images not found, dowloading ...\")\n", - " #os.makedirs(SAMPLE_PATH, exist_ok=True)\n", - " dl_seqs_from_url(url, os.path.join(BASE_DIRECTORY, SAMPLE_PATH))\n", - "else:\n", - " print(f\"SAMPLE_PATH {SAMPLE_PATH} directory exists, assume alerts inside, no download\")\n" + "run_all()" ] }, { "cell_type": "markdown", + "id": "start-over-md", "metadata": {}, "source": [ - "## Send some random alerts sequences from a list of cameras\n", - "Download a file containing relevant images and predictions, then send alerts to selected cameras (with a random azimuth and random imgs picked from the alerts folder downloaded below)." + "## Start over\n", + "\n", + "Deletes **every alert** of your local environment, with its sequences, detections and images, including\n", + "alerts you created another way. Organizations, users and cameras are kept." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, + "id": "start-over", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Deleted 5 sequences and 46 detections.\n" + ] + } + ], "source": [ - "send_alert_from_cam_ids = [2, 4, 5, 14] # select cameras" + "from scenarios import delete_all_alerts\n", + "\n", + "delete_all_alerts()" ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", + "id": "cell-01", "metadata": {}, - "outputs": [], "source": [ - "SAMPLE_PATH = \"selection-true-positives\"\n", - "url = \"https://github.com/pyronear/pyro-envdev/releases/download/v0.0.1/selection-true-positives.zip\"\n", + "## Scenarios one by one\n", "\n", - "if not os.path.isdir(SAMPLE_PATH):\n", - " print(\"Images not found, dowloading ...\")\n", - " \n", - " os.makedirs(SAMPLE_PATH, exist_ok=True)\n", - " dl_seqs_from_url(url, SAMPLE_PATH)\n", - "else:\n", - " print(f\"SAMPLE_PATH {SAMPLE_PATH} directory exists, assume alerts inside, no download\")" + "Run this cell first, then any scenario below." ] }, { "cell_type": "code", "execution_count": null, + "id": "cell-02", "metadata": {}, "outputs": [], "source": [ - "send_alert_from_cam_ids = [2, 4, 5, 14] # select cameras\n", - "\n", - "sequances_folders = glob.glob(f\"{SAMPLE_PATH}/*\")\n", - "\n", - "for camera_id in send_alert_from_cam_ids:\n", - " \n", - " camera_token = get_camera_token(API_URL, camera_id, admin_access_token)\n", - " camera_client = Client(camera_token, API_URL)\n", - "\n", - " sequances_folder = sequances_folders[random.randint(0,len(sequances_folders)-1)]\n", - " imgs = glob.glob(f\"{sequances_folder}/images/*\")\n", - " imgs.sort()\n", - " preds = glob.glob(f\"{sequances_folder}/labels_predictions/*\")\n", - " preds.sort()\n", - "\n", - " cam_poses = camera_client.get_current_poses().json()\n", - " # pick a random pose id\n", - " pose_id = random.choice(cam_poses)['id']\n", - "\n", - " print(f\"Sending alerts from camera {camera_id} at (pose_id={pose_id})\")\n", - " for img_file, pred_file in zip(imgs, preds):\n", - " \n", - " stream = io.BytesIO()\n", - " im = Image.open(img_file)\n", - " im.save(stream, format=\"JPEG\", quality=80)\n", + "from scenarios import SCENARIOS, SINGLE, TRIANGULATED, run_scenario, send_sequences\n", "\n", - " bboxes = read_pred_file(pred_file)\n", - " response = camera_client.create_detection(stream.getvalue(), bboxes, pose_id=pose_id)\n", - " # Force a KeyError if the request failed\n", - " \n", - " response.json()[\"id\"]" + "for name, scenario in SCENARIOS.items():\n", + " print(f\"{name}: {scenario['description']}\")" ] }, { "cell_type": "markdown", + "id": "cell-03", "metadata": {}, "source": [ - "## Send custom alerts sequences\n", + "## 1. A fire seen by three towers (sdis-77)\n", "\n", - "The following code block helps you send some alerts/sequences from a specific diretory containing sequences data\n", + "Real fire of 2025-07-14, seen from Croix d'Augas, Nemours and Moret-sur-Loing.\n", "\n", - "- Create a directory with the name you want, ex ```alerts_with_style```\n", - "- In directory ```../data```or in current dir, there are some alerts folders downloaded\n", - "- Copy each inteterting alert dir (dir that contains a dir img, and a dir label predictions) and paste them to your ```alerts_with_style``` directory\n", - "- then below, fill the ```SEQUENCES_DIR_PATH``` with the path to you directory\n", - "- fill the CAM_MAPPING mapping dict : the first number in alert folder are dict keys that will map with camera id in database : ex {2:78} -> all alert folder starting with 2 will create alerts for the camera of id 78\n", - " - you can find cameras id in the object ```cameras``` (created at the beginning of this notebook), or look in the DB table cameras\n", - " - If you put alert dir with prefix number that does not match, no alerts will be created\n", - " " + "**What to look at:** a single alert, located on the map where the three cones cross.\n", + "*Change camera* switches between the three points of view." ] }, { "cell_type": "code", "execution_count": null, + "id": "cell-04", "metadata": {}, "outputs": [], "source": [ - "SEQUENCES_DIR_PATH = \"../data/alert_samples/single_sequences/*\"\n", - "sequences_directories = glob.glob(SEQUENCES_DIR_PATH)\n", - "CAM_MAPPING = {22: 13, 42: 7, 59: 5, 14: 11, 43: 8, 79: 14, 13: 10, 11: 15, 60: 2, 15: 12, 12: 9, 10: 16, 41: 2, 65: 8}\n", - "\n", - "for seq_path in sequences_directories:\n", - " \n", - " seq_prefix_num = int(seq_path.split(\"/\")[-1].split(\"_\")[0])\n", - "\n", - " if CAM_MAPPING.get(seq_prefix_num) is None:\n", - " print(f\" === WARNING the prefix {seq_prefix_num} is not in CAM_MAPPING dict, hence no alerts will be generated (or from random camera but no implemented yet)\")\n", - " else: \n", - " camera_id = CAM_MAPPING[seq_prefix_num]\n", - " camera_token = get_camera_token(API_URL, camera_id, admin_access_token)\n", - " camera_client = Client(camera_token, API_URL)\n", - " \n", - " imgs = glob.glob(f\"{seq_path}/images/*\")\n", - " imgs.sort()\n", - " preds = glob.glob(f\"{seq_path}/labels_predictions/*\")\n", - " preds.sort()\n", - "\n", - " cam_poses = camera_client.get_current_poses().json()\n", - " # pick a random pose id\n", - " pose_id = random.choice(cam_poses)['id']\n", - "\n", - " \n", - " print(f\"Sending alerts from camera {camera_id} at (pose_id={pose_id})\")\n", - " for img_file, pred_file in zip(imgs, preds):\n", - " \n", - " stream = io.BytesIO()\n", - " im = Image.open(img_file)\n", - " im.save(stream, format=\"JPEG\", quality=80)\n", - " \n", - " #bboxes = read_pred_file(pred_file)\n", - " with open(pred_file, 'r', encoding='utf-8') as file:\n", - " bboxes = ast.literal_eval(file.read())\n", - " #bboxes = np.loadtxt(pred_file, ndmin=2)\n", - " response = camera_client.create_detection(stream.getvalue(), bboxes, pose_id=pose_id)\n", - "\n", - " # Force a KeyError if the request failed\n", - " time.sleep(0.5)\n", - " response.json()[\"id\"]" + "run_scenario(\"triangulated-fire-77\")" ] }, { "cell_type": "markdown", + "id": "cell-05", "metadata": {}, "source": [ - "## Send 2 sequences that triangulate (user test07)\n", - "Download a file containning 2 alerts from 2 cameras that triangulate, then it send alerts to the api. " + "## 2. A fire seen by a single camera (sdis-77)\n", + "\n", + "Real fire of 2025-05-20, seen from Videlles.\n", + "\n", + "**What to look at:** a separate alert with a direction (one cone) but no location,\n", + "since a single camera cannot tell how far the smoke is." ] }, { "cell_type": "code", "execution_count": null, + "id": "cell-06", "metadata": {}, "outputs": [], "source": [ - "BASE_DIRECTORY = \"../data\"\n", - "SAMPLE_PATH = \"triangulated_sequences\"\n", - "url = \"https://github.com/pyronear/pyro-envdev/releases/download/v0.0.1/triangulated_sequences.zip\" \n", - "if not os.path.isdir(os.path.join(BASE_DIRECTORY, SAMPLE_PATH)):\n", - " print(\"Images not found, dowloading ...\")\n", - " dl_seqs_from_url(url, os.path.join(BASE_DIRECTORY, SAMPLE_PATH))\n", - "else:\n", - " print(f\"SAMPLE_PATH {SAMPLE_PATH} directory exists, assume alerts inside, no download\")\n", - "\n", - "\n", - "cam_triangulation = {\n", - " \"12\": {\n", - " \"azimuth\": 226,\n", - " \"path\": os.path.join(BASE_DIRECTORY, SAMPLE_PATH, \"brison-03\")\n", - " },\n", - " \"13\": {\n", - " \"azimuth\": 54,\n", - " \"path\": os.path.join(BASE_DIRECTORY, SAMPLE_PATH, \"serre-de-barre-01\")\n", - " }\n", - "}\n", - "\n", - "send_triangulated_alerts(cam_triangulation, API_URL, Client, admin_access_token)\n" + "run_scenario(\"single-camera-fire-77\")" ] }, { "cell_type": "markdown", + "id": "cell-07", "metadata": {}, "source": [ - "## Send 2 other sequences that triangulates (user test07)" + "## 3. A fire for the other organization (sdis-07)\n", + "\n", + "Real fire of 2025-09-29, seen from Serre-de-Barre (Ardèche). Log in with `test07` / `test` to see it:\n", + "each organization only sees its own cameras and alerts." ] }, { "cell_type": "code", "execution_count": null, + "id": "cell-08", "metadata": {}, "outputs": [], "source": [ - "cam_triangulation = {\n", - " \"11\": {\n", - " \"azimuth\": 226,\n", - " \"path\": \"../data/alert_samples/triangulated_sequences/14_brison-03_2025-09-29_13-58-36\"\n", - " },\n", - " \"13\": {\n", - " \"azimuth\": 173,\n", - " \"path\": \"../data/alert_samples/triangulated_sequences/22_serre-de-barre-01_2025-09-29_13-56-58\"\n", - " }\n", - "}\n", - "\n", - "\n", - "send_triangulated_alerts(cam_triangulation, API_URL, Client, admin_access_token)" + "run_scenario(\"single-camera-fire-07\")" ] }, { "cell_type": "markdown", + "id": "cell-09", "metadata": {}, "source": [ - "## Send 3 sequences that triangulates (user test77)" + "## Send your own sequence\n", + "\n", + "`send_sequences` replays any sequence folder as detections of a camera. Each shot is\n", + "`(camera name, pose azimuth, folder)`:\n", + "- **camera name**: see `data/csv/API_DATA_DEV - cameras.csv`;\n", + "- **pose azimuth**: direction of the center of the image, in degrees (0 = north, 90 = east);\n", + "- **folder**: contains an `images/` folder and either `labels_predictions/` (pyro-engine format,\n", + " `[(xmin, ymin, xmax, ymax, conf)]`) or `labels_ground_truth/` (YOLO format), one file per image.\n", + "\n", + "Use footage recorded by the camera you send it from, so that the image matches the map.\n", + "`standalone=True` keeps the fire out of alerts shared with other cameras.\n", + "\n", + "The example below replays another real fire seen from Nemours. As this tower also sees the fire of\n", + "scenario 1, it would join that alert if sent within 30 minutes, unless `standalone=True`." ] }, { "cell_type": "code", "execution_count": null, + "id": "cell-10", "metadata": {}, "outputs": [], "source": [ - "cam_triangulation = {\n", - " \"4\": {\n", - " \"azimuth\": 190,\n", - " \"path\": \"../data/alert_samples/triangulated_sequences/41_croix-augas-02_2025-07-14_11-24-31\"\n", - " },\n", - " \"8\": {\n", - " \"azimuth\": 25,\n", - " \"path\": \"../data/alert_samples/triangulated_sequences/65_nemours-02_2025-07-14_11-24-10\"\n", - " },\n", - " \"5\": {\n", - " \"azimuth\": 280,\n", - " \"path\": \"../data/alert_samples/triangulated_sequences/66_moret-sur-loing-01_2025-07-14_11-22-05\"\n", - " }\n", - "}\n", - "\n", - "send_triangulated_alerts(cam_triangulation, API_URL, Client, admin_access_token)\n" + "send_sequences(\n", + " [(\"nemours-02\", 340, SINGLE / \"2025-06-04T11-05-31_nemours-02-340_sequence-7638\")],\n", + " standalone=True,\n", + ")" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -367,9 +240,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.14" + "version": "3.11.17" } }, "nbformat": 4, - "nbformat_minor": 4 + "nbformat_minor": 5 }