From 265d07002b770f404079434e50297e7be5875654 Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 19:01:07 +0100 Subject: [PATCH 01/10] add external predictor --- pyro-predictor/pyproject.toml | 36 ++++ pyro-predictor/pyro_predictor/__init__.py | 9 + pyro-predictor/pyro_predictor/predictor.py | 210 +++++++++++++++++++ pyro-predictor/pyro_predictor/utils.py | 116 +++++++++++ pyro-predictor/pyro_predictor/vision.py | 232 +++++++++++++++++++++ 5 files changed, 603 insertions(+) create mode 100644 pyro-predictor/pyproject.toml create mode 100644 pyro-predictor/pyro_predictor/__init__.py create mode 100644 pyro-predictor/pyro_predictor/predictor.py create mode 100644 pyro-predictor/pyro_predictor/utils.py create mode 100644 pyro-predictor/pyro_predictor/vision.py diff --git a/pyro-predictor/pyproject.toml b/pyro-predictor/pyproject.toml new file mode 100644 index 00000000..383fc1e7 --- /dev/null +++ b/pyro-predictor/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=67.0.0", "wheel>=0.40.0"] +build-backend = "setuptools.build_meta" + +[tool.poetry] +name = "pyro_predictor" +version = "1.0.0" +description = "Standalone wildfire detection predictor (YOLO inference + temporal state)" +authors = ["Pyronear "] +readme = "README.md" +license = "Apache-2.0" +keywords = ["deep learning", "vision", "yolo", "wildfire"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Artificial Intelligence" +] + +[tool.poetry.dependencies] +python = ">=3.11,<4.0" +numpy = "*" +pillow = "==11.0.0" +opencv-python-headless = "*" +tqdm = "==4.67.1" +onnxruntime = "==1.22.1" +ncnn = "==1.0.20240410" + +[tool.poetry.urls] +repository = "https://github.com/pyronear/pyro-engine" +tracker = "https://github.com/pyronear/pyro-engine/issues" diff --git a/pyro-predictor/pyro_predictor/__init__.py b/pyro-predictor/pyro_predictor/__init__.py new file mode 100644 index 00000000..f69692cc --- /dev/null +++ b/pyro-predictor/pyro_predictor/__init__.py @@ -0,0 +1,9 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from .predictor import Predictor +from .vision import Classifier + +__all__ = ["Predictor", "Classifier"] diff --git a/pyro-predictor/pyro_predictor/predictor.py b/pyro-predictor/pyro_predictor/predictor.py new file mode 100644 index 00000000..40718bbc --- /dev/null +++ b/pyro-predictor/pyro_predictor/predictor.py @@ -0,0 +1,210 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +import logging +from collections import deque +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import numpy.typing as npt +from PIL import Image + +from .utils import box_iou, nms +from .vision import Classifier + +__all__ = ["Predictor"] + +logger = logging.getLogger(__name__) + + +class Predictor: + """Wildfire detection predictor: runs model inference and maintains per-camera sliding-window state. + + This class is self-contained and has no dependency on external services (no pyroclient, no HTTP calls). + It can be used standalone for offline inference or embedded in a larger system like Engine. + + Args: + model_path: path to an ONNX model file; if None, the default NCNN model is downloaded + conf_thresh: confidence threshold above which an alert is considered active + model_conf_thresh: per-frame confidence threshold passed to the YOLO model + max_bbox_size: discard detections wider than this fraction of the image + nb_consecutive_frames: sliding-window size for temporal smoothing + frame_size: if set, resize each frame to (H, W) before inference + cam_ids: list of camera IDs to pre-initialise state for + verbose: if False, suppress all informational log output (default True) + kwargs: forwarded to Classifier + + Examples: + >>> from pyro_predictor import Predictor + >>> predictor = Predictor() + >>> conf = predictor.predict(pil_image, cam_id="192.168.1.10_0") + """ + + def __init__( + self, + model_path: Optional[str] = None, + conf_thresh: float = 0.15, + model_conf_thresh: float = 0.05, + max_bbox_size: float = 0.4, + nb_consecutive_frames: int = 8, + frame_size: Optional[Tuple[int, int]] = None, + cam_ids: Optional[List[str]] = None, + verbose: bool = True, + **kwargs: Any, + ) -> None: + self.verbose = verbose + self.model = Classifier(model_path=model_path, conf=model_conf_thresh, max_bbox_size=max_bbox_size, verbose=verbose, **kwargs) + self.conf_thresh = conf_thresh + self.model_conf_thresh = model_conf_thresh + self.max_bbox_size = max_bbox_size + self.nb_consecutive_frames = nb_consecutive_frames + self.frame_size = frame_size + + self._states: Dict[str, Dict[str, Any]] = {"-1": self._new_state()} + if cam_ids: + for cam_id in cam_ids: + self._states[cam_id] = self._new_state() + + def _new_state(self) -> Dict[str, Any]: + return { + "last_predictions": deque(maxlen=self.nb_consecutive_frames), + "ongoing": False, + "anchor_bbox": None, + "anchor_ts": None, + "miss_count": 0, + } + + def _update_states(self, frame: Image.Image, preds: np.ndarray, cam_key: str) -> float: + prev_ongoing = self._states[cam_key]["ongoing"] + + conf_th = self.conf_thresh * self.nb_consecutive_frames + if prev_ongoing: + conf_th *= 0.8 + + boxes = np.zeros((0, 5), dtype=np.float64) + boxes = np.concatenate([boxes, preds]) + for _, box, _, _, _ in self._states[cam_key]["last_predictions"]: + if box.shape[0] > 0: + boxes = np.concatenate([boxes, box]) + + conf = 0.0 + output_predictions: npt.NDArray[np.float64] = np.zeros((0, 5), dtype=np.float64) + + if boxes.shape[0]: + best_boxes = nms(boxes) + detections = boxes[boxes[:, -1] > self.conf_thresh, :] + ious_detections = box_iou(best_boxes[:, :4], detections[:, :4]) + strong_detection = np.sum(ious_detections > 0, axis=0) >= int(self.nb_consecutive_frames / 2) + best_boxes = best_boxes[strong_detection, :] + + if best_boxes.shape[0]: + ious = box_iou(best_boxes[:, :4], boxes[:, :4]) + best_boxes_scores = np.array([sum(boxes[iou > 0, 4]) for iou in ious.T]) + combine_predictions = best_boxes[best_boxes_scores > conf_th, :] + if len(best_boxes_scores) > 0: + conf = np.max(best_boxes_scores) / (self.nb_consecutive_frames + 1) + + if combine_predictions.shape[0] > 0: + ious = box_iou(combine_predictions[:, :4], preds[:, :4]) + iou_match = np.array([np.max(iou) > 0 for iou in ious], dtype=bool) + matched_preds = preds[iou_match, :] + if matched_preds.ndim == 1: + matched_preds = matched_preds[np.newaxis, :] + output_predictions = matched_preds.astype(np.float64) + + # no zero confidence fabrication before ongoing + # if empty and we were already ongoing, reuse anchor but set conf to 0 + if output_predictions.shape[0] == 0: + anchor = self._states[cam_key]["anchor_bbox"] + if prev_ongoing and anchor is not None: + output_predictions = anchor.copy() + output_predictions[:, -1] = 0.0 # filled during ongoing, confidence forced to 0 + else: + output_predictions = np.empty((0, 5), dtype=np.float64) # stays empty for backfill later + else: + # refresh anchor during ongoing with light smoothing + if prev_ongoing: + best_idx = int(np.argmax(output_predictions[:, 4])) + best = output_predictions[best_idx : best_idx + 1] + anchor = self._states[cam_key]["anchor_bbox"] + if anchor is None: + self._states[cam_key]["anchor_bbox"] = best.copy() + else: + alpha = 0.3 + self._states[cam_key]["anchor_bbox"] = alpha * best + (1.0 - alpha) * anchor + self._states[cam_key]["miss_count"] = 0 + + output_predictions = np.round(output_predictions, 3) + output_predictions = output_predictions[:5, :] + if output_predictions.size > 0: + output_predictions = np.atleast_2d(output_predictions) + + self._states[cam_key]["last_predictions"].append(( + frame, + preds, + output_predictions.tolist(), # [] if empty + datetime.now(timezone.utc).isoformat(), + False, + )) + + new_ongoing = conf > self.conf_thresh + if prev_ongoing and not new_ongoing: + self._states[cam_key]["anchor_bbox"] = None + self._states[cam_key]["anchor_ts"] = None + self._states[cam_key]["miss_count"] = 0 + elif not prev_ongoing and new_ongoing: + if output_predictions.size > 0: + self._states[cam_key]["anchor_bbox"] = output_predictions.copy() + self._states[cam_key]["miss_count"] = 0 + + self._states[cam_key]["ongoing"] = new_ongoing + return conf + + def predict( + self, + frame: Image.Image, + cam_id: Optional[str] = None, + occlusion_bboxes: Optional[Dict[Any, Any]] = None, + fake_pred: Optional[np.ndarray] = None, + ) -> float: + """Run inference on a frame and return the aggregated wildfire confidence score. + + Args: + frame: input PIL image + cam_id: camera identifier; uses a default slot when None + occlusion_bboxes: dict of occlusion bounding boxes to suppress detections + fake_pred: bypass model inference with a pre-computed raw prediction array (for evaluation) + + Returns: + confidence score in [0, 1] + """ + cam_key = cam_id or "-1" + if cam_key not in self._states: + self._states[cam_key] = self._new_state() + + if isinstance(self.frame_size, tuple): + frame = frame.resize(self.frame_size[::-1], Image.BILINEAR) # type: ignore[attr-defined] + + if fake_pred is None: + preds = self.model(frame.convert("RGB"), occlusion_bboxes or {}) + else: + if fake_pred.size == 0: + preds = np.empty((0, 5)) + else: + preds = self.model.post_process(fake_pred, pad=(0, 0)) + preds = preds[(preds[:, 2] - preds[:, 0]) < self.max_bbox_size, :] + preds = np.reshape(preds, (-1, 5)) + + if self.verbose: + logger.info(f"pred for {cam_key} : {preds}") + conf = self._update_states(frame, preds, cam_key) + + if self.verbose: + device_str = f"Camera '{cam_id}' - " if isinstance(cam_id, str) else "" + pred_str = "Wildfire detected" if conf > self.conf_thresh else "No wildfire" + logger.info(f"{device_str}{pred_str} (confidence: {conf:.2%})") + + return float(conf) diff --git a/pyro-predictor/pyro_predictor/utils.py b/pyro-predictor/pyro_predictor/utils.py new file mode 100644 index 00000000..b1cf6ae3 --- /dev/null +++ b/pyro-predictor/pyro_predictor/utils.py @@ -0,0 +1,116 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + + +import cv2 +import numpy as np +from tqdm import tqdm + +__all__ = ["DownloadProgressBar", "letterbox", "nms", "xywh2xyxy"] + + +def xywh2xyxy(x: np.ndarray): + y = np.copy(x) + y[..., 0] = x[..., 0] - x[..., 2] / 2 # top left x + y[..., 1] = x[..., 1] - x[..., 3] / 2 # top left y + y[..., 2] = x[..., 0] + x[..., 2] / 2 # bottom right x + y[..., 3] = x[..., 1] + x[..., 3] / 2 # bottom right y + return y + + +def letterbox( + im: np.ndarray, + new_shape: tuple = (1024, 1024), + color: tuple = (114, 114, 114), + auto: bool = False, + stride: int = 32, +): + """Letterbox image transform for yolo models + Args: + im (np.ndarray): Input image + new_shape (tuple, optional): Image size. Defaults to (1024, 1024). + color (tuple, optional): Pixel fill value for the area outside the transformed image. + Defaults to (114, 114, 114). + auto (bool, optional): auto padding. Defaults to False. + stride (int, optional): padding stride. Defaults to 32. + Returns: + np.ndarray: Output image + """ + # Resize and pad image while meeting stride-multiple constraints + im = np.array(im) + shape = im.shape[:2] # current shape [height, width] + if isinstance(new_shape, int): + new_shape = (new_shape, new_shape) + # Scale ratio (new / old) + r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) + # Compute padding + new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r)) + dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] # wh padding + if auto: # minimum rectangle + dw, dh = np.mod(dw, stride), np.mod(dh, stride) # wh padding + dw /= 2 # divide padding into 2 sides + dh /= 2 + if shape[::-1] != new_unpad: # resize + im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR) + top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) + left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) + # add border + h, w = im.shape[:2] + im_b = np.zeros((h + top + bottom, w + left + right, 3)) + color + im_b[top : top + h, left : left + w, :] = im + return im_b.astype("uint8"), (left, top) + + +def box_iou(box1: np.ndarray, box2: np.ndarray, eps: float = 1e-7): + """ + Calculate intersection-over-union (IoU) of boxes. + Both sets of boxes are expected to be in (x1, y1, x2, y2) format. + Based on https://github.com/pytorch/vision/blob/master/torchvision/ops/boxes.py + + Args: + box1 (np.ndarray): A numpy array of shape (N, 4) representing N bounding boxes. + box2 (np.ndarray): A numpy array of shape (M, 4) representing M bounding boxes. + eps (float, optional): A small value to avoid division by zero. Defaults to 1e-7. + + Returns: + (np.ndarray): An NxM numpy array containing the pairwise IoU values for every element in box1 and box2. + """ + (a1, a2), (b1, b2) = np.split(box1, 2, 1), np.split(box2, 2, 1) + inter = (np.minimum(a2, b2[:, None, :]) - np.maximum(a1, b1[:, None, :])).clip(0).prod(2) + + # IoU = inter / (area1 + area2 - inter) + return inter / ((a2 - a1).prod(1) + (b2 - b1).prod(1)[:, None] - inter + eps) + + +def nms(boxes: np.ndarray, overlapThresh: int = 0): + """Non maximum suppression + + Args: + boxes (np.ndarray): A numpy array of shape (N, 4) representing N bounding boxes in (x1, y1, x2, y2, conf) format + overlapThresh (int, optional): iou threshold. Defaults to 0. + + Returns: + boxes: Boxes after NMS + """ + # Return an empty list, if no boxes given + boxes = boxes[boxes[:, -1].argsort()] + if len(boxes) == 0: + return [] + + indices = np.arange(len(boxes)) + rr = box_iou(boxes[:, :4], boxes[:, :4]) + for i, box in enumerate(boxes): + temp_indices = indices[indices != i] + if np.any(rr[i, temp_indices] > overlapThresh): + indices = indices[indices != i] + + return boxes[indices] + + +class DownloadProgressBar(tqdm): + def update_to(self, b=1, bsize=1, tsize=None): + if tsize is not None: + self.total = tsize + self.update(b * bsize - self.n) diff --git a/pyro-predictor/pyro_predictor/vision.py b/pyro-predictor/pyro_predictor/vision.py new file mode 100644 index 00000000..4b309580 --- /dev/null +++ b/pyro-predictor/pyro_predictor/vision.py @@ -0,0 +1,232 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +import logging +import os +import platform +import tarfile +from typing import Tuple +from urllib.request import urlretrieve + +import ncnn +import numpy as np +import onnxruntime +from PIL import Image + +from .utils import DownloadProgressBar, box_iou, letterbox, nms, xywh2xyxy + +__all__ = ["Classifier"] + +MODEL_URL_FOLDER = "https://huggingface.co/pyronear/yolo11s_mighty-mongoose_v5.1.0/resolve/main/" +MODEL_NAME = "ncnn_cpu_yolo11s_mighty-mongoose_v5.1.0.tar.gz" + +logger = logging.getLogger(__name__) + + +class Classifier: + """Implements an image classification model using YOLO backend. + + Examples: + >>> from pyro_predictor.vision import Classifier + >>> model = Classifier() + + Args: + model_path: model path + verbose: if False, suppress all informational log output + """ + + def __init__( + self, + model_folder="data", + imgsz=1024, + conf=0.15, + iou=0, + format="ncnn", + model_path=None, + max_bbox_size=0.4, + verbose=True, + ) -> None: + self.verbose = verbose + if model_path: + if not os.path.isfile(model_path): + raise ValueError(f"Model file not found: {model_path}") + if os.path.splitext(model_path)[-1].lower() != ".onnx": + raise ValueError(f"Input model_path should point to an ONNX export but currently is {model_path}") + self.format = "onnx" + else: + if format == "ncnn": + if not self.is_arm_architecture(): + if self.verbose: + logger.info("NCNN format is optimized for arm architecture only, switching to onnx is recommended") + model = MODEL_NAME + self.format = "ncnn" + elif format == "onnx": + model = MODEL_NAME.replace("ncnn", "onnx") + self.format = "onnx" + else: + raise ValueError("Unsupported format: should be 'ncnn' or 'onnx'") + + model_path = os.path.join(model_folder, model) + model_url = MODEL_URL_FOLDER + model + + if not os.path.isfile(model_path): + if self.verbose: + logger.info(f"Downloading model from {model_url} ...") + os.makedirs(model_folder, exist_ok=True) + with DownloadProgressBar(unit="B", unit_scale=True, miniters=1, desc=model_path, disable=not self.verbose) as t: + urlretrieve(model_url, model_path, reporthook=t.update_to) + if self.verbose: + logger.info("Model downloaded!") + + # Extract .tar.gz archive + if model_path.endswith(".tar.gz"): + base_name = os.path.basename(model_path).replace(".tar.gz", "") + extract_path = os.path.join(model_folder, base_name) + if not os.path.isdir(extract_path): + with tarfile.open(model_path, "r:gz") as tar: + tar.extractall(model_folder) + if self.verbose: + logger.info(f"Extracted model to: {extract_path}") + model_path = extract_path + + if self.format == "ncnn": + self.model = ncnn.Net() + self.model.load_param(os.path.join(model_path, "best_ncnn_model", "model.ncnn.param")) + self.model.load_model(os.path.join(model_path, "best_ncnn_model", "model.ncnn.bin")) + + else: + try: + onnx_file = model_path if model_path.endswith(".onnx") else os.path.join(model_path, "best.onnx") + available_providers = onnxruntime.get_available_providers() + if "CUDAExecutionProvider" in available_providers: + providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] + if self.verbose: + logger.info("CUDA is available — using CUDAExecutionProvider for ONNX inference") + elif "CoreMLExecutionProvider" in available_providers: + providers = ["CoreMLExecutionProvider", "CPUExecutionProvider"] + if self.verbose: + logger.info("CoreML (MPS) is available — using CoreMLExecutionProvider for ONNX inference") + else: + providers = ["CPUExecutionProvider"] + if self.verbose: + logger.info("No GPU provider available — using CPUExecutionProvider for ONNX inference") + self.ort_session = onnxruntime.InferenceSession(onnx_file, providers=providers) + + except Exception as e: + raise RuntimeError(f"Failed to load the ONNX model from {model_path}: {e!s}") from e + + if self.verbose: + logger.info(f"ONNX model loaded successfully from {model_path}") + + self.imgsz = imgsz + self.conf = conf + self.iou = iou + self.max_bbox_size = max_bbox_size + + def is_arm_architecture(self): + # Check for ARM architecture + return platform.machine().startswith("arm") or platform.machine().startswith("aarch") + + def prep_process(self, pil_img: Image.Image) -> Tuple[np.ndarray, Tuple[int, int]]: + """Preprocess an image for inference + + Args: + pil_img: A valid PIL image. + + Returns: + A tuple containing: + - The resized and normalized image of shape (1, C, H, W). + - Padding information as a tuple of integers (pad_height, pad_width). + """ + np_img, pad = letterbox(np.array(pil_img), self.imgsz) # Applies letterbox resize with padding + + if self.format == "ncnn": + np_img = ncnn.Mat.from_pixels(np_img, ncnn.Mat.PixelType.PIXEL_BGR, np_img.shape[1], np_img.shape[0]) + mean = [0, 0, 0] + std = [1 / 255, 1 / 255, 1 / 255] + np_img.substract_mean_normalize(mean=mean, norm=std) + else: + np_img = np.expand_dims(np_img.astype("float32"), axis=0) # Add batch dimension + np_img = np.ascontiguousarray(np_img.transpose((0, 3, 1, 2))) # Convert from BHWC to BCHW format + np_img /= 255.0 # Normalize to [0, 1] + + return np_img, pad + + def post_process(self, pred: np.ndarray, pad: Tuple[int, int]) -> np.ndarray: + """Post-process model predictions. + + Args: + pred: Raw predictions from the model. + pad: Padding information as (left_pad, top_pad). + + Returns: + Processed predictions as a numpy array. + """ + pred = pred[:, pred[-1, :] > self.conf] # Drop low-confidence predictions + pred = np.transpose(pred) + pred = xywh2xyxy(pred) + pred = pred[pred[:, 4].argsort()] # Sort by confidence + pred = nms(pred) + pred = pred[::-1] # Reverse for highest confidence first + + if len(pred) > 0: + left_pad, top_pad = pad # Unpack the tuple + pred[:, :4:2] -= left_pad + pred[:, 1:4:2] -= top_pad + pred[:, :4:2] /= self.imgsz - 2 * left_pad + pred[:, 1:4:2] /= self.imgsz - 2 * top_pad + pred = np.clip(pred, 0, 1) + else: + pred = np.zeros((0, 5)) # Return empty prediction array + + return pred + + def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict = {}) -> np.ndarray: + """Run the classifier on an input image. + + Args: + pil_img: The input PIL image. + occlusion_mask: Optional occlusion mask to exclude certain areas. + + Returns: + Processed predictions. + """ + np_img, pad = self.prep_process(pil_img) + + if self.format == "ncnn": + extractor = self.model.create_extractor() + extractor.set_light_mode(True) + extractor.input("in0", np_img) + pred = ncnn.Mat() + extractor.extract("out0", pred) + pred = np.asarray(pred) + else: + pred = self.ort_session.run(["output0"], {"images": np_img})[0][0] + + # Convert pad to a tuple if required + if isinstance(pad, list): + pad = tuple(pad) + + pred = self.post_process(pred, pad) # Ensure pad is passed as a tuple + + # drop big detections + pred = np.clip(pred, 0, 1) + pred = pred[(pred[:, 2] - pred[:, 0]) < self.max_bbox_size, :] + pred = np.reshape(pred, (-1, 5)) + + if self.verbose: + logger.info(f"Model original pred : {pred}") + + # Remove prediction in bbox occlusion mask + if len(occlusion_bboxes): + all_boxes = np.array([b[:4] for b in occlusion_bboxes.values()], dtype=pred.dtype) + + pred_boxes = pred[:, :4].astype(pred.dtype) + ious = box_iou(pred_boxes, all_boxes) + max_ious = ious.max(axis=0) + keep = max_ious <= 0.1 + pred = pred[keep] + + return pred From 0069c0a06a695aacbdc6ab99ae7e07b7e58fc991 Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 19:01:46 +0100 Subject: [PATCH 02/10] adapt engine --- pyroengine/__init__.py | 20 +++- pyroengine/engine.py | 164 +++++++------------------------ pyroengine/utils.py | 111 +-------------------- pyroengine/vision.py | 214 +---------------------------------------- 4 files changed, 59 insertions(+), 450 deletions(-) diff --git a/pyroengine/__init__.py b/pyroengine/__init__.py index 9a95a2dd..90889ba8 100644 --- a/pyroengine/__init__.py +++ b/pyroengine/__init__.py @@ -1,3 +1,19 @@ -from .core import * -from . import engine, utils +from pyro_predictor import Predictor from .version import __version__ + +# Lazy imports: core (SystemController, is_day_time) and engine require +# requests/pyroclient which are optional if only Predictor is used. +def __getattr__(name: str): + if name in ("SystemController", "is_day_time"): + from .core import SystemController, is_day_time + + globals()["SystemController"] = SystemController + globals()["is_day_time"] = is_day_time + return globals()[name] + if name in ("engine", "core", "utils", "predictor"): + import importlib + + mod = importlib.import_module(f".{name}", __name__) + globals()[name] = mod + return mod + raise AttributeError(f"module 'pyroengine' has no attribute {name!r}") diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 55289d3f..08ce98c0 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -10,21 +10,17 @@ import signal import time from collections import deque -from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Never, Optional, Tuple import numpy as np -import numpy.typing as npt import requests from PIL import Image from pyroclient import client from requests.exceptions import ConnectionError from requests.models import Response -from pyroengine.utils import box_iou, nms - -from .vision import Classifier +from pyro_predictor import Predictor __all__ = ["Engine"] @@ -49,14 +45,16 @@ def heartbeat_with_timeout(api_instance, cam_id, timeout=1) -> None: signal.alarm(0) -class Engine: - """This implements an object to manage predictions and API interactions for wildfire alerts. +class Engine(Predictor): + """Manages predictions and API interactions for wildfire alerts. + + Extends Predictor with pyroclient API integration: heartbeats, image uploads, alert staging and caching. Args: hub_repo: repository on HF Hub to load the ONNX model from conf_thresh: confidence threshold to send an alert api_url: url of the pyronear API - cam_creds: api credectials for each camera, the dictionary should be as the one in the example + cam_creds: api credentials for each camera, the dictionary should be as the one in the example alert_relaxation: number of consecutive positive detections required to send the first alert, and also the number of consecutive negative detections before stopping the alert frame_size: Resize frame to frame_size before sending it to the api in order to save bandwidth (H, W) @@ -100,13 +98,17 @@ def __init__( last_bbox_mask_fetch_period: int = 3600, # 1H **kwargs: Any, ) -> None: - """Init engine""" - # Engine Setup - - self.model = Classifier(model_path=model_path, conf=model_conf_thresh, max_bbox_size=max_bbox_size) - self.conf_thresh = conf_thresh - self.model_conf_thresh = model_conf_thresh - self.max_bbox_size = max_bbox_size + cam_ids = list(cam_creds.keys()) if isinstance(cam_creds, dict) else None + super().__init__( + model_path=model_path, + conf_thresh=conf_thresh, + model_conf_thresh=model_conf_thresh, + max_bbox_size=max_bbox_size, + nb_consecutive_frames=nb_consecutive_frames, + frame_size=frame_size, + cam_ids=cam_ids, + **kwargs, + ) # API Setup self.api_client: dict[str, Any] = {} @@ -119,8 +121,6 @@ def __init__( # Cache & relaxation self.frame_saving_period = frame_saving_period - self.nb_consecutive_frames = nb_consecutive_frames - self.frame_size = frame_size self.jpeg_quality = jpeg_quality self.cache_backup_period = cache_backup_period self.day_time_strategy = day_time_strategy @@ -133,30 +133,12 @@ def __init__( # Local backup self._backup_size = backup_size - # Var initialization - self._states: Dict[str, Dict[str, Any]] = { - "-1": { - "last_predictions": deque(maxlen=self.nb_consecutive_frames), - "ongoing": False, - "last_image_sent": None, - "last_bbox_mask_fetch": None, - "anchor_bbox": None, - "anchor_ts": None, - "miss_count": 0, - }, - } - if isinstance(cam_creds, dict): - for cam_id in cam_creds: - self._states[cam_id] = { - "last_predictions": deque(maxlen=self.nb_consecutive_frames), - "ongoing": False, - "last_image_sent": None, - "last_bbox_mask_fetch": None, - "anchor_bbox": None, - "anchor_ts": None, - "miss_count": 0, - } + # Augment states with API-specific fields + for state in self._states.values(): + state["last_image_sent"] = None + state["last_bbox_mask_fetch"] = None + # Occlusion masks self.occlusion_masks: Dict[str, Tuple[Optional[str], Dict[Any, Any], int]] = {"-1": (None, {}, 0)} if isinstance(cam_creds, dict): for cam_id, (_, azimuth, bbox_mask_url) in cam_creds.items(): @@ -167,105 +149,30 @@ def __init__( self._cache = Path(cache_folder) # with Docker, the path has to be a bind volume assert self._cache.is_dir() + def _new_state(self) -> Dict[str, Any]: + state = super()._new_state() + state["last_image_sent"] = None + state["last_bbox_mask_fetch"] = None + return state + def heartbeat(self, cam_id: str) -> Response: """Updates last ping of device""" ip = cam_id.split("_")[0] return self.api_client[ip].heartbeat() - def _update_states(self, frame: Image.Image, preds: np.ndarray, cam_key: str) -> float: - prev_ongoing = self._states[cam_key]["ongoing"] - - conf_th = self.conf_thresh * self.nb_consecutive_frames - if prev_ongoing: - conf_th *= 0.8 - - boxes = np.zeros((0, 5), dtype=np.float64) - boxes = np.concatenate([boxes, preds]) - for _, box, _, _, _ in self._states[cam_key]["last_predictions"]: - if box.shape[0] > 0: - boxes = np.concatenate([boxes, box]) - - conf = 0.0 - output_predictions: npt.NDArray[np.float64] = np.zeros((0, 5), dtype=np.float64) - - if boxes.shape[0]: - best_boxes = nms(boxes) - detections = boxes[boxes[:, -1] > self.conf_thresh, :] - ious_detections = box_iou(best_boxes[:, :4], detections[:, :4]) - strong_detection = np.sum(ious_detections > 0, axis=0) >= int(self.nb_consecutive_frames / 2) - best_boxes = best_boxes[strong_detection, :] - - if best_boxes.shape[0]: - ious = box_iou(best_boxes[:, :4], boxes[:, :4]) - best_boxes_scores = np.array([sum(boxes[iou > 0, 4]) for iou in ious.T]) - combine_predictions = best_boxes[best_boxes_scores > conf_th, :] - if len(best_boxes_scores) > 0: - conf = np.max(best_boxes_scores) / (self.nb_consecutive_frames + 1) - - if combine_predictions.shape[0] > 0: - ious = box_iou(combine_predictions[:, :4], preds[:, :4]) - iou_match = np.array([np.max(iou) > 0 for iou in ious], dtype=bool) - matched_preds = preds[iou_match, :] - if matched_preds.ndim == 1: - matched_preds = matched_preds[np.newaxis, :] - output_predictions = matched_preds.astype(np.float64) - - # no zero confidence fabrication before ongoing - # if empty and we were already ongoing, reuse anchor but set conf to 0 - if output_predictions.shape[0] == 0: - anchor = self._states[cam_key]["anchor_bbox"] - if prev_ongoing and anchor is not None: - output_predictions = anchor.copy() - output_predictions[:, -1] = 0.0 # filled during ongoing, confidence forced to 0 - else: - output_predictions = np.empty((0, 5), dtype=np.float64) # stays empty for backfill later - else: - # refresh anchor during ongoing with light smoothing - if prev_ongoing: - best_idx = int(np.argmax(output_predictions[:, 4])) - best = output_predictions[best_idx : best_idx + 1] - anchor = self._states[cam_key]["anchor_bbox"] - if anchor is None: - self._states[cam_key]["anchor_bbox"] = best.copy() - else: - alpha = 0.3 - self._states[cam_key]["anchor_bbox"] = alpha * best + (1.0 - alpha) * anchor - self._states[cam_key]["miss_count"] = 0 - - output_predictions = np.round(output_predictions, 3) - output_predictions = output_predictions[:5, :] - if output_predictions.size > 0: - output_predictions = np.atleast_2d(output_predictions) - - self._states[cam_key]["last_predictions"].append(( - frame, - preds, - output_predictions.tolist(), # [] if empty - datetime.now(timezone.utc).isoformat(), - False, - )) - - new_ongoing = conf > self.conf_thresh - if prev_ongoing and not new_ongoing: - self._states[cam_key]["anchor_bbox"] = None - self._states[cam_key]["anchor_ts"] = None - self._states[cam_key]["miss_count"] = 0 - elif not prev_ongoing and new_ongoing: - if output_predictions.size > 0: - self._states[cam_key]["anchor_bbox"] = output_predictions.copy() - self._states[cam_key]["miss_count"] = 0 - - self._states[cam_key]["ongoing"] = new_ongoing - return conf - def predict( - self, frame: Image.Image, cam_id: Optional[str] = None, fake_pred: Optional[np.ndarray] = None + self, + frame: Image.Image, + cam_id: Optional[str] = None, + occlusion_bboxes: Optional[Dict[Any, Any]] = None, + fake_pred: Optional[np.ndarray] = None, ) -> float: """Computes the confidence that the image contains wildfire cues Args: frame: a PIL image cam_id: the name of the camera that sent this image + occlusion_bboxes: ignored — Engine manages occlusion masks internally via URL fetch fake_pred: replace model prediction by another one for evaluation purposes, need to be given in onnx format: fake_pred = [[x1, x2] [y1, y2] @@ -276,6 +183,9 @@ def predict( the predicted confidence """ cam_key = cam_id or "-1" + if cam_key not in self._states: + self._states[cam_key] = self._new_state() + # Reduce image size to save bandwidth if isinstance(self.frame_size, tuple): frame = frame.resize(self.frame_size[::-1], Image.BILINEAR) # type: ignore[attr-defined] diff --git a/pyroengine/utils.py b/pyroengine/utils.py index 24125341..ceb31a21 100644 --- a/pyroengine/utils.py +++ b/pyroengine/utils.py @@ -3,114 +3,7 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. - -import cv2 -import numpy as np -from tqdm import tqdm +# Re-export from pyro_predictor for backwards compatibility. +from pyro_predictor.utils import DownloadProgressBar, box_iou, letterbox, nms, xywh2xyxy __all__ = ["DownloadProgressBar", "letterbox", "nms", "xywh2xyxy"] - - -def xywh2xyxy(x: np.ndarray): - y = np.copy(x) - y[..., 0] = x[..., 0] - x[..., 2] / 2 # top left x - y[..., 1] = x[..., 1] - x[..., 3] / 2 # top left y - y[..., 2] = x[..., 0] + x[..., 2] / 2 # bottom right x - y[..., 3] = x[..., 1] + x[..., 3] / 2 # bottom right y - return y - - -def letterbox( - im: np.ndarray, - new_shape: tuple = (1024, 1024), - color: tuple = (114, 114, 114), - auto: bool = False, - stride: int = 32, -): - """Letterbox image transform for yolo models - Args: - im (np.ndarray): Input image - new_shape (tuple, optional): Image size. Defaults to (1024, 1024). - color (tuple, optional): Pixel fill value for the area outside the transformed image. - Defaults to (114, 114, 114). - auto (bool, optional): auto padding. Defaults to False. - stride (int, optional): padding stride. Defaults to 32. - Returns: - np.ndarray: Output image - """ - # Resize and pad image while meeting stride-multiple constraints - im = np.array(im) - shape = im.shape[:2] # current shape [height, width] - if isinstance(new_shape, int): - new_shape = (new_shape, new_shape) - # Scale ratio (new / old) - r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) - # Compute padding - new_unpad = round(shape[1] * r), round(shape[0] * r) - dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] # wh padding - if auto: # minimum rectangle - dw, dh = np.mod(dw, stride), np.mod(dh, stride) # wh padding - dw /= 2 # divide padding into 2 sides - dh /= 2 - if shape[::-1] != new_unpad: # resize - im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR) - top, bottom = round(dh - 0.1), round(dh + 0.1) - left, right = round(dw - 0.1), round(dw + 0.1) - # add border - h, w = im.shape[:2] - im_b = np.zeros((h + top + bottom, w + left + right, 3)) + color - im_b[top : top + h, left : left + w, :] = im - return im_b.astype("uint8"), (left, top) - - -def box_iou(box1: np.ndarray, box2: np.ndarray, eps: float = 1e-7): - """ - Calculate intersection-over-union (IoU) of boxes. - Both sets of boxes are expected to be in (x1, y1, x2, y2) format. - Based on https://github.com/pytorch/vision/blob/master/torchvision/ops/boxes.py - - Args: - box1 (np.ndarray): A numpy array of shape (N, 4) representing N bounding boxes. - box2 (np.ndarray): A numpy array of shape (M, 4) representing M bounding boxes. - eps (float, optional): A small value to avoid division by zero. Defaults to 1e-7. - - Returns: - (np.ndarray): An NxM numpy array containing the pairwise IoU values for every element in box1 and box2. - """ - (a1, a2), (b1, b2) = np.split(box1, 2, 1), np.split(box2, 2, 1) - inter = (np.minimum(a2, b2[:, None, :]) - np.maximum(a1, b1[:, None, :])).clip(0).prod(2) - - # IoU = inter / (area1 + area2 - inter) - return inter / ((a2 - a1).prod(1) + (b2 - b1).prod(1)[:, None] - inter + eps) - - -def nms(boxes: np.ndarray, overlap_thresh: int = 0): - """Non maximum suppression - - Args: - boxes (np.ndarray): A numpy array of shape (N, 4) representing N bounding boxes in (x1, y1, x2, y2, conf) format - overlapThresh (int, optional): iou threshold. Defaults to 0. - - Returns: - boxes: Boxes after NMS - """ - # Return an empty list, if no boxes given - boxes = boxes[boxes[:, -1].argsort()] - if len(boxes) == 0: - return [] - - indices = np.arange(len(boxes)) - rr = box_iou(boxes[:, :4], boxes[:, :4]) - for i, _box in enumerate(boxes): - temp_indices = indices[indices != i] - if np.any(rr[i, temp_indices] > overlap_thresh): - indices = indices[indices != i] - - return boxes[indices] - - -class DownloadProgressBar(tqdm): - def update_to(self, b=1, bsize=1, tsize=None) -> None: - if tsize is not None: - self.total = tsize - self.update(b * bsize - self.n) diff --git a/pyroengine/vision.py b/pyroengine/vision.py index 366f85bd..5133484b 100644 --- a/pyroengine/vision.py +++ b/pyroengine/vision.py @@ -3,217 +3,7 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. -import logging -import pathlib -import platform -import tarfile -from typing import Tuple - -import ncnn -import numpy as np -import onnxruntime -from huggingface_hub import hf_hub_download -from PIL import Image - -from .utils import box_iou, letterbox, nms, xywh2xyxy +# Re-export from pyro_predictor for backwards compatibility. +from pyro_predictor.vision import Classifier __all__ = ["Classifier"] - -MODEL_REPO_ID = "pyronear/yolo11s_nimble-narwhal_v6.0.0" -MODEL_NAME = "ncnn_cpu.tar.gz" - -logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO, force=True) -logger = logging.getLogger(__name__) - - -class Classifier: - """Implements an image classification model using YOLO backend. - - Examples: - >>> from pyroengine.vision import Classifier - >>> model = Classifier() - - Args: - model_path: model path - """ - - def __init__( - self, - model_folder="data", - imgsz=1024, - conf=0.15, - iou=0, - format="ncnn", - model_path=None, - max_bbox_size=0.4, - ) -> None: - if model_path: - if not pathlib.Path(model_path).is_file(): - raise ValueError(f"Model file not found: {model_path}") - if pathlib.Path(model_path).suffix.lower() != ".onnx": - raise ValueError(f"Input model_path should point to an ONNX export but currently is {model_path}") - self.format = "onnx" - else: - if format == "ncnn": - if not self.is_arm_architecture(): - logger.info("NCNN format is optimized for arm architecture only, switching to onnx is recommended") - model = MODEL_NAME - self.format = "ncnn" - elif format == "onnx": - model = MODEL_NAME.replace("ncnn", "onnx") - self.format = "onnx" - else: - raise ValueError("Unsupported format: should be 'ncnn' or 'onnx'") - - model_path = str(pathlib.Path(model_folder) / model) - - if not pathlib.Path(model_path).is_file(): - logger.info(f"Downloading model from {MODEL_REPO_ID}/{model} ...") - pathlib.Path(model_folder).mkdir(exist_ok=True, parents=True) - hf_hub_download(repo_id=MODEL_REPO_ID, filename=model, local_dir=model_folder) - logger.info("Model downloaded!") - - # Extract archive - if model_path.endswith(".tar.gz"): - base_name = pathlib.Path(model_path).name.replace(".tar.gz", "") - extract_path = str(pathlib.Path(model_folder) / base_name) - if not pathlib.Path(extract_path).is_dir(): - pathlib.Path(extract_path).mkdir(parents=True, exist_ok=True) - with tarfile.open(model_path, "r:gz") as tar: - tar.extractall(extract_path) - logger.info(f"Extracted model to: {extract_path}") - model_path = extract_path - - if self.format == "ncnn": - self.model = ncnn.Net() - self.model.load_param(str(pathlib.Path(model_path) / "best_ncnn_model" / "model.ncnn.param")) - self.model.load_model(str(pathlib.Path(model_path) / "best_ncnn_model" / "model.ncnn.bin")) - - else: - try: - onnx_file = model_path if model_path.endswith(".onnx") else str(pathlib.Path(model_path) / "best.onnx") - available_providers = onnxruntime.get_available_providers() - if "CUDAExecutionProvider" in available_providers: - providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] - logger.info("CUDA is available — using CUDAExecutionProvider for ONNX inference") - else: - providers = ["CPUExecutionProvider"] - logger.info("Using CPUExecutionProvider for ONNX inference") - self.ort_session = onnxruntime.InferenceSession(onnx_file, providers=providers) - - except Exception as e: - raise RuntimeError(f"Failed to load the ONNX model from {model_path}: {e!s}") from e - - logger.info(f"ONNX model loaded successfully from {model_path}") - - self.imgsz = imgsz - self.conf = conf - self.iou = iou - self.max_bbox_size = max_bbox_size - - def is_arm_architecture(self): - # Check for ARM architecture - return platform.machine().startswith("arm") or platform.machine().startswith("aarch") - - def prep_process(self, pil_img: Image.Image) -> Tuple[np.ndarray, Tuple[int, int]]: - """Preprocess an image for inference - - Args: - pil_img: A valid PIL image. - - Returns: - A tuple containing: - - The resized and normalized image of shape (1, C, H, W). - - Padding information as a tuple of integers (pad_height, pad_width). - """ - np_img, pad = letterbox(np.array(pil_img), self.imgsz) # Applies letterbox resize with padding - - if self.format == "ncnn": - np_img = ncnn.Mat.from_pixels(np_img, ncnn.Mat.PixelType.PIXEL_BGR, np_img.shape[1], np_img.shape[0]) - mean = [0, 0, 0] - std = [1 / 255, 1 / 255, 1 / 255] - np_img.substract_mean_normalize(mean=mean, norm=std) - else: - np_img = np.expand_dims(np_img.astype("float32"), axis=0) # Add batch dimension - np_img = np.ascontiguousarray(np_img.transpose((0, 3, 1, 2))) # Convert from BHWC to BCHW format - np_img /= 255.0 # Normalize to [0, 1] - - return np_img, pad - - def post_process(self, pred: np.ndarray, pad: Tuple[int, int]) -> np.ndarray: - """Post-process model predictions. - - Args: - pred: Raw predictions from the model. - pad: Padding information as (left_pad, top_pad). - - Returns: - Processed predictions as a numpy array. - """ - pred = pred[:, pred[-1, :] > self.conf] # Drop low-confidence predictions - pred = np.transpose(pred) - pred = xywh2xyxy(pred) - pred = pred[pred[:, 4].argsort()] # Sort by confidence - pred = nms(pred) - pred = pred[::-1] # Reverse for highest confidence first - - if len(pred) > 0: - left_pad, top_pad = pad # Unpack the tuple - pred[:, :4:2] -= left_pad - pred[:, 1:4:2] -= top_pad - pred[:, :4:2] /= self.imgsz - 2 * left_pad - pred[:, 1:4:2] /= self.imgsz - 2 * top_pad - pred = np.clip(pred, 0, 1) - else: - pred = np.zeros((0, 5)) # Return empty prediction array - - return pred - - def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict | None = None) -> np.ndarray: - """Run the classifier on an input image. - - Args: - pil_img: The input PIL image. - occlusion_mask: Optional occlusion mask to exclude certain areas. - - Returns: - Processed predictions. - """ - if occlusion_bboxes is None: - occlusion_bboxes = {} - np_img, pad = self.prep_process(pil_img) - - if self.format == "ncnn": - extractor = self.model.create_extractor() - extractor.set_light_mode(True) - extractor.input("in0", np_img) - pred = ncnn.Mat() - extractor.extract("out0", pred) - pred = np.asarray(pred) - else: - pred = self.ort_session.run(["output0"], {"images": np_img})[0][0] - - # Convert pad to a tuple if required - if isinstance(pad, list): - pad = tuple(pad) - - pred = self.post_process(pred, pad) # Ensure pad is passed as a tuple - - # drop big detections - pred = np.clip(pred, 0, 1) - pred = pred[(pred[:, 2] - pred[:, 0]) < self.max_bbox_size, :] - pred = np.reshape(pred, (-1, 5)) - - logger.info(f"Model original pred : {pred}") - - # Remove prediction in bbox occlusion mask - if len(occlusion_bboxes): - all_boxes = np.array([b[:4] for b in occlusion_bboxes.values()], dtype=pred.dtype) - - pred_boxes = pred[:, :4].astype(pred.dtype) - ious = box_iou(pred_boxes, all_boxes) - max_ious = ious.max(axis=0) - keep = max_ious <= 0.1 - pred = pred[keep] - - return pred From 112524e027d12db4c2ce35ff46e790fa3edc4d7a Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 19:01:54 +0100 Subject: [PATCH 03/10] adapt ci --- .github/workflows/builds.yml | 3 +++ .github/workflows/tests.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index e8cc45c3..0fbcd885 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -33,6 +33,9 @@ jobs: - name: Install dependencies run: pip install --no-cache-dir -r requirements.txt + - name: Install pyro-predictor + run: pip install -e pyro-predictor/ + - name: Install local package run: pip install -e . --upgrade diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a0da920e..0e13c1f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -127,5 +127,5 @@ jobs: license: 'Apache-2.0' owner: 'Pyronear' starting-year: 2022 - folders: 'pyroengine,docs,scripts,.github,src,pyro_camera_api' + folders: 'pyroengine,docs,scripts,.github,src,pyro_camera_api,pyro-predictor/pyro_predictor' ignore-files: 'version.py,__init__.py' From 16a96e9141b4851766c3ded4325cebad90c73eba Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 19:02:01 +0100 Subject: [PATCH 04/10] adapt test --- tests/test_predictor.py | 82 +++++++++++++++++++++++++++++++++++++++++ tests/test_vision.py | 11 +++++- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/test_predictor.py diff --git a/tests/test_predictor.py b/tests/test_predictor.py new file mode 100644 index 00000000..4590be79 --- /dev/null +++ b/tests/test_predictor.py @@ -0,0 +1,82 @@ +import logging + +import numpy as np +import pytest +from PIL import Image + +from pyro_predictor import Classifier, Predictor + + +def test_predictor_direct_import(): + """Predictor and Classifier are importable directly from pyro_predictor.""" + assert Predictor is not None + assert Classifier is not None + + +def test_predictor_offline(tmpdir_factory, mock_wildfire_image, mock_forest_image): + folder = str(tmpdir_factory.mktemp("predictor_cache")) + predictor = Predictor(nb_consecutive_frames=4, verbose=False) + + out = predictor.predict(mock_forest_image) + assert isinstance(out, float) and 0 <= out <= 1 + assert len(predictor._states["-1"]["last_predictions"]) == 1 + assert predictor._states["-1"]["ongoing"] is False + + out = predictor.predict(mock_wildfire_image) + assert isinstance(out, float) and 0 <= out <= 1 + assert len(predictor._states["-1"]["last_predictions"]) == 2 + + out = predictor.predict(mock_wildfire_image) + assert isinstance(out, float) and 0 <= out <= 1 + assert predictor._states["-1"]["ongoing"] == True + + +def test_predictor_per_camera_state(mock_wildfire_image, mock_forest_image): + """Each cam_id maintains independent state.""" + predictor = Predictor(nb_consecutive_frames=4, verbose=False) + + predictor.predict(mock_wildfire_image, cam_id="cam_a") + predictor.predict(mock_forest_image, cam_id="cam_b") + + assert len(predictor._states["cam_a"]["last_predictions"]) == 1 + assert len(predictor._states["cam_b"]["last_predictions"]) == 1 + # cam_a saw wildfire, cam_b saw forest — states are independent + assert predictor._states["cam_a"]["last_predictions"][0][1].shape[0] > 0 + assert predictor._states["cam_b"]["last_predictions"][0][1].shape[0] == 0 + + +def test_predictor_fake_pred(mock_wildfire_image): + """fake_pred bypasses model and goes through state update.""" + predictor = Predictor(nb_consecutive_frames=4, verbose=False) + + fake = np.empty((0,)) + out = predictor.predict(mock_wildfire_image, fake_pred=fake) + assert isinstance(out, float) + + fake = np.array([[0.1, 0.1, 0.2, 0.2, 0.9], [0.3, 0.3, 0.4, 0.4, 0.8]]).T + out = predictor.predict(mock_wildfire_image, fake_pred=fake) + assert isinstance(out, float) + + +def test_predictor_verbose_false_no_logs(mock_wildfire_image, caplog): + """verbose=False suppresses pyro_predictor log output.""" + predictor = Predictor(nb_consecutive_frames=2, verbose=False) + with caplog.at_level(logging.INFO, logger="pyro_predictor"): + predictor.predict(mock_wildfire_image) + assert caplog.records == [] + + +def test_predictor_verbose_true_emits_logs(mock_wildfire_image, caplog): + """verbose=True (default) emits INFO logs.""" + predictor = Predictor(nb_consecutive_frames=2, verbose=True) + with caplog.at_level(logging.INFO, logger="pyro_predictor"): + predictor.predict(mock_wildfire_image) + assert any(r.levelno == logging.INFO for r in caplog.records) + + +def test_classifier_verbose_false_no_logs(tmpdir_factory, caplog): + """Classifier verbose=False suppresses log output during init.""" + folder = str(tmpdir_factory.mktemp("cls_cache")) + with caplog.at_level(logging.INFO, logger="pyro_predictor"): + Classifier(model_folder=folder, format="onnx", verbose=False) + assert caplog.records == [] diff --git a/tests/test_vision.py b/tests/test_vision.py index 772263c8..fb351b6c 100644 --- a/tests/test_vision.py +++ b/tests/test_vision.py @@ -4,7 +4,16 @@ import numpy as np -from pyroengine.vision import Classifier +# Canonical import — Classifier lives in pyro_predictor +from pyro_predictor import Classifier + +# pyroengine.vision shim must re-export the same class +from pyroengine.vision import Classifier as ClassifierShim + + +def test_shim_is_same_class(): + """pyroengine.vision.Classifier must be the same object as pyro_predictor.Classifier.""" + assert ClassifierShim is Classifier def test_classifier(tmpdir_factory, mock_wildfire_image): From 80b4c6a30e798c6bdd7d3294e3c74347cc765ef1 Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 19:02:37 +0100 Subject: [PATCH 05/10] adapt pyproject --- pyproject.toml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e293f6e..6d72b6ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,13 +29,10 @@ classifiers = [ [tool.poetry.dependencies] python = ">=3.11,<4.0" requests = "==2.31.0" -tqdm = "==4.67.1" -onnxruntime = "==1.22.1" huggingface_hub = "==0.23.1" -pillow = "==11.0.0" -ncnn = "==1.0.20240410" pyroclient = { git = "https://github.com/pyronear/pyro-api.git", branch = "main", subdirectory = "client" } pyro_camera_api_client = { git = "https://github.com/pyronear/pyro-engine.git", branch = "pyro-camera-api", subdirectory = "pyro_camera_api/client" } +pyro_predictor = { path = "pyro-predictor", develop = true } python-dotenv = "==1.1.0" @@ -76,7 +73,7 @@ source = ["pyroengine"] [tool.mypy] python_version = "3.11" -files = "pyroengine/,pyro_camera_api/" +files = "pyroengine/,pyro_camera_api/,pyro-predictor/pyro_predictor/" show_error_codes = true pretty = true warn_unused_ignores = true From c2ca4b1be03cc621bdb2b91a46dbae4f423b137c Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 20:02:50 +0100 Subject: [PATCH 06/10] style --- poetry.lock | 45 ++++++++++++++++++- pyproject.toml | 12 ++--- pyro-predictor/pyro_predictor/__init__.py | 2 +- pyro-predictor/pyro_predictor/predictor.py | 4 +- pyro-predictor/pyro_predictor/vision.py | 17 ++++--- .../client/pyro_camera_api_client/version.py | 5 +++ .../pyro_camera_api/services/vision.py | 12 ++--- pyroengine/__init__.py | 5 ++- pyroengine/core.py | 2 +- pyroengine/engine.py | 34 ++++++-------- pyroengine/utils.py | 2 +- tests/test_predictor.py | 17 ++++--- tests/test_sensors.py | 5 ++- tests/test_vision.py | 3 +- 14 files changed, 108 insertions(+), 57 deletions(-) diff --git a/poetry.lock b/poetry.lock index 640dfa2d..a64e6345 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1182,6 +1182,27 @@ files = [ [package.dependencies] numpy = {version = ">=2,<2.3.0", markers = "python_version >= \"3.9\""} +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209"}, + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c"}, + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb"}, + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22"}, + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d"}, + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e"}, + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b"}, + {file = "opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6"}, +] + +[package.dependencies] +numpy = {version = ">=2", markers = "python_version >= \"3.9\""} + [[package]] name = "packaging" version = "25.0" @@ -1469,6 +1490,28 @@ reference = "pyro-camera-api" resolved_reference = "0f3ff6836d226334847af63e365e8849c2bced22" subdirectory = "pyro_camera_api/client" +[[package]] +name = "pyro-predictor" +version = "1.0.0" +description = "Standalone wildfire detection predictor (YOLO inference + temporal state)" +optional = false +python-versions = ">=3.11,<4.0" +groups = ["main"] +files = [] +develop = true + +[package.dependencies] +ncnn = "==1.0.20240410" +numpy = "*" +onnxruntime = "==1.22.1" +opencv-python-headless = "*" +pillow = "==11.0.0" +tqdm = "==4.67.1" + +[package.source] +type = "directory" +url = "pyro-predictor" + [[package]] name = "pyroclient" version = "0.2.0.dev0" @@ -1997,4 +2040,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<4.0" -content-hash = "5cd221843cf5ff8af559ee22be426da53cbb8224396df51acc166efbac9bda4b" +content-hash = "927f5fe2db8284300bf748db6d82d4b90c7e1d0a71adc7c4f4644d5520ca9042" diff --git a/pyproject.toml b/pyproject.toml index 6d72b6ed..0619aa3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,7 @@ ignore = [ "E501", "B008", "B904", "C901", "F403", "E731", "C416", "ANN002", "ANN003", "COM812", "ISC001" ] -exclude = [".git"] +exclude = [".git", "pyro-predictor/**", "docs/**"] [tool.ruff.lint.flake8-quotes] docstring-quotes = "double" @@ -133,10 +133,12 @@ known-third-party = ["pillow", "tqdm", "onnxruntime", "huggingface_hub"] [tool.ruff.lint.per-file-ignores] "**/__init__.py" = ["I001", "F401", "CPY001"] -"src/**.py" = ["D", "T201", "S101", "ANN"] -".github/**.py" = ["D", "T201", "ANN"] -"tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ANN202", "ARG001"] -"pyro_camera_api/**.py" = ["D", "T201", "S101", "ANN"] +"src/**.py" = ["D", "T201", "S101", "ANN", "BLE001", "S106", "S113", "S501"] +".github/**.py" = ["D", "T201", "ANN", "S", "PYI024"] +"tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ANN202", "ARG001", "S113"] +"pyro_camera_api/**.py" = ["D", "T201", "S101", "ANN", "BLE001", "S113", "S501", "S404", "S603", "S405", "S314", "E402", "RUF029"] +"pyroengine/core.py" = ["BLE001"] +"pyroengine/sensors.py" = ["S113", "S501", "ANN"] [tool.ruff.format] quote-style = "double" diff --git a/pyro-predictor/pyro_predictor/__init__.py b/pyro-predictor/pyro_predictor/__init__.py index f69692cc..6562e64a 100644 --- a/pyro-predictor/pyro_predictor/__init__.py +++ b/pyro-predictor/pyro_predictor/__init__.py @@ -6,4 +6,4 @@ from .predictor import Predictor from .vision import Classifier -__all__ = ["Predictor", "Classifier"] +__all__ = ["Classifier", "Predictor"] diff --git a/pyro-predictor/pyro_predictor/predictor.py b/pyro-predictor/pyro_predictor/predictor.py index 40718bbc..aad6e77c 100644 --- a/pyro-predictor/pyro_predictor/predictor.py +++ b/pyro-predictor/pyro_predictor/predictor.py @@ -56,7 +56,9 @@ def __init__( **kwargs: Any, ) -> None: self.verbose = verbose - self.model = Classifier(model_path=model_path, conf=model_conf_thresh, max_bbox_size=max_bbox_size, verbose=verbose, **kwargs) + self.model = Classifier( + model_path=model_path, conf=model_conf_thresh, max_bbox_size=max_bbox_size, verbose=verbose, **kwargs + ) self.conf_thresh = conf_thresh self.model_conf_thresh = model_conf_thresh self.max_bbox_size = max_bbox_size diff --git a/pyro-predictor/pyro_predictor/vision.py b/pyro-predictor/pyro_predictor/vision.py index 4b309580..2c78018c 100644 --- a/pyro-predictor/pyro_predictor/vision.py +++ b/pyro-predictor/pyro_predictor/vision.py @@ -5,6 +5,7 @@ import logging import os +import pathlib import platform import tarfile from typing import Tuple @@ -50,7 +51,7 @@ def __init__( ) -> None: self.verbose = verbose if model_path: - if not os.path.isfile(model_path): + if not pathlib.Path(model_path).is_file(): raise ValueError(f"Model file not found: {model_path}") if os.path.splitext(model_path)[-1].lower() != ".onnx": raise ValueError(f"Input model_path should point to an ONNX export but currently is {model_path}") @@ -59,7 +60,9 @@ def __init__( if format == "ncnn": if not self.is_arm_architecture(): if self.verbose: - logger.info("NCNN format is optimized for arm architecture only, switching to onnx is recommended") + logger.info( + "NCNN format is optimized for arm architecture only, switching to onnx is recommended" + ) model = MODEL_NAME self.format = "ncnn" elif format == "onnx": @@ -71,11 +74,13 @@ def __init__( model_path = os.path.join(model_folder, model) model_url = MODEL_URL_FOLDER + model - if not os.path.isfile(model_path): + if not pathlib.Path(model_path).is_file(): if self.verbose: logger.info(f"Downloading model from {model_url} ...") - os.makedirs(model_folder, exist_ok=True) - with DownloadProgressBar(unit="B", unit_scale=True, miniters=1, desc=model_path, disable=not self.verbose) as t: + pathlib.Path(model_folder).mkdir(exist_ok=True, parents=True) + with DownloadProgressBar( + unit="B", unit_scale=True, miniters=1, desc=model_path, disable=not self.verbose + ) as t: urlretrieve(model_url, model_path, reporthook=t.update_to) if self.verbose: logger.info("Model downloaded!") @@ -84,7 +89,7 @@ def __init__( if model_path.endswith(".tar.gz"): base_name = os.path.basename(model_path).replace(".tar.gz", "") extract_path = os.path.join(model_folder, base_name) - if not os.path.isdir(extract_path): + if not pathlib.Path(extract_path).is_dir(): with tarfile.open(model_path, "r:gz") as tar: tar.extractall(model_folder) if self.verbose: diff --git a/pyro_camera_api/client/pyro_camera_api_client/version.py b/pyro_camera_api/client/pyro_camera_api_client/version.py index 4b4a921b..1e6cc3a2 100644 --- a/pyro_camera_api/client/pyro_camera_api_client/version.py +++ b/pyro_camera_api/client/pyro_camera_api_client/version.py @@ -1 +1,6 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + __version__ = "0.1.0.dev0" diff --git a/pyro_camera_api/pyro_camera_api/services/vision.py b/pyro_camera_api/pyro_camera_api/services/vision.py index d227802f..9f5c6bd9 100644 --- a/pyro_camera_api/pyro_camera_api/services/vision.py +++ b/pyro_camera_api/pyro_camera_api/services/vision.py @@ -57,7 +57,7 @@ class Anonymizer: Examples: >>> from pyroengine.vision import Anonymizer - >>> anonymizer = Anonymizer(format="onnx", conf=0.2) + >>> anonymizer = Anonymizer(model_format="onnx", conf=0.2) >>> from PIL import Image >>> img = Image.open("car.jpg") >>> detections = anonymizer(img) @@ -75,7 +75,7 @@ def __init__( imgsz=640, conf=0.25, iou=0, - format="ncnn", + model_format="ncnn", model_path=None, ) -> None: if model_path: @@ -85,12 +85,12 @@ def __init__( raise ValueError(f"Input model_path should point to an ONNX export but currently is {model_path}") self.format = "onnx" else: - if format == "ncnn": + if model_format == "ncnn": if not self.is_arm_architecture(): logger.info("NCNN format is optimized for arm architecture only, switching to onnx is recommended") model = MODEL_NAME self.format = "ncnn" - elif format == "onnx": + elif model_format == "onnx": model = MODEL_NAME.replace("ncnn", "onnx") self.format = "onnx" else: @@ -103,7 +103,7 @@ def __init__( logger.info(f"Downloading model from {model_url} ...") pathlib.Path(model_folder).mkdir(exist_ok=True, parents=True) with DownloadProgressBar(unit="B", unit_scale=True, miniters=1, desc=model_path) as t: - urlretrieve(model_url, model_path, reporthook=t.update_to) + urlretrieve(model_url, model_path, reporthook=t.update_to) # noqa: S310 logger.info("Model downloaded!") # Extract .tar.gz archive @@ -113,7 +113,7 @@ def __init__( if not pathlib.Path(extract_path).is_dir(): pathlib.Path(extract_path).mkdir(exist_ok=True, parents=True) with tarfile.open(model_path, "r:gz") as tar: - tar.extractall(extract_path) # 👈 extract *inside* the versioned folder + tar.extractall(extract_path) # noqa: S202 # trusted source logger.info(f"Extracted model to: {extract_path}") model_path = extract_path diff --git a/pyroengine/__init__.py b/pyroengine/__init__.py index 90889ba8..bcb82f04 100644 --- a/pyroengine/__init__.py +++ b/pyroengine/__init__.py @@ -1,9 +1,12 @@ +from typing import Any + from pyro_predictor import Predictor from .version import __version__ + # Lazy imports: core (SystemController, is_day_time) and engine require # requests/pyroclient which are optional if only Predictor is used. -def __getattr__(name: str): +def __getattr__(name: str) -> Any: # noqa: ANN401 if name in ("SystemController", "is_day_time"): from .core import SystemController, is_day_time diff --git a/pyroengine/core.py b/pyroengine/core.py index 98820698..3713014a 100644 --- a/pyroengine/core.py +++ b/pyroengine/core.py @@ -31,7 +31,7 @@ logger = logging.getLogger(__name__) -def is_day_time(cache, frame, strategy, delta=0): +def is_day_time(cache: Optional[Path], frame: Image.Image, strategy: str, delta: int = 0) -> bool: """ Determine whether it is daytime based on the selected strategy. diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 08ce98c0..ed0d30b5 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -3,7 +3,6 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. -import glob import io import logging import shutil @@ -16,30 +15,29 @@ import numpy as np import requests from PIL import Image +from pyro_predictor import Predictor from pyroclient import client -from requests.exceptions import ConnectionError +from requests.exceptions import ConnectionError as RequestsConnectionError from requests.models import Response -from pyro_predictor import Predictor - __all__ = ["Engine"] logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO, force=True) logger = logging.getLogger(__name__) -def handler(signum, frame) -> Never: +def handler(_signum: int, _frame: object) -> Never: raise TimeoutError("Heartbeat check timed out") -def heartbeat_with_timeout(api_instance, cam_id, timeout=1) -> None: +def heartbeat_with_timeout(api_instance: object, cam_id: str, timeout: int = 1) -> None: signal.signal(signal.SIGALRM, handler) signal.alarm(timeout) try: api_instance.heartbeat(cam_id) except TimeoutError: logger.warning(f"Heartbeat check timed out for {cam_id}") - except ConnectionError: + except RequestsConnectionError: logger.warning(f"Unable to reach the pyro-api with {cam_id}") finally: signal.alarm(0) @@ -96,7 +94,7 @@ def __init__( save_detections_frames: Optional[bool] = False, send_last_image_period: int = 3600, # 1H last_bbox_mask_fetch_period: int = 3600, # 1H - **kwargs: Any, + **kwargs: Any, # noqa: ANN401 ) -> None: cam_ids = list(cam_creds.keys()) if isinstance(cam_creds, dict) else None super().__init__( @@ -147,7 +145,8 @@ def __init__( # Restore pending alerts cache self._alerts: deque = deque(maxlen=cache_size) self._cache = Path(cache_folder) # with Docker, the path has to be a bind volume - assert self._cache.is_dir() + if not self._cache.is_dir(): + raise ValueError(f"Cache folder does not exist: {self._cache}") def _new_state(self) -> Dict[str, Any]: state = super()._new_state() @@ -164,7 +163,7 @@ def predict( self, frame: Image.Image, cam_id: Optional[str] = None, - occlusion_bboxes: Optional[Dict[Any, Any]] = None, + occlusion_bboxes: Optional[Dict[Any, Any]] = None, # noqa: ARG002 fake_pred: Optional[np.ndarray] = None, ) -> float: """Computes the confidence that the image contains wildfire cues @@ -218,7 +217,7 @@ def predict( if bbox_mask_url is not None: full_url = f"{bbox_mask_url}_{azimuth}.json" try: - response = requests.get(full_url) + response = requests.get(full_url, timeout=5) bbox_mask_dict = response.json() self.occlusion_masks[cam_key] = (bbox_mask_url, bbox_mask_dict, azimuth) logger.info(f"Downloaded occlusion masks for cam {cam_key} at {bbox_mask_url} :{bbox_mask_dict}") @@ -331,7 +330,7 @@ def _process_alerts(self) -> None: logger.info(f"Camera '{cam_id}' - alert sent") stream.seek(0) # "Rewind" the stream to the beginning so we can read its content - except (KeyError, ConnectionError, ValueError) as e: + except (KeyError, RequestsConnectionError, ValueError) as e: logger.warning(f"Camera '{cam_id}' - unable to upload cache") logger.warning(e) break @@ -352,7 +351,7 @@ def _local_backup(self, img: Image.Image, cam_id: Optional[str], is_alert: bool file = backup_cache.joinpath(f"{time.strftime('%Y%m%d-%H%M%S')}.jpg") img.save(file) - def _clean_local_backup(self, backup_cache) -> None: + def _clean_local_backup(self, backup_cache: Path) -> None: """Clean local backup when it's bigger than _backup_size MB Args: @@ -361,14 +360,7 @@ def _clean_local_backup(self, backup_cache) -> None: backup_by_days = list(backup_cache.glob("*")) backup_by_days.sort() for folder in backup_by_days: - s = ( - sum( - Path(f).stat().st_size - for f in glob.glob(str(backup_cache) + "/**/*", recursive=True) - if Path(f).is_file() - ) - // 1024**2 - ) + s = sum(f.stat().st_size for f in backup_cache.rglob("*") if f.is_file()) // 1024**2 if s > self._backup_size: shutil.rmtree(folder) else: diff --git a/pyroengine/utils.py b/pyroengine/utils.py index ceb31a21..415c653d 100644 --- a/pyroengine/utils.py +++ b/pyroengine/utils.py @@ -4,6 +4,6 @@ # See LICENSE or go to for full license details. # Re-export from pyro_predictor for backwards compatibility. -from pyro_predictor.utils import DownloadProgressBar, box_iou, letterbox, nms, xywh2xyxy +from pyro_predictor.utils import DownloadProgressBar, letterbox, nms, xywh2xyxy __all__ = ["DownloadProgressBar", "letterbox", "nms", "xywh2xyxy"] diff --git a/tests/test_predictor.py b/tests/test_predictor.py index 4590be79..0c09d21a 100644 --- a/tests/test_predictor.py +++ b/tests/test_predictor.py @@ -1,9 +1,6 @@ import logging import numpy as np -import pytest -from PIL import Image - from pyro_predictor import Classifier, Predictor @@ -13,22 +10,24 @@ def test_predictor_direct_import(): assert Classifier is not None -def test_predictor_offline(tmpdir_factory, mock_wildfire_image, mock_forest_image): - folder = str(tmpdir_factory.mktemp("predictor_cache")) +def test_predictor_offline(mock_wildfire_image, mock_forest_image): predictor = Predictor(nb_consecutive_frames=4, verbose=False) out = predictor.predict(mock_forest_image) - assert isinstance(out, float) and 0 <= out <= 1 + assert isinstance(out, float) + assert 0 <= out <= 1 assert len(predictor._states["-1"]["last_predictions"]) == 1 assert predictor._states["-1"]["ongoing"] is False out = predictor.predict(mock_wildfire_image) - assert isinstance(out, float) and 0 <= out <= 1 + assert isinstance(out, float) + assert 0 <= out <= 1 assert len(predictor._states["-1"]["last_predictions"]) == 2 out = predictor.predict(mock_wildfire_image) - assert isinstance(out, float) and 0 <= out <= 1 - assert predictor._states["-1"]["ongoing"] == True + assert isinstance(out, float) + assert 0 <= out <= 1 + assert predictor._states["-1"]["ongoing"] is True def test_predictor_per_camera_state(mock_wildfire_image, mock_forest_image): diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 7d96c5da..8199b32b 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -91,7 +91,7 @@ def test_set_ptz_preset_no_slots(): with patch("requests.post", return_value=mock_response): camera = ReolinkCamera("192.168.99.99", "login", "pwd", "ptz") - with pytest.raises(ValueError, match="No available slots for new presets."): + with pytest.raises(ValueError, match=r"No available slots for new presets\."): camera.set_ptz_preset() @@ -223,7 +223,8 @@ def mock_set_manual_focus(pos): def mock_capture(): # Dummy image, content doesn't matter because we mock sharpness - return Image.fromarray((np.random.rand(100, 100) * 255).astype(np.uint8)) + rng = np.random.default_rng() + return Image.fromarray((rng.random((100, 100)) * 255).astype(np.uint8)) def mock_sharpness(image): pos = called_positions[-1] diff --git a/tests/test_vision.py b/tests/test_vision.py index fb351b6c..2b9fc02b 100644 --- a/tests/test_vision.py +++ b/tests/test_vision.py @@ -46,8 +46,7 @@ def test_classifier(tmpdir_factory, mock_wildfire_image): def sha256sum(path): - with pathlib.Path(path).open("rb") as f: - return hashlib.sha256(f.read()).hexdigest() + return hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest() def test_download(tmpdir_factory): From e63eee5329a7eae73e7f1bd97bc47e0a54541918 Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 20:07:24 +0100 Subject: [PATCH 07/10] fix mypy --- pyroengine/core.py | 4 ++-- pyroengine/engine.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyroengine/core.py b/pyroengine/core.py index 3713014a..8d25dcaf 100644 --- a/pyroengine/core.py +++ b/pyroengine/core.py @@ -55,8 +55,8 @@ def is_day_time(cache: Optional[Path], frame: Image.Image, strategy: str, delta: is_day = False if strategy in ["both", "ir"]: - frame = np.array(frame) - if np.max(frame[:, :, 0] - frame[:, :, 1]) == 0: + frame_arr = np.array(frame) + if np.max(frame_arr[:, :, 0] - frame_arr[:, :, 1]) == 0: is_day = False return is_day diff --git a/pyroengine/engine.py b/pyroengine/engine.py index ed0d30b5..af989dd7 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -30,7 +30,7 @@ def handler(_signum: int, _frame: object) -> Never: raise TimeoutError("Heartbeat check timed out") -def heartbeat_with_timeout(api_instance: object, cam_id: str, timeout: int = 1) -> None: +def heartbeat_with_timeout(api_instance: Any, cam_id: str, timeout: int = 1) -> None: # noqa: ANN401 signal.signal(signal.SIGALRM, handler) signal.alarm(timeout) try: From 6d759f9270712cf726a8114403343a240041ffd9 Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 25 Mar 2026 20:23:31 +0100 Subject: [PATCH 08/10] fix test --- tests/test_predictor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_predictor.py b/tests/test_predictor.py index 0c09d21a..a82e2ac9 100644 --- a/tests/test_predictor.py +++ b/tests/test_predictor.py @@ -27,7 +27,7 @@ def test_predictor_offline(mock_wildfire_image, mock_forest_image): out = predictor.predict(mock_wildfire_image) assert isinstance(out, float) assert 0 <= out <= 1 - assert predictor._states["-1"]["ongoing"] is True + assert predictor._states["-1"]["ongoing"] def test_predictor_per_camera_state(mock_wildfire_image, mock_forest_image): From 0a6f7aeec476f804d2a480fa9ca4c20e6d06725b Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 31 Mar 2026 17:54:52 +0200 Subject: [PATCH 09/10] put back develop predictor --- pyro-predictor/pyro_predictor/vision.py | 78 ++++++++++--------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/pyro-predictor/pyro_predictor/vision.py b/pyro-predictor/pyro_predictor/vision.py index 2c78018c..8b040fb8 100644 --- a/pyro-predictor/pyro_predictor/vision.py +++ b/pyro-predictor/pyro_predictor/vision.py @@ -4,25 +4,25 @@ # See LICENSE or go to for full license details. import logging -import os import pathlib import platform import tarfile from typing import Tuple -from urllib.request import urlretrieve import ncnn import numpy as np import onnxruntime +from huggingface_hub import hf_hub_download from PIL import Image -from .utils import DownloadProgressBar, box_iou, letterbox, nms, xywh2xyxy +from .utils import box_iou, letterbox, nms, xywh2xyxy __all__ = ["Classifier"] -MODEL_URL_FOLDER = "https://huggingface.co/pyronear/yolo11s_mighty-mongoose_v5.1.0/resolve/main/" -MODEL_NAME = "ncnn_cpu_yolo11s_mighty-mongoose_v5.1.0.tar.gz" +MODEL_REPO_ID = "pyronear/yolo11s_nimble-narwhal_v6.0.0" +MODEL_NAME = "ncnn_cpu.tar.gz" +logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO, force=True) logger = logging.getLogger(__name__) @@ -30,12 +30,11 @@ class Classifier: """Implements an image classification model using YOLO backend. Examples: - >>> from pyro_predictor.vision import Classifier + >>> from pyroengine.vision import Classifier >>> model = Classifier() Args: model_path: model path - verbose: if False, suppress all informational log output """ def __init__( @@ -47,22 +46,17 @@ def __init__( format="ncnn", model_path=None, max_bbox_size=0.4, - verbose=True, ) -> None: - self.verbose = verbose if model_path: if not pathlib.Path(model_path).is_file(): raise ValueError(f"Model file not found: {model_path}") - if os.path.splitext(model_path)[-1].lower() != ".onnx": + if pathlib.Path(model_path).suffix.lower() != ".onnx": raise ValueError(f"Input model_path should point to an ONNX export but currently is {model_path}") self.format = "onnx" else: if format == "ncnn": if not self.is_arm_architecture(): - if self.verbose: - logger.info( - "NCNN format is optimized for arm architecture only, switching to onnx is recommended" - ) + logger.info("NCNN format is optimized for arm architecture only, switching to onnx is recommended") model = MODEL_NAME self.format = "ncnn" elif format == "onnx": @@ -71,59 +65,46 @@ def __init__( else: raise ValueError("Unsupported format: should be 'ncnn' or 'onnx'") - model_path = os.path.join(model_folder, model) - model_url = MODEL_URL_FOLDER + model + model_path = str(pathlib.Path(model_folder) / model) if not pathlib.Path(model_path).is_file(): - if self.verbose: - logger.info(f"Downloading model from {model_url} ...") + logger.info(f"Downloading model from {MODEL_REPO_ID}/{model} ...") pathlib.Path(model_folder).mkdir(exist_ok=True, parents=True) - with DownloadProgressBar( - unit="B", unit_scale=True, miniters=1, desc=model_path, disable=not self.verbose - ) as t: - urlretrieve(model_url, model_path, reporthook=t.update_to) - if self.verbose: - logger.info("Model downloaded!") - - # Extract .tar.gz archive + hf_hub_download(repo_id=MODEL_REPO_ID, filename=model, local_dir=model_folder) + logger.info("Model downloaded!") + + # Extract archive if model_path.endswith(".tar.gz"): - base_name = os.path.basename(model_path).replace(".tar.gz", "") - extract_path = os.path.join(model_folder, base_name) + base_name = pathlib.Path(model_path).name.replace(".tar.gz", "") + extract_path = str(pathlib.Path(model_folder) / base_name) if not pathlib.Path(extract_path).is_dir(): + pathlib.Path(extract_path).mkdir(parents=True, exist_ok=True) with tarfile.open(model_path, "r:gz") as tar: - tar.extractall(model_folder) - if self.verbose: - logger.info(f"Extracted model to: {extract_path}") + tar.extractall(extract_path) + logger.info(f"Extracted model to: {extract_path}") model_path = extract_path if self.format == "ncnn": self.model = ncnn.Net() - self.model.load_param(os.path.join(model_path, "best_ncnn_model", "model.ncnn.param")) - self.model.load_model(os.path.join(model_path, "best_ncnn_model", "model.ncnn.bin")) + self.model.load_param(str(pathlib.Path(model_path) / "best_ncnn_model" / "model.ncnn.param")) + self.model.load_model(str(pathlib.Path(model_path) / "best_ncnn_model" / "model.ncnn.bin")) else: try: - onnx_file = model_path if model_path.endswith(".onnx") else os.path.join(model_path, "best.onnx") + onnx_file = model_path if model_path.endswith(".onnx") else str(pathlib.Path(model_path) / "best.onnx") available_providers = onnxruntime.get_available_providers() if "CUDAExecutionProvider" in available_providers: providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] - if self.verbose: - logger.info("CUDA is available — using CUDAExecutionProvider for ONNX inference") - elif "CoreMLExecutionProvider" in available_providers: - providers = ["CoreMLExecutionProvider", "CPUExecutionProvider"] - if self.verbose: - logger.info("CoreML (MPS) is available — using CoreMLExecutionProvider for ONNX inference") + logger.info("CUDA is available — using CUDAExecutionProvider for ONNX inference") else: providers = ["CPUExecutionProvider"] - if self.verbose: - logger.info("No GPU provider available — using CPUExecutionProvider for ONNX inference") + logger.info("Using CPUExecutionProvider for ONNX inference") self.ort_session = onnxruntime.InferenceSession(onnx_file, providers=providers) except Exception as e: raise RuntimeError(f"Failed to load the ONNX model from {model_path}: {e!s}") from e - if self.verbose: - logger.info(f"ONNX model loaded successfully from {model_path}") + logger.info(f"ONNX model loaded successfully from {model_path}") self.imgsz = imgsz self.conf = conf @@ -188,7 +169,7 @@ def post_process(self, pred: np.ndarray, pad: Tuple[int, int]) -> np.ndarray: return pred - def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict = {}) -> np.ndarray: + def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict | None = None) -> np.ndarray: """Run the classifier on an input image. Args: @@ -198,6 +179,8 @@ def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict = {}) -> np.ndar Returns: Processed predictions. """ + if occlusion_bboxes is None: + occlusion_bboxes = {} np_img, pad = self.prep_process(pil_img) if self.format == "ncnn": @@ -221,8 +204,7 @@ def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict = {}) -> np.ndar pred = pred[(pred[:, 2] - pred[:, 0]) < self.max_bbox_size, :] pred = np.reshape(pred, (-1, 5)) - if self.verbose: - logger.info(f"Model original pred : {pred}") + logger.info(f"Model original pred : {pred}") # Remove prediction in bbox occlusion mask if len(occlusion_bboxes): @@ -234,4 +216,4 @@ def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict = {}) -> np.ndar keep = max_ious <= 0.1 pred = pred[keep] - return pred + return pred \ No newline at end of file From 4815cf92a3962a06fc48e14f54a7ad07a5008152 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 31 Mar 2026 17:59:24 +0200 Subject: [PATCH 10/10] missing verbose --- pyro-predictor/pyro_predictor/vision.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyro-predictor/pyro_predictor/vision.py b/pyro-predictor/pyro_predictor/vision.py index 8b040fb8..30835bfa 100644 --- a/pyro-predictor/pyro_predictor/vision.py +++ b/pyro-predictor/pyro_predictor/vision.py @@ -46,6 +46,7 @@ def __init__( format="ncnn", model_path=None, max_bbox_size=0.4, + verbose=True, ) -> None: if model_path: if not pathlib.Path(model_path).is_file(): @@ -216,4 +217,4 @@ def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict | None = None) - keep = max_ious <= 0.1 pred = pred[keep] - return pred \ No newline at end of file + return pred