About Multi-camera viewer optimized for RTSP streams
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #2 from jeamy/yolo

yolo 1

author
jeamylee
committer
GitHub
date (Jun 2, 2026, 8:20 AM +0200) commit 3fc8f1e3 parent 39ceb0a1
+154 -43
+8 -2
README.md
··· 188 188 "analysis_fps_per_camera": 3.0, 189 189 "stable_frames": 2, 190 190 "cooldown_seconds": 180, 191 + "event_suppress_seconds": 30, 192 + "event_clip_seconds": 30, 191 193 "pre_event_seconds": 8, 192 194 "post_event_seconds": 20, 193 195 "classes": ["person", "car", "truck", "dog", "cat", "bird"] ··· 221 223 "model": "yolo11n.pt" 222 224 ``` 223 225 224 - When an object is detected for `stable_frames` consecutive analysis frames and 225 - the per-camera/per-class `cooldown_seconds` has elapsed, WildCam: 226 + When an object is detected for `stable_frames` consecutive analysis frames, 227 + the per-camera/per-class `cooldown_seconds` has elapsed, and the camera-wide 228 + `event_suppress_seconds` burst lock has elapsed, WildCam: 226 229 227 230 - saves an annotated JPEG snapshot in `~/Videos/Reolink/snapshots` 228 231 - starts an AVI event clip in `~/Videos/Reolink/events` 229 232 - sends an email with the image attached if `email.enabled` is `true` 233 + 234 + `event_clip_seconds` controls the total event video length and is capped at 235 + 180 seconds. `pre_event_seconds` is included in that total length. 230 236 231 237 Pretrained COCO classes include `person`, vehicles, and common animals such as 232 238 `dog`, `cat`, `bird`, `horse`, `sheep`, and `cow`. Wild animals such as deer,
+2 -1
camera_config.json.example
··· 21 21 } 22 22 ], 23 23 "recording_path": "~/Videos/Reolink", 24 - "model_dir": "~/Videos/Reolink/models" 25 24 "detection": { 26 25 "model": "yolo11n.pt", 27 26 "imgsz": 640, ··· 30 29 "analysis_fps_per_camera": 3.0, 31 30 "stable_frames": 2, 32 31 "cooldown_seconds": 180, 32 + "event_suppress_seconds": 30, 33 + "event_clip_seconds": 30, 33 34 "pre_event_seconds": 8, 34 35 "post_event_seconds": 20, 35 36 "classes": [
+38 -12
detection.py
··· 31 31 "analysis_fps_per_camera": 3.0, 32 32 "stable_frames": 2, 33 33 "cooldown_seconds": 180, 34 + "event_suppress_seconds": 30, 35 + "event_clip_seconds": 30, 34 36 "pre_event_seconds": 8, 35 37 "post_event_seconds": 20, 36 38 "classes": [ ··· 158 160 self._last_analyzed: dict[int, float] = {} 159 161 self._stable_hits: dict[tuple[int, str], int] = {} 160 162 self._last_event: dict[tuple[int, str], float] = {} 163 + self._last_camera_event: dict[int, float] = {} 161 164 self._model = None 162 165 self._names: dict[int, str] = {} 163 166 self._device = "cpu" ··· 283 286 now = time.monotonic() 284 287 stable_frames = max(1, int(self.config.get("stable_frames", 2))) 285 288 cooldown = max(0.0, float(self.config.get("cooldown_seconds", 180))) 289 + event_suppress = max(0.0, float(self.config.get("event_suppress_seconds", 30))) 290 + eligible: list[tuple[str, dict[str, Any]]] = [] 291 + 292 + if now - self._last_camera_event.get(camera_id, 0.0) < event_suppress: 293 + self._reset_stable_hits(camera_id) 294 + return 286 295 287 296 for label, best in best_by_label.items(): 288 297 key = (camera_id, label) ··· 291 300 continue 292 301 if now - self._last_event.get(key, 0.0) < cooldown: 293 302 continue 303 + eligible.append((label, best)) 304 + 305 + if not eligible: 306 + return 294 307 295 - self._last_event[key] = now 296 - annotated = self._annotate(frame, detections) 297 - self.detected.emit( 298 - DetectionResult( 299 - camera_id=camera_id, 300 - camera_name=camera_name, 301 - label=label, 302 - confidence=float(best["confidence"]), 303 - detections=detections, 304 - annotated_frame=annotated, 305 - timestamp=time.time(), 306 - ) 308 + label, best = max(eligible, key=lambda item: float(item[1]["confidence"])) 309 + self._last_event[(camera_id, label)] = now 310 + self._last_camera_event[camera_id] = now 311 + self._reset_other_stable_hits(camera_id, label) 312 + annotated = self._annotate(frame, detections) 313 + self.detected.emit( 314 + DetectionResult( 315 + camera_id=camera_id, 316 + camera_name=camera_name, 317 + label=label, 318 + confidence=float(best["confidence"]), 319 + detections=detections, 320 + annotated_frame=annotated, 321 + timestamp=time.time(), 307 322 ) 323 + ) 308 324 309 325 def _decay_stable_hits(self, camera_id: int): 310 326 for key in list(self._stable_hits.keys()): 311 327 if key[0] == camera_id: 312 328 self._stable_hits[key] = max(0, self._stable_hits[key] - 1) 329 + 330 + def _reset_stable_hits(self, camera_id: int): 331 + for key in list(self._stable_hits.keys()): 332 + if key[0] == camera_id: 333 + self._stable_hits[key] = 0 334 + 335 + def _reset_other_stable_hits(self, camera_id: int, active_label: str): 336 + for key in list(self._stable_hits.keys()): 337 + if key[0] == camera_id and key[1] != active_label: 338 + self._stable_hits[key] = 0 313 339 314 340 def _annotate(self, frame, detections: list[dict[str, Any]]): 315 341 annotated = frame.copy()
+4
i18n.py
··· 17 17 "label.detection_conf": "Konfidenz:", 18 18 "label.detection_fps": "FPS/Kamera:", 19 19 "label.detection_cooldown": "Cooldown s:", 20 + "label.detection_suppress": "Event-Sperre s:", 21 + "label.detection_clip": "Clip-Länge s:", 20 22 "label.detection_classes": "Klassen:", 21 23 "detection.device.auto": "Auto", 22 24 "detection.device.cuda": "CUDA", ··· 173 175 "label.detection_conf": "Confidence:", 174 176 "label.detection_fps": "FPS/camera:", 175 177 "label.detection_cooldown": "Cooldown s:", 178 + "label.detection_suppress": "Event lock s:", 179 + "label.detection_clip": "Clip length s:", 176 180 "label.detection_classes": "Classes:", 177 181 "detection.device.auto": "Auto", 178 182 "detection.device.cuda": "CUDA",
+81 -22
main_window.py
··· 364 364 self.detection_cooldown_spin.setValue(int(self.detection_config.get("cooldown_seconds", 180))) 365 365 row2.addWidget(self.detection_cooldown_label) 366 366 row2.addWidget(self.detection_cooldown_spin) 367 + 368 + self.detection_suppress_label = QLabel(tr("label.detection_suppress")) 369 + self.detection_suppress_spin = QSpinBox() 370 + self.detection_suppress_spin.setRange(0, 600) 371 + self.detection_suppress_spin.setValue(int(self.detection_config.get("event_suppress_seconds", 30))) 372 + row2.addWidget(self.detection_suppress_label) 373 + row2.addWidget(self.detection_suppress_spin) 374 + 375 + self.detection_clip_label = QLabel(tr("label.detection_clip")) 376 + self.detection_clip_spin = QSpinBox() 377 + self.detection_clip_spin.setRange(1, 180) 378 + self.detection_clip_spin.setValue(int(self.detection_config.get("event_clip_seconds", 30))) 379 + row2.addWidget(self.detection_clip_label) 380 + row2.addWidget(self.detection_clip_spin) 367 381 row2.addStretch() 368 382 layout.addLayout(row2) 369 383 ··· 382 396 self.detection_conf_spin, 383 397 self.detection_fps_spin, 384 398 self.detection_cooldown_spin, 399 + self.detection_suppress_spin, 400 + self.detection_clip_spin, 385 401 self.detection_classes_input, 386 402 ): 387 403 if isinstance(widget, QLineEdit): ··· 487 503 "confidence": float(self.detection_conf_spin.value()), 488 504 "analysis_fps_per_camera": float(self.detection_fps_spin.value()), 489 505 "cooldown_seconds": int(self.detection_cooldown_spin.value()), 506 + "event_suppress_seconds": int(self.detection_suppress_spin.value()), 507 + "event_clip_seconds": int(self.detection_clip_spin.value()), 490 508 "classes": classes, 491 509 "recording_path": self.recording_path, 492 510 "model_dir": str(default_model_dir(self.recording_path)), ··· 516 534 def _sync_detection_config_ui(self): 517 535 if not hasattr(self, "detection_model_input"): 518 536 return 519 - self.detection_model_input.setText(str(self.detection_config.get("model", "yolo11n.pt"))) 520 - idx = self.detection_device_combo.findData(str(self.detection_config.get("device", "auto"))) 521 - if idx >= 0: 522 - self.detection_device_combo.setCurrentIndex(idx) 523 - self.detection_imgsz_spin.setValue(int(self.detection_config.get("imgsz", 640))) 524 - self.detection_conf_spin.setValue(float(self.detection_config.get("confidence", 0.4))) 525 - self.detection_fps_spin.setValue(float(self.detection_config.get("analysis_fps_per_camera", 3.0))) 526 - self.detection_cooldown_spin.setValue(int(self.detection_config.get("cooldown_seconds", 180))) 527 - self.detection_classes_input.setText(", ".join(self.detection_config.get("classes", []))) 537 + widgets = [ 538 + self.detection_model_input, 539 + self.detection_device_combo, 540 + self.detection_imgsz_spin, 541 + self.detection_conf_spin, 542 + self.detection_fps_spin, 543 + self.detection_cooldown_spin, 544 + self.detection_suppress_spin, 545 + self.detection_clip_spin, 546 + self.detection_classes_input, 547 + ] 548 + for widget in widgets: 549 + widget.blockSignals(True) 550 + try: 551 + self.detection_model_input.setText(str(self.detection_config.get("model", "yolo11n.pt"))) 552 + idx = self.detection_device_combo.findData(str(self.detection_config.get("device", "auto"))) 553 + if idx >= 0: 554 + self.detection_device_combo.setCurrentIndex(idx) 555 + self.detection_imgsz_spin.setValue(int(self.detection_config.get("imgsz", 640))) 556 + self.detection_conf_spin.setValue(float(self.detection_config.get("confidence", 0.4))) 557 + self.detection_fps_spin.setValue(float(self.detection_config.get("analysis_fps_per_camera", 3.0))) 558 + self.detection_cooldown_spin.setValue(int(self.detection_config.get("cooldown_seconds", 180))) 559 + self.detection_suppress_spin.setValue(int(self.detection_config.get("event_suppress_seconds", 30))) 560 + self.detection_clip_spin.setValue(int(self.detection_config.get("event_clip_seconds", 30))) 561 + self.detection_classes_input.setText(", ".join(self.detection_config.get("classes", []))) 562 + finally: 563 + for widget in widgets: 564 + widget.blockSignals(False) 528 565 529 566 def _sync_email_config_ui(self): 530 567 if not hasattr(self, "email_enabled_check"): 531 568 return 532 - self.email_enabled_check.setChecked(bool(self.email_config.get("enabled", False))) 533 - self.email_host_input.setText(str(self.email_config.get("smtp_host", ""))) 534 - self.email_port_spin.setValue(int(self.email_config.get("smtp_port", 587))) 535 - self.email_tls_check.setChecked(bool(self.email_config.get("use_tls", True))) 536 - self.email_user_input.setText(str(self.email_config.get("smtp_username", ""))) 537 - self.email_password_input.setText(str(self.email_config.get("smtp_password", ""))) 538 - self.email_from_input.setText(str(self.email_config.get("from", ""))) 539 - to_value = self.email_config.get("to", []) 540 - if isinstance(to_value, list): 541 - to_value = ", ".join(to_value) 542 - self.email_to_input.setText(str(to_value)) 569 + widgets = [ 570 + self.email_enabled_check, 571 + self.email_host_input, 572 + self.email_port_spin, 573 + self.email_tls_check, 574 + self.email_user_input, 575 + self.email_password_input, 576 + self.email_from_input, 577 + self.email_to_input, 578 + ] 579 + for widget in widgets: 580 + widget.blockSignals(True) 581 + try: 582 + self.email_enabled_check.setChecked(bool(self.email_config.get("enabled", False))) 583 + self.email_host_input.setText(str(self.email_config.get("smtp_host", ""))) 584 + self.email_port_spin.setValue(int(self.email_config.get("smtp_port", 587))) 585 + self.email_tls_check.setChecked(bool(self.email_config.get("use_tls", True))) 586 + self.email_user_input.setText(str(self.email_config.get("smtp_username", ""))) 587 + self.email_password_input.setText(str(self.email_config.get("smtp_password", ""))) 588 + self.email_from_input.setText(str(self.email_config.get("from", ""))) 589 + to_value = self.email_config.get("to", []) 590 + if isinstance(to_value, list): 591 + to_value = ", ".join(to_value) 592 + self.email_to_input.setText(str(to_value)) 593 + finally: 594 + for widget in widgets: 595 + widget.blockSignals(False) 543 596 544 597 def _runtime_detection_config(self): 545 598 config = dict(self.detection_config) ··· 1436 1489 clip_file = None 1437 1490 thread = self.camera_threads.get(event.camera_id) 1438 1491 if thread is not None: 1492 + clip_seconds = min(180.0, max(1.0, float(self.detection_config.get("event_clip_seconds", 30)))) 1493 + pre_seconds = min(clip_seconds - 1.0, max(0.0, float(self.detection_config.get("pre_event_seconds", 8)))) 1494 + post_seconds = max(1.0, clip_seconds - pre_seconds) 1439 1495 clip_file = thread.start_event_clip( 1440 1496 self.event_path, 1441 1497 event.label, 1442 - pre_seconds=float(self.detection_config.get("pre_event_seconds", 8)), 1443 - post_seconds=float(self.detection_config.get("post_event_seconds", 20)), 1498 + pre_seconds=pre_seconds, 1499 + post_seconds=post_seconds, 1500 + max_seconds=clip_seconds, 1444 1501 ) 1445 1502 1446 1503 self.statusBar().showMessage( ··· 1709 1766 self.detection_conf_label.setText(tr("label.detection_conf")) 1710 1767 self.detection_fps_label.setText(tr("label.detection_fps")) 1711 1768 self.detection_cooldown_label.setText(tr("label.detection_cooldown")) 1769 + self.detection_suppress_label.setText(tr("label.detection_suppress")) 1770 + self.detection_clip_label.setText(tr("label.detection_clip")) 1712 1771 self.detection_classes_label.setText(tr("label.detection_classes")) 1713 1772 if hasattr(self, "detection_model_test_btn"): 1714 1773 self.detection_model_test_btn.setText(tr("btn.model_test"))
+21 -6
stream.py
··· 37 37 self.event_writer = None 38 38 self._event_clip_filename = None 39 39 self._event_clip_until = 0.0 40 + self._event_clip_started = 0.0 40 41 self._writer_lock = threading.Lock() 41 42 self._buffer_lock = threading.Lock() 42 43 self._frame_buffer = deque() ··· 267 268 self.event_writer = None 268 269 self._event_clip_filename = None 269 270 self._event_clip_until = 0.0 271 + self._event_clip_started = 0.0 270 272 271 273 def start_recording(self, output_path): 272 274 """Starte Aufzeichnung""" ··· 310 312 return filename 311 313 return None 312 314 313 - def start_event_clip(self, output_path, label: str, pre_seconds: float = 8.0, post_seconds: float = 20.0): 315 + def start_event_clip( 316 + self, 317 + output_path, 318 + label: str, 319 + pre_seconds: float = 8.0, 320 + post_seconds: float = 20.0, 321 + max_seconds: float = 180.0, 322 + ): 314 323 """Start a short event clip from the rolling frame buffer plus future frames.""" 315 324 if not (self.cap and self.cap.isOpened()): 316 325 return None 317 326 318 327 with self._writer_lock: 319 328 now = time.monotonic() 329 + max_seconds = min(180.0, max(1.0, float(max_seconds))) 330 + post_seconds = max(1.0, min(float(post_seconds), max_seconds)) 320 331 if self.event_writer is not None and now < self._event_clip_until: 321 - self._event_clip_until = max(self._event_clip_until, now + max(1.0, float(post_seconds))) 332 + max_until = (self._event_clip_started or now) + max_seconds 333 + self._event_clip_until = min(max_until, max(self._event_clip_until, now + post_seconds)) 322 334 return self._event_clip_filename 323 335 324 336 with self._buffer_lock: 325 - buffered = [ 326 - frame 337 + buffered_items = [ 338 + (frame_time, frame) 327 339 for frame_time, frame in self._frame_buffer 328 - if frame_time >= now - max(0.0, float(pre_seconds)) 340 + if frame_time >= now - max(0.0, min(float(pre_seconds), max_seconds)) 329 341 ] 342 + buffered = [frame for _frame_time, frame in buffered_items] 343 + actual_pre_seconds = now - buffered_items[0][0] if buffered_items else 0.0 330 344 331 345 fps = float(self.cap.get(cv2.CAP_PROP_FPS)) 332 346 if not fps or fps <= 0 or fps > 120: ··· 355 369 356 370 self.event_writer = vw 357 371 self._event_clip_filename = filename 358 - self._event_clip_until = now + max(1.0, float(post_seconds)) 372 + self._event_clip_started = now - max(0.0, actual_pre_seconds) 373 + self._event_clip_until = min(self._event_clip_started + max_seconds, now + post_seconds) 359 374 return filename 360 375 361 376 def stop_recording(self):