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 #3 from jeamy/yolo

yolo 2

author
jeamylee
committer
GitHub
date (Jun 2, 2026, 5:24 PM +0200) commit 37c72692 parent 3fc8f1e3
+202 -5
+12 -4
.github/workflows/build.yml
··· 143 143 path: dist/*_${{ matrix.platform }}_*.zip 144 144 if-no-files-found: error 145 145 146 + - name: Prepare Linux release assets 147 + if: runner.os == 'Linux' 148 + run: | 149 + python scripts/prepare_release_assets.py --platform "${{ matrix.platform }}" --include-appimage 150 + 151 + - name: Prepare non-Linux release assets 152 + if: runner.os != 'Linux' 153 + run: | 154 + python scripts/prepare_release_assets.py --platform "${{ matrix.platform }}" 155 + 146 156 - name: Publish Linux release assets 147 157 if: runner.os == 'Linux' 148 158 uses: softprops/action-gh-release@v2 149 159 with: 150 160 tag_name: ${{ needs.prepare.outputs.tag }} 151 161 target_commitish: ${{ needs.prepare.outputs.target_sha }} 152 - files: | 153 - dist/*_${{ matrix.platform }}_*.zip 154 - dist/*_${{ matrix.platform }}_*.AppImage 162 + files: dist/release-assets/* 155 163 generate_release_notes: true 156 164 overwrite_files: true 157 165 ··· 161 169 with: 162 170 tag_name: ${{ needs.prepare.outputs.tag }} 163 171 target_commitish: ${{ needs.prepare.outputs.target_sha }} 164 - files: dist/*_${{ matrix.platform }}_*.zip 172 + files: dist/release-assets/* 165 173 generate_release_notes: true 166 174 overwrite_files: true
+7
README.md
··· 192 192 "event_clip_seconds": 30, 193 193 "pre_event_seconds": 8, 194 194 "post_event_seconds": 20, 195 + "motion_required_classes": ["bicycle", "car", "motorcycle", "bus", "truck"], 196 + "motion_min_pixels": 12.0, 195 197 "classes": ["person", "car", "truck", "dog", "cat", "bird"] 196 198 }, 197 199 "email": { ··· 233 235 234 236 `event_clip_seconds` controls the total event video length and is capped at 235 237 180 seconds. `pre_event_seconds` is included in that total length. 238 + 239 + Classes listed in `motion_required_classes` only trigger an event when the 240 + detected bounding box moves by at least `motion_min_pixels` between analyzed 241 + frames. By default this is enabled for vehicle classes, so parked cars do not 242 + create repeated alerts. 236 243 237 244 Pretrained COCO classes include `person`, vehicles, and common animals such as 238 245 `dog`, `cat`, `bird`, `horse`, `sheep`, and `cow`. Wild animals such as deer,
+8
camera_config.json.example
··· 33 33 "event_clip_seconds": 30, 34 34 "pre_event_seconds": 8, 35 35 "post_event_seconds": 20, 36 + "motion_required_classes": [ 37 + "bicycle", 38 + "car", 39 + "motorcycle", 40 + "bus", 41 + "truck" 42 + ], 43 + "motion_min_pixels": 12.0, 36 44 "classes": [ 37 45 "person", 38 46 "bicycle",
+24
detection.py
··· 1 1 import threading 2 2 import time 3 + import math 3 4 from dataclasses import dataclass 4 5 from pathlib import Path 5 6 import shutil ··· 35 36 "event_clip_seconds": 30, 36 37 "pre_event_seconds": 8, 37 38 "post_event_seconds": 20, 39 + "motion_required_classes": [ 40 + "bicycle", 41 + "car", 42 + "motorcycle", 43 + "bus", 44 + "truck", 45 + ], 46 + "motion_min_pixels": 12.0, 38 47 "classes": [ 39 48 "person", 40 49 "bicycle", ··· 161 170 self._stable_hits: dict[tuple[int, str], int] = {} 162 171 self._last_event: dict[tuple[int, str], float] = {} 163 172 self._last_camera_event: dict[int, float] = {} 173 + self._last_motion_box: dict[tuple[int, str], tuple[float, float]] = {} 164 174 self._model = None 165 175 self._names: dict[int, str] = {} 166 176 self._device = "cpu" ··· 287 297 stable_frames = max(1, int(self.config.get("stable_frames", 2))) 288 298 cooldown = max(0.0, float(self.config.get("cooldown_seconds", 180))) 289 299 event_suppress = max(0.0, float(self.config.get("event_suppress_seconds", 30))) 300 + motion_required = set(self.config.get("motion_required_classes") or []) 301 + motion_min_pixels = max(0.0, float(self.config.get("motion_min_pixels", 12.0))) 290 302 eligible: list[tuple[str, dict[str, Any]]] = [] 291 303 292 304 if now - self._last_camera_event.get(camera_id, 0.0) < event_suppress: ··· 295 307 296 308 for label, best in best_by_label.items(): 297 309 key = (camera_id, label) 310 + if motion_required and label in motion_required and not self._has_required_motion(key, best, motion_min_pixels): 311 + self._stable_hits[key] = 0 312 + continue 298 313 self._stable_hits[key] = self._stable_hits.get(key, 0) + 1 299 314 if self._stable_hits[key] < stable_frames: 300 315 continue ··· 336 351 for key in list(self._stable_hits.keys()): 337 352 if key[0] == camera_id and key[1] != active_label: 338 353 self._stable_hits[key] = 0 354 + 355 + def _has_required_motion(self, key: tuple[int, str], detection: dict[str, Any], min_pixels: float) -> bool: 356 + x1, y1, x2, y2 = detection["box"] 357 + center = ((x1 + x2) / 2.0, (y1 + y2) / 2.0) 358 + previous = self._last_motion_box.get(key) 359 + self._last_motion_box[key] = center 360 + if previous is None: 361 + return False 362 + return math.hypot(center[0] - previous[0], center[1] - previous[1]) >= min_pixels 339 363 340 364 def _annotate(self, frame, detections: list[dict[str, Any]]): 341 365 annotated = frame.copy()
+4
i18n.py
··· 20 20 "label.detection_suppress": "Event-Sperre s:", 21 21 "label.detection_clip": "Clip-Länge s:", 22 22 "label.detection_classes": "Klassen:", 23 + "label.detection_motion_classes": "Bewegung nötig für:", 24 + "label.detection_motion_pixels": "Min. Bewegung px:", 23 25 "detection.device.auto": "Auto", 24 26 "detection.device.cuda": "CUDA", 25 27 "detection.device.cpu": "CPU", ··· 178 180 "label.detection_suppress": "Event lock s:", 179 181 "label.detection_clip": "Clip length s:", 180 182 "label.detection_classes": "Classes:", 183 + "label.detection_motion_classes": "Motion required for:", 184 + "label.detection_motion_pixels": "Min. motion px:", 181 185 "detection.device.auto": "Auto", 182 186 "detection.device.cuda": "CUDA", 183 187 "detection.device.cpu": "CPU",
+32
main_window.py
··· 388 388 row3.addWidget(self.detection_classes_input) 389 389 layout.addLayout(row3) 390 390 391 + row4 = QHBoxLayout() 392 + self.detection_motion_classes_label = QLabel(tr("label.detection_motion_classes")) 393 + self.detection_motion_classes_input = QLineEdit(", ".join(self.detection_config.get("motion_required_classes", []))) 394 + row4.addWidget(self.detection_motion_classes_label) 395 + row4.addWidget(self.detection_motion_classes_input) 396 + 397 + self.detection_motion_pixels_label = QLabel(tr("label.detection_motion_pixels")) 398 + self.detection_motion_pixels_spin = QDoubleSpinBox() 399 + self.detection_motion_pixels_spin.setRange(0.0, 200.0) 400 + self.detection_motion_pixels_spin.setSingleStep(1.0) 401 + self.detection_motion_pixels_spin.setDecimals(1) 402 + self.detection_motion_pixels_spin.setValue(float(self.detection_config.get("motion_min_pixels", 12.0))) 403 + row4.addWidget(self.detection_motion_pixels_label) 404 + row4.addWidget(self.detection_motion_pixels_spin) 405 + layout.addLayout(row4) 406 + 391 407 group.setLayout(layout) 392 408 for widget in ( 393 409 self.detection_model_input, ··· 399 415 self.detection_suppress_spin, 400 416 self.detection_clip_spin, 401 417 self.detection_classes_input, 418 + self.detection_motion_classes_input, 419 + self.detection_motion_pixels_spin, 402 420 ): 403 421 if isinstance(widget, QLineEdit): 404 422 widget.editingFinished.connect(self._save_detection_config_from_ui) ··· 496 514 for item in self.detection_classes_input.text().split(",") 497 515 if item.strip() 498 516 ] 517 + motion_classes = [ 518 + item.strip() 519 + for item in self.detection_motion_classes_input.text().split(",") 520 + if item.strip() 521 + ] 499 522 self.detection_config.update({ 500 523 "model": self.detection_model_input.text().strip() or "yolo11n.pt", 501 524 "device": self.detection_device_combo.currentData() or "auto", ··· 506 529 "event_suppress_seconds": int(self.detection_suppress_spin.value()), 507 530 "event_clip_seconds": int(self.detection_clip_spin.value()), 508 531 "classes": classes, 532 + "motion_required_classes": motion_classes, 533 + "motion_min_pixels": float(self.detection_motion_pixels_spin.value()), 509 534 "recording_path": self.recording_path, 510 535 "model_dir": str(default_model_dir(self.recording_path)), 511 536 }) ··· 544 569 self.detection_suppress_spin, 545 570 self.detection_clip_spin, 546 571 self.detection_classes_input, 572 + self.detection_motion_classes_input, 573 + self.detection_motion_pixels_spin, 547 574 ] 548 575 for widget in widgets: 549 576 widget.blockSignals(True) ··· 559 586 self.detection_suppress_spin.setValue(int(self.detection_config.get("event_suppress_seconds", 30))) 560 587 self.detection_clip_spin.setValue(int(self.detection_config.get("event_clip_seconds", 30))) 561 588 self.detection_classes_input.setText(", ".join(self.detection_config.get("classes", []))) 589 + self.detection_motion_classes_input.setText(", ".join(self.detection_config.get("motion_required_classes", []))) 590 + self.detection_motion_pixels_spin.setValue(float(self.detection_config.get("motion_min_pixels", 12.0))) 562 591 finally: 563 592 for widget in widgets: 564 593 widget.blockSignals(False) ··· 1495 1524 clip_file = thread.start_event_clip( 1496 1525 self.event_path, 1497 1526 event.label, 1527 + camera_name=event.camera_name, 1498 1528 pre_seconds=pre_seconds, 1499 1529 post_seconds=post_seconds, 1500 1530 max_seconds=clip_seconds, ··· 1769 1799 self.detection_suppress_label.setText(tr("label.detection_suppress")) 1770 1800 self.detection_clip_label.setText(tr("label.detection_clip")) 1771 1801 self.detection_classes_label.setText(tr("label.detection_classes")) 1802 + self.detection_motion_classes_label.setText(tr("label.detection_motion_classes")) 1803 + self.detection_motion_pixels_label.setText(tr("label.detection_motion_pixels")) 1772 1804 if hasattr(self, "detection_model_test_btn"): 1773 1805 self.detection_model_test_btn.setText(tr("btn.model_test")) 1774 1806 current_device = self.detection_device_combo.currentData()
+111
scripts/prepare_release_assets.py
··· 1 + #!/usr/bin/env python3 2 + """Prepare GitHub release assets without exceeding the per-asset size limit.""" 3 + 4 + from __future__ import annotations 5 + 6 + import argparse 7 + import glob 8 + import hashlib 9 + from pathlib import Path 10 + import shutil 11 + 12 + 13 + DEFAULT_MAX_ASSET_SIZE = 1900 * 1024 * 1024 14 + BUFFER_SIZE = 8 * 1024 * 1024 15 + 16 + 17 + def file_sha256(path: Path) -> str: 18 + digest = hashlib.sha256() 19 + with path.open("rb") as handle: 20 + for chunk in iter(lambda: handle.read(BUFFER_SIZE), b""): 21 + digest.update(chunk) 22 + return digest.hexdigest() 23 + 24 + 25 + def split_file(source: Path, release_dir: Path, max_asset_size: int) -> list[Path]: 26 + parts: list[Path] = [] 27 + part_number = 1 28 + 29 + with source.open("rb") as handle: 30 + while True: 31 + target = release_dir / f"{source.name}.part{part_number:02d}" 32 + written = 0 33 + 34 + with target.open("wb") as output: 35 + while written < max_asset_size: 36 + chunk = handle.read(min(BUFFER_SIZE, max_asset_size - written)) 37 + if not chunk: 38 + break 39 + output.write(chunk) 40 + written += len(chunk) 41 + 42 + if written == 0: 43 + target.unlink(missing_ok=True) 44 + break 45 + 46 + parts.append(target) 47 + part_number += 1 48 + 49 + return parts 50 + 51 + 52 + def find_sources(platform: str, include_appimage: bool) -> list[Path]: 53 + patterns = [f"dist/*_{platform}_*.zip"] 54 + if include_appimage: 55 + patterns.append(f"dist/*_{platform}_*.AppImage") 56 + 57 + return [Path(path) for pattern in patterns for path in glob.glob(pattern)] 58 + 59 + 60 + def prepare_release_assets(platform: str, include_appimage: bool, max_asset_size: int) -> None: 61 + release_dir = Path("dist") / "release-assets" 62 + release_dir.mkdir(parents=True, exist_ok=True) 63 + 64 + sources = find_sources(platform, include_appimage) 65 + if not sources: 66 + raise SystemExit("No release assets found") 67 + 68 + checksums: list[str] = [] 69 + split_assets: list[str] = [] 70 + 71 + for source in sources: 72 + if source.stat().st_size < max_asset_size: 73 + target = release_dir / source.name 74 + shutil.copy2(source, target) 75 + checksums.append(f"{file_sha256(target)} {target.name}") 76 + continue 77 + 78 + split_assets.append(source.name) 79 + for part in split_file(source, release_dir, max_asset_size): 80 + checksums.append(f"{file_sha256(part)} {part.name}") 81 + 82 + checksum_path = release_dir / f"wildcam_{platform}_SHA256SUMS.txt" 83 + checksum_path.write_text("\n".join(checksums) + "\n", encoding="utf-8") 84 + 85 + if split_assets: 86 + note_path = release_dir / f"wildcam_{platform}_split_assets.txt" 87 + note_path.write_text( 88 + "Some release assets exceeded GitHub's 2 GiB per-file limit and were split.\n" 89 + "Reassemble a split asset with: cat <asset>.part* > <asset>\n" 90 + "Split assets:\n" 91 + + "\n".join(f"- {asset}" for asset in split_assets) 92 + + "\n", 93 + encoding="utf-8", 94 + ) 95 + 96 + 97 + def parse_args() -> argparse.Namespace: 98 + parser = argparse.ArgumentParser() 99 + parser.add_argument("--platform", required=True) 100 + parser.add_argument("--include-appimage", action="store_true") 101 + parser.add_argument("--max-asset-size", type=int, default=DEFAULT_MAX_ASSET_SIZE) 102 + return parser.parse_args() 103 + 104 + 105 + def main() -> None: 106 + args = parse_args() 107 + prepare_release_assets(args.platform, args.include_appimage, args.max_asset_size) 108 + 109 + 110 + if __name__ == "__main__": 111 + main()
+4 -1
stream.py
··· 316 316 self, 317 317 output_path, 318 318 label: str, 319 + camera_name: str = "", 319 320 pre_seconds: float = 8.0, 320 321 post_seconds: float = 20.0, 321 322 max_seconds: float = 180.0, ··· 355 356 os.makedirs(output_path, exist_ok=True) 356 357 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 357 358 safe_label = "".join(c if (c.isalnum() or c in "-_") else "_" for c in str(label)) 358 - filename = os.path.join(output_path, f"event_{self.camera_id}_{safe_label}_{timestamp}.avi") 359 + safe_camera = "".join(c if (c.isalnum() or c in "-_") else "_" for c in str(camera_name)) 360 + prefix = f"event_{safe_camera}_{self.camera_id}" if safe_camera else f"event_{self.camera_id}" 361 + filename = os.path.join(output_path, f"{prefix}_{safe_label}_{timestamp}.avi") 359 362 360 363 fourcc = cv2.VideoWriter_fourcc(*'MJPG') 361 364 vw = cv2.VideoWriter(filename, fourcc, fps, (width, height))