From bf01babc94b02516042b81d597e498768e57fe69 Mon Sep 17 00:00:00 2001 From: Mateo Date: Sat, 27 Jul 2024 07:06:19 +0200 Subject: [PATCH 1/5] mesure for each cam (#221) * mesure for each cam * fix test * use not gray image --- pyroengine/core.py | 14 +------------- tests/test_core.py | 35 ++++++++--------------------------- 2 files changed, 9 insertions(+), 40 deletions(-) diff --git a/pyroengine/core.py b/pyroengine/core.py index 68f215c..182d241 100644 --- a/pyroengine/core.py +++ b/pyroengine/core.py @@ -75,7 +75,7 @@ async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue # Move camera to the next pose to avoid waiting next_pos_id = camera.cam_poses[(idx + 1) % len(camera.cam_poses)] camera.move_camera("ToPos", idx=int(next_pos_id), speed=50) - if frame is not None: + if frame is not None and is_day_time(None, frame, "ir"): await image_queue.put((cam_id, frame)) await asyncio.sleep(0) # Yield control else: @@ -137,17 +137,6 @@ async def analyze_stream(self, image_queue: asyncio.Queue) -> None: finally: image_queue.task_done() # Mark the task as done - def check_day_time(self) -> None: - """ - Checks and updates the day_time attribute based on the current frame. - """ - try: - frame = self.cameras[0].capture() - if frame is not None: - self.day_time = is_day_time(None, frame, "ir") - except Exception as e: - logging.exception(f"Exception during initial day time check: {e}") - async def run(self, period: int = 30, send_alerts: bool = True) -> None: """ Captures and analyzes all camera streams, then processes alerts. @@ -157,7 +146,6 @@ async def run(self, period: int = 30, send_alerts: bool = True) -> None: send_alerts (bool): Boolean to activate / deactivate alert sending """ try: - self.check_day_time() if self.day_time: image_queue: asyncio.Queue[Any] = asyncio.Queue() diff --git a/tests/test_core.py b/tests/test_core.py index 071fe8a..ef1c4f2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -16,18 +16,18 @@ def mock_engine(): @pytest.fixture -def mock_cameras(): +def mock_cameras(mock_wildfire_image): camera = MagicMock() - camera.capture.return_value = Image.new("RGB", (100, 100)) # Mock captured image + camera.capture.return_value = mock_wildfire_image # Mock captured image camera.cam_type = "static" camera.ip_address = "192.168.1.1" return [camera] @pytest.fixture -def mock_cameras_ptz(): +def mock_cameras_ptz(mock_wildfire_image): camera = MagicMock() - camera.capture.return_value = Image.new("RGB", (100, 100)) # Mock captured image + camera.capture.return_value = mock_wildfire_image # Mock captured image camera.cam_type = "ptz" camera.cam_poses = [1, 2] camera.ip_address = "192.168.1.1" @@ -94,9 +94,9 @@ async def test_capture_images_ptz(system_controller_ptz): @pytest.mark.asyncio -async def test_analyze_stream(system_controller): +async def test_analyze_stream(system_controller, mock_wildfire_image): queue = asyncio.Queue() - mock_frame = Image.new("RGB", (100, 100)) + mock_frame = mock_wildfire_image await queue.put(("192.168.1.1", mock_frame)) analyze_task = asyncio.create_task(system_controller.analyze_stream(queue)) @@ -118,9 +118,9 @@ async def test_capture_images_method(system_controller): @pytest.mark.asyncio -async def test_analyze_stream_method(system_controller): +async def test_analyze_stream_method(system_controller, mock_wildfire_image): queue = asyncio.Queue() - mock_frame = Image.new("RGB", (100, 100)) + mock_frame = mock_wildfire_image await queue.put(("192.168.1.1", mock_frame)) await queue.put(None) # Signal the end of the stream @@ -129,25 +129,6 @@ async def test_analyze_stream_method(system_controller): system_controller.engine.predict.assert_called_once_with(mock_frame, "192.168.1.1") -def test_check_day_time(system_controller): - with patch("pyroengine.core.is_day_time", return_value=True) as mock_is_day_time: - system_controller.check_day_time() - assert system_controller.day_time is True - mock_is_day_time.assert_called_once() - - with patch("pyroengine.core.is_day_time", return_value=False) as mock_is_day_time: - system_controller.check_day_time() - assert system_controller.day_time is False - mock_is_day_time.assert_called_once() - - with patch("pyroengine.core.is_day_time", side_effect=Exception("Error in is_day_time")) as mock_is_day_time, patch( - "pyroengine.core.logging.exception" - ) as mock_logging_exception: - system_controller.check_day_time() - mock_is_day_time.assert_called_once() - mock_logging_exception.assert_called_once_with("Exception during initial day time check: Error in is_day_time") - - def test_repr_method(system_controller): repr_str = repr(system_controller) # Check if the representation is a string From 9e70b17eb7e201cc4436a03b3fb6ca0a2123d460 Mon Sep 17 00:00:00 2001 From: Mateo Date: Sat, 27 Jul 2024 07:51:58 +0200 Subject: [PATCH 2/5] Reduce fp (#222) * require multiple detection * at least two detection * reduce fp * fix th --- pyroengine/engine.py | 41 ++++++++++++++++++++++++----------------- pyroengine/vision.py | 4 ++-- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/pyroengine/engine.py b/pyroengine/engine.py index 6c71f0f..a0412a9 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -62,7 +62,7 @@ class Engine: def __init__( self, model_path: Optional[str] = None, - conf_thresh: float = 0.25, + conf_thresh: float = 0.15, api_url: Optional[str] = None, cam_creds: Optional[Dict[str, Dict[str, str]]] = None, latitude: Optional[float] = None, @@ -82,7 +82,7 @@ def __init__( """Init engine""" # Engine Setup - self.model = Classifier(model_path=model_path) + self.model = Classifier(model_path=model_path, conf=0.05) self.conf_thresh = conf_thresh # API Setup @@ -207,21 +207,27 @@ def _update_states(self, frame: Image.Image, preds: np.ndarray, cam_key: str) -> # Get the best ones if boxes.shape[0]: best_boxes = nms(boxes) - 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, :] - conf = np.max(best_boxes_scores) / (self.nb_consecutive_frames + 1) # memory + preds - - if len(combine_predictions): - - # send only preds boxes that match combine_predictions - ious = box_iou(combine_predictions[:, :4], preds[:, :4]) - iou_match = [np.max(iou) > 0 for iou in ious] - output_predictions = preds[iou_match, :] - - # Limit bbox size for api - output_predictions = np.round(output_predictions, 3) # max 3 digit - output_predictions = output_predictions[:5, :] # max 5 bbox + # We keep only detections with at least two boxes above conf_th + detections = boxes[boxes[:, -1] > self.conf_thresh, :] + ious_detections = box_iou(best_boxes[:, :4], detections[:, :4]) + strong_detection = np.sum(ious_detections > 0, 0) > 1 + 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, :] + conf = np.max(best_boxes_scores) / (self.nb_consecutive_frames + 1) # memory + preds + if len(combine_predictions): + + # send only preds boxes that match combine_predictions + ious = box_iou(combine_predictions[:, :4], preds[:, :4]) + iou_match = [np.max(iou) > 0 for iou in ious] + output_predictions = preds[iou_match, :] + + # Limit bbox size for api + output_predictions = np.round(output_predictions, 3) # max 3 digit + output_predictions = output_predictions[:5, :] # max 5 bbox self._states[cam_key]["last_predictions"].append( (frame, preds, output_predictions.tolist(), datetime.now(timezone.utc).isoformat(), False) @@ -259,6 +265,7 @@ def predict(self, frame: Image.Image, cam_id: Optional[str] = None) -> float: # Inference with ONNX preds = self.model(frame.convert("RGB"), self.occlusion_masks[cam_key]) + print(preds) conf = self._update_states(frame, preds, cam_key) if self.save_captured_frames: diff --git a/pyroengine/vision.py b/pyroengine/vision.py index 17e5445..2314cce 100644 --- a/pyroengine/vision.py +++ b/pyroengine/vision.py @@ -46,7 +46,7 @@ class Classifier: model_path: model path """ - def __init__(self, model_folder="data", imgsz=1024, conf=0.15, iou=0.05, format="ncnn", model_path=None) -> None: + def __init__(self, model_folder="data", imgsz=1024, conf=0.15, iou=0, format="ncnn", model_path=None) -> None: if model_path is None: if format == "ncnn": if self.is_arm_architecture(): @@ -128,7 +128,7 @@ def load_metadata(self, metadata_path): def __call__(self, pil_img: Image.Image, occlusion_mask: Optional[np.ndarray] = None) -> np.ndarray: - results = self.model(pil_img, imgsz=self.imgsz, conf=self.conf, iou=self.iou) + results = self.model(pil_img, imgsz=self.imgsz, conf=self.conf, iou=self.iou, verbose=False) y = np.concatenate( (results[0].boxes.xyxyn.cpu().numpy(), results[0].boxes.conf.cpu().numpy().reshape((-1, 1))), axis=1 ) From 4742146ae1dc43b3d4afb858d2b1a436e84f4758 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 30 Jul 2024 08:43:40 +0200 Subject: [PATCH 3/5] Imprrove day check (#223) * new day check * update tests --- pyroengine/core.py | 106 ++++++++++++++++++++++++++++++++------------- tests/test_core.py | 25 +++++++++++ 2 files changed, 102 insertions(+), 29 deletions(-) diff --git a/pyroengine/core.py b/pyroengine/core.py index 182d241..7c2363a 100644 --- a/pyroengine/core.py +++ b/pyroengine/core.py @@ -58,13 +58,16 @@ def is_day_time(cache, frame, strategy, delta=0): return is_day -async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue) -> None: +async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue) -> bool: """ - Captures an image from the camera and puts it into a queue. + Captures an image from the camera and puts it into a queue. Returns whether it is daytime for this camera. Args: camera (ReolinkCamera): The camera instance. image_queue (asyncio.Queue): The queue to put the captured image. + + Returns: + bool: True if it is daytime according to this camera, False otherwise. """ cam_id = camera.ip_address try: @@ -75,16 +78,21 @@ async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue # Move camera to the next pose to avoid waiting next_pos_id = camera.cam_poses[(idx + 1) % len(camera.cam_poses)] camera.move_camera("ToPos", idx=int(next_pos_id), speed=50) - if frame is not None and is_day_time(None, frame, "ir"): + if frame is not None: await image_queue.put((cam_id, frame)) await asyncio.sleep(0) # Yield control + if not is_day_time(None, frame, "ir"): + return False else: frame = camera.capture() if frame is not None: await image_queue.put((cam_id, frame)) await asyncio.sleep(0) # Yield control + if not is_day_time(None, frame, "ir"): + return False except Exception as e: logging.exception(f"Error during image capture from camera {cam_id}: {e}") + return True class SystemController: @@ -106,17 +114,21 @@ def __init__(self, engine: Engine, cameras: List[ReolinkCamera]) -> None: """ self.engine = engine self.cameras = cameras - self.day_time = True + self.is_day = True - async def capture_images(self, image_queue: asyncio.Queue) -> None: + async def capture_images(self, image_queue: asyncio.Queue) -> bool: """ Captures images from all cameras using asyncio. Args: image_queue (asyncio.Queue): The queue to put the captured images. + + Returns: + bool: True if it is daytime according to all cameras, False otherwise. """ tasks = [capture_camera_image(camera, image_queue) for camera in self.cameras] - await asyncio.gather(*tasks) + day_times = await asyncio.gather(*tasks) + return all(day_times) async def analyze_stream(self, image_queue: asyncio.Queue) -> None: """ @@ -137,41 +149,72 @@ async def analyze_stream(self, image_queue: asyncio.Queue) -> None: finally: image_queue.task_done() # Mark the task as done - async def run(self, period: int = 30, send_alerts: bool = True) -> None: + async def night_mode(self) -> bool: + """ + Checks if it is nighttime for any camera. + + Returns: + bool: True if it is daytime for all cameras, False otherwise. + """ + for camera in self.cameras: + cam_id = camera.ip_address + try: + if camera.cam_type == "ptz": + for idx, pose_id in enumerate(camera.cam_poses): + cam_id = f"{camera.ip_address}_{pose_id}" + frame = camera.capture() + # Move camera to the next pose to avoid waiting + next_pos_id = camera.cam_poses[(idx + 1) % len(camera.cam_poses)] + camera.move_camera("ToPos", idx=int(next_pos_id), speed=50) + if not is_day_time(None, frame, "ir"): + return False + else: + frame = camera.capture() + if not is_day_time(None, frame, "ir"): + return False + except Exception as e: + logging.exception(f"Error during image capture from camera {cam_id}: {e}") + return True + + async def run(self, period: int = 30, send_alerts: bool = True) -> bool: """ Captures and analyzes all camera streams, then processes alerts. Args: period (int): The time period between captures in seconds. - send_alerts (bool): Boolean to activate / deactivate alert sending + send_alerts (bool): Boolean to activate / deactivate alert sending. + + Returns: + bool: True if it is daytime according to all cameras, False otherwise. """ try: + image_queue: asyncio.Queue[Any] = asyncio.Queue() - if self.day_time: - image_queue: asyncio.Queue[Any] = asyncio.Queue() - - # Start the image processor task - processor_task = asyncio.create_task(self.analyze_stream(image_queue)) + # Start the image processor task + processor_task = asyncio.create_task(self.analyze_stream(image_queue)) - # Capture images concurrently - await self.capture_images(image_queue) + # Capture images concurrently + self.is_day = await self.capture_images(image_queue) - # Wait for the image processor to finish processing - await image_queue.join() # Ensure all tasks are marked as done + # Wait for the image processor to finish processing + await image_queue.join() # Ensure all tasks are marked as done - # Signal the image processor to stop processing - await image_queue.put(None) - await processor_task # Ensure the processor task completes + # Signal the image processor to stop processing + await image_queue.put(None) + await processor_task # Ensure the processor task completes - # Process alerts + # Process alerts + if send_alerts: try: - if send_alerts: - self.engine._process_alerts() + self.engine._process_alerts() except Exception as e: logging.error(f"Error processing alerts: {e}") + return self.is_day + except Exception as e: logging.warning(f"Analyze stream error: {e}") + return True async def main_loop(self, period: int, send_alerts: bool = True) -> None: """ @@ -179,16 +222,21 @@ async def main_loop(self, period: int, send_alerts: bool = True) -> None: Args: period (int): The time period between captures in seconds. - send_alerts (bool): Boolean to activate / deactivate alert sending + send_alerts (bool): Boolean to activate / deactivate alert sending. """ while True: start_ts = time.time() await self.run(period, send_alerts) - # Sleep only once all images are processed - loop_time = time.time() - start_ts - sleep_time = max(period - (loop_time), 0) - logging.info(f"Loop run under {loop_time:.2f} seconds, sleeping for {sleep_time:.2f}") - await asyncio.sleep(sleep_time) + if not self.is_day: + while not await self.night_mode(): + logging.info("Nighttime detected by at least one camera, sleeping for 1 hour.") + await asyncio.sleep(3600) # Sleep for 1 hour + else: + # Sleep only once all images are processed + loop_time = time.time() - start_ts + sleep_time = max(period - (loop_time), 0) + logging.info(f"Loop run under {loop_time:.2f} seconds, sleeping for {sleep_time:.2f}") + await asyncio.sleep(sleep_time) def __repr__(self) -> str: """ diff --git a/tests/test_core.py b/tests/test_core.py index ef1c4f2..42a21f9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -34,6 +34,16 @@ def mock_cameras_ptz(mock_wildfire_image): return [camera] +@pytest.fixture +def mock_cameras_ptz_night(): + camera = MagicMock() + camera.capture.return_value = Image.new("RGB", (100, 100), (255, 255, 255)) # Mock captured image + camera.cam_type = "ptz" + camera.cam_poses = [1, 2] + camera.ip_address = "192.168.1.1" + return [camera] + + @pytest.fixture def system_controller(mock_engine, mock_cameras): return SystemController(engine=mock_engine, cameras=mock_cameras) @@ -44,6 +54,21 @@ def system_controller_ptz(mock_engine, mock_cameras_ptz): return SystemController(engine=mock_engine, cameras=mock_cameras_ptz) +@pytest.fixture +def system_controller_ptz_night(mock_engine, mock_cameras_ptz_night): + return SystemController(engine=mock_engine, cameras=mock_cameras_ptz_night) + + +@pytest.mark.asyncio +async def test_night_mode(system_controller): + assert await system_controller.night_mode() + + +@pytest.mark.asyncio +async def test_night_mode_ptz(system_controller_ptz_night): + assert not await system_controller_ptz_night.night_mode() + + def test_is_day_time_ir_strategy(mock_wildfire_image): # Use day image assert is_day_time(None, mock_wildfire_image, "ir") From a532a7b17b0f6c66ad4bec7ae806bba68be880ac Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 30 Jul 2024 15:01:54 +0200 Subject: [PATCH 4/5] prevent frame none (#224) --- pyroengine/core.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pyroengine/core.py b/pyroengine/core.py index 7c2363a..88f64a7 100644 --- a/pyroengine/core.py +++ b/pyroengine/core.py @@ -81,15 +81,15 @@ async def capture_camera_image(camera: ReolinkCamera, image_queue: asyncio.Queue if frame is not None: await image_queue.put((cam_id, frame)) await asyncio.sleep(0) # Yield control - if not is_day_time(None, frame, "ir"): - return False + if not is_day_time(None, frame, "ir"): + return False else: frame = camera.capture() if frame is not None: await image_queue.put((cam_id, frame)) await asyncio.sleep(0) # Yield control - if not is_day_time(None, frame, "ir"): - return False + if not is_day_time(None, frame, "ir"): + return False except Exception as e: logging.exception(f"Error during image capture from camera {cam_id}: {e}") return True @@ -166,12 +166,14 @@ async def night_mode(self) -> bool: # Move camera to the next pose to avoid waiting next_pos_id = camera.cam_poses[(idx + 1) % len(camera.cam_poses)] camera.move_camera("ToPos", idx=int(next_pos_id), speed=50) - if not is_day_time(None, frame, "ir"): - return False + if frame is not None: + if not is_day_time(None, frame, "ir"): + return False else: frame = camera.capture() - if not is_day_time(None, frame, "ir"): - return False + if frame is not None: + if not is_day_time(None, frame, "ir"): + return False except Exception as e: logging.exception(f"Error during image capture from camera {cam_id}: {e}") return True From a6187311cdadb893eb24f1193516bd371cb4a522 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 30 Jul 2024 15:30:45 +0200 Subject: [PATCH 5/5] timeout heartbeat (#225) --- pyroengine/engine.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pyroengine/engine.py b/pyroengine/engine.py index a0412a9..ff5fe16 100644 --- a/pyroengine/engine.py +++ b/pyroengine/engine.py @@ -9,6 +9,7 @@ import logging import os import shutil +import signal import time from collections import deque from datetime import datetime, timedelta, timezone @@ -30,6 +31,23 @@ logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO, force=True) +def handler(signum, frame): + raise TimeoutError("Heartbeat check timed out") + + +def heartbeat_with_timeout(api_instance, cam_id, timeout=1): + signal.signal(signal.SIGALRM, handler) + signal.alarm(timeout) + try: + api_instance.heartbeat(cam_id) + except TimeoutError: + logging.warning(f"Heartbeat check timed out for {cam_id}") + except ConnectionError: + logging.warning(f"Unable to reach the pyro-api with {cam_id}") + finally: + signal.alarm(0) + + class Engine: """This implements an object to manage predictions and API interactions for wildfire alerts. @@ -253,10 +271,7 @@ def predict(self, frame: Image.Image, cam_id: Optional[str] = None) -> float: # Heartbeat if len(self.api_client) > 0 and isinstance(cam_id, str): - try: - self.heartbeat(cam_id) - except ConnectionError: - logging.warning(f"Unable to reach the pyro-api with {cam_id}") + heartbeat_with_timeout(self, cam_id, timeout=1) cam_key = cam_id or "-1" # Reduce image size to save bandwidth