About Multi-camera viewer optimized for RTSP streams
0

Configure Feed

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

at master 96 kB View raw
1import os 2 3os.environ['OPENCV_FFMPEG_LOGLEVEL'] = '-8' 4os.environ['OPENCV_LOG_LEVEL'] = 'SILENT' 5os.environ['AV_LOG_FORCE_NOCOLOR'] = '1' 6os.environ['AV_LOG_FORCE_LEVEL'] = '-8' 7os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|loglevel;quiet' 8 9import cv2 10import numpy as np 11import threading 12from PyQt6.QtWidgets import ( 13 QComboBox, 14 QCheckBox, 15 QDialog, 16 QDoubleSpinBox, 17 QFileDialog, 18 QGroupBox, 19 QHBoxLayout, 20 QLabel, 21 QLineEdit, 22 QMainWindow, 23 QMessageBox, 24 QProgressDialog, 25 QPushButton, 26 QScrollArea, 27 QSizePolicy, 28 QSpinBox, 29 QSplitter, 30 QStyle, 31 QTabWidget, 32 QVBoxLayout, 33 QWidget, 34) 35from PyQt6.QtCore import QEvent, QRect, QSize, Qt, QTimer, pyqtSignal 36from PyQt6.QtGui import QImage, QPixmap 37from datetime import datetime 38import time 39 40from camera_utils import ( 41 _build_rtsp_url, 42 _is_battery_camera, 43 normalize_reolinkproxy_camera, 44) 45from config import DEFAULT_RECORDING_PATH, config_payload, load_config_data, save_config_data, snapshot_path_for 46from detection import DEFAULT_DETECTION_CONFIG, DetectionWorker, default_model_dir, prepare_model_path 47from dialogs import CameraDiscoveryDialog, CameraEditDialog 48from i18n import set_language, tr 49from notifications import DEFAULT_EMAIL_CONFIG, send_detection_email 50from stream import CameraThread 51from ui_resources import load_svg_icon 52from widgets import CameraListContainer, CameraWidget, PreviewLabel 53 54try: 55 import sip # type: ignore 56except Exception: # pragma: no cover 57 try: 58 from PyQt6 import sip # type: ignore 59 except Exception: # pragma: no cover 60 sip = None 61 62 63class MainWindow(QMainWindow): 64 """Hauptfenster der Anwendung""" 65 email_test_finished = pyqtSignal(bool, str) 66 model_test_finished = pyqtSignal(bool, str, str) 67 68 def __init__(self): 69 super().__init__() 70 self.language = "de" 71 set_language(self.language) 72 self.setWindowTitle(tr("app.title")) 73 self.setGeometry(100, 100, 1200, 800) 74 75 self.cameras = [] 76 self.camera_threads = {} # Dict für parallele Thread-Verwaltung 77 self.camera_widgets = {} # Dict für Widget-Zugriff 78 self.recording_path = DEFAULT_RECORDING_PATH 79 self.snapshot_path = snapshot_path_for(self.recording_path) 80 self.event_path = os.path.join(self.recording_path, "events") 81 self.detection_config = dict(DEFAULT_DETECTION_CONFIG) 82 self.email_config = dict(DEFAULT_EMAIL_CONFIG) 83 self.detection_worker = None 84 self._model_retry_delays = [30, 120, 300] 85 self._model_retry_attempt = 0 86 self._model_retry_scheduled = False 87 self.cameras_per_row = 3 # Standard: 3 Kameras pro Reihe 88 self.next_camera_id = 1 89 self.selected_camera_id = None 90 self.selected_camera_ids = [] # Multi-Kamera-Auswahl 91 self._restore_preview_camera_ids = [] 92 self.multi_view_labels = {} # Labels für Multi-Kamera-Ansicht 93 self.zoomed_camera_id = None 94 self.preview_crop_camera_id = None 95 self.preview_crop_rect = None 96 self._rebuilding_camera_list = False 97 self._closing = False 98 self._shutdown_started_at = None 99 self._shutdown_dialog = None 100 self._order_custom = False 101 self.email_test_finished.connect(self._on_email_test_finished) 102 self.model_test_finished.connect(self._on_model_test_finished) 103 104 # Erstelle Aufzeichnungsordner 105 os.makedirs(self.recording_path, exist_ok=True) 106 os.makedirs(self.snapshot_path, exist_ok=True) 107 os.makedirs(self.event_path, exist_ok=True) 108 109 self.init_ui() 110 self.load_config() 111 112 def init_ui(self): 113 """UI initialisieren""" 114 central_widget = QWidget() 115 self.setCentralWidget(central_widget) 116 main_layout = QVBoxLayout(central_widget) 117 118 tabs = QTabWidget() 119 tab_cameras = QWidget() 120 tab_config = QWidget() 121 self.tabs = tabs 122 tabs.addTab(tab_cameras, tr("tab.cameras")) 123 tabs.addTab(tab_config, tr("tab.config")) 124 main_layout.addWidget(tabs) 125 126 cameras_layout = QVBoxLayout(tab_cameras) 127 config_tab_layout = QVBoxLayout(tab_config) 128 cameras_layout.setContentsMargins(6, 6, 6, 6) 129 cameras_layout.setSpacing(6) 130 131 # Konfigurations-Panel 132 config_group = QGroupBox(tr("group.camera_config")) 133 self.config_group = config_group 134 config_layout = QVBoxLayout() 135 136 # Erste Zeile: URL und Name 137 row1 = QHBoxLayout() 138 self.url_label = QLabel(tr("label.rtsp_url")) 139 row1.addWidget(self.url_label) 140 self.url_input = QLineEdit() 141 self.url_input.setPlaceholderText(tr("placeholder.rtsp_url")) 142 self.url_input.setMinimumWidth(400) 143 row1.addWidget(self.url_input) 144 145 self.name_label = QLabel(tr("label.name")) 146 row1.addWidget(self.name_label) 147 self.name_input = QLineEdit() 148 self.name_input.setPlaceholderText(tr("placeholder.name.short")) 149 self.name_input.setMaximumWidth(150) 150 row1.addWidget(self.name_input) 151 152 self.uid_label = QLabel(tr("label.uid")) 153 row1.addWidget(self.uid_label) 154 self.uid_input = QLineEdit() 155 self.uid_input.setPlaceholderText(tr("placeholder.uid")) 156 self.uid_input.setMaximumWidth(150) 157 row1.addWidget(self.uid_input) 158 159 self.add_btn = QPushButton(tr("btn.add")) 160 add_btn = self.add_btn 161 add_btn.clicked.connect(self.add_camera) 162 add_btn.setIcon(load_svg_icon("plus.svg")) 163 add_btn.setIconSize(QSize(18, 18)) 164 row1.addWidget(add_btn) 165 166 self.discover_btn = QPushButton(tr("btn.discover")) 167 discover_btn = self.discover_btn 168 discover_btn.clicked.connect(self.show_discovery_dialog) 169 discover_btn.setIcon(load_svg_icon("search.svg")) 170 discover_btn.setIconSize(QSize(18, 18)) 171 discover_btn.setStyleSheet("background-color: #1976d2; color: white; font-weight: bold;") 172 row1.addWidget(discover_btn) 173 174 config_layout.addLayout(row1) 175 176 # Zweite Zeile: Grid-Einstellungen und Aktionen 177 row2 = QHBoxLayout() 178 self.grid_cols_label = QLabel(tr("label.cameras_per_row")) 179 row2.addWidget(self.grid_cols_label) 180 self.grid_cols_spin = QSpinBox() 181 self.grid_cols_spin.setRange(1, 5) 182 self.grid_cols_spin.setValue(3) 183 self.grid_cols_spin.valueChanged.connect(self.update_grid_layout) 184 row2.addWidget(self.grid_cols_spin) 185 186 self.clear_btn = QPushButton(tr("btn.clear_all")) 187 clear_btn = self.clear_btn 188 clear_btn.clicked.connect(self.clear_cameras) 189 row2.addWidget(clear_btn) 190 191 self.path_btn = QPushButton(tr("btn.path")) 192 path_btn = self.path_btn 193 path_btn.clicked.connect(self.select_recording_path) 194 path_btn.setIcon(load_svg_icon("folder.svg")) 195 path_btn.setIconSize(QSize(18, 18)) 196 row2.addWidget(path_btn) 197 198 self.language_label = QLabel(tr("label.language")) 199 row2.addWidget(self.language_label) 200 self.language_combo = QComboBox() 201 self.language_combo.addItem(tr("language.de"), "de") 202 self.language_combo.addItem(tr("language.en"), "en") 203 self.language_combo.currentIndexChanged.connect(self._on_language_changed) 204 self.language_combo.setMaximumWidth(140) 205 row2.addWidget(self.language_combo) 206 207 row2.addStretch() 208 config_layout.addLayout(row2) 209 210 config_group.setLayout(config_layout) 211 config_tab_layout.addWidget(config_group) 212 config_tab_layout.addWidget(self._create_detection_config_group()) 213 config_tab_layout.addWidget(self._create_email_config_group()) 214 config_tab_layout.addStretch() 215 216 # Control Panel 217 control_widget = QWidget() 218 control_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 219 control_widget.setFixedHeight(36) 220 221 control_layout = QHBoxLayout(control_widget) 222 control_layout.setContentsMargins(0, 0, 0, 0) 223 control_layout.setSpacing(6) 224 225 self.start_all_btn = QPushButton(tr("btn.start_all")) 226 self.start_all_btn.clicked.connect(self.start_all_streams) 227 self.start_all_btn.setIcon(load_svg_icon("play.svg")) 228 self.start_all_btn.setIconSize(QSize(18, 18)) 229 self.start_all_btn.setStyleSheet("font-weight: bold; padding: 4px 10px;") 230 self.start_all_btn.setFixedHeight(28) 231 232 self.stop_all_btn = QPushButton(tr("btn.stop_all")) 233 self.stop_all_btn.clicked.connect(self.stop_all_streams) 234 self.stop_all_btn.setIcon(load_svg_icon("stop.svg")) 235 self.stop_all_btn.setIconSize(QSize(18, 18)) 236 self.stop_all_btn.setFixedHeight(28) 237 238 self.record_all_btn = QPushButton(tr("btn.record_all")) 239 self.record_all_btn.setCheckable(True) 240 self.record_all_btn.clicked.connect(self.toggle_all_recording) 241 self.record_all_btn.setIcon(load_svg_icon("record.svg")) 242 self.record_all_btn.setIconSize(QSize(18, 18)) 243 self.record_all_btn.setFixedHeight(28) 244 245 self.camera_count_label = QLabel(tr("label.camera_count", total=0, active=0)) 246 self.camera_count_label.setStyleSheet("font-weight: bold;") 247 248 control_layout.addWidget(self.start_all_btn) 249 control_layout.addWidget(self.stop_all_btn) 250 control_layout.addWidget(self.record_all_btn) 251 control_layout.addStretch() 252 control_layout.addWidget(self.camera_count_label) 253 254 cameras_layout.addWidget(control_widget) 255 256 self.grid_cols_label.setVisible(False) 257 self.grid_cols_spin.setVisible(False) 258 259 content_layout = QHBoxLayout() 260 content_layout.setContentsMargins(0, 0, 0, 0) 261 262 splitter = QSplitter(Qt.Orientation.Horizontal) 263 splitter.setChildrenCollapsible(False) 264 self.content_splitter = splitter 265 266 self.left_scroll = QScrollArea() 267 self.left_scroll.setWidgetResizable(True) 268 self.left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 269 self.left_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 270 self.left_scroll.setMinimumWidth(260) 271 272 self.camera_list_container = CameraListContainer() 273 self.camera_list_container.order_changed.connect(self._on_camera_order_changed) 274 self.left_scroll.setWidget(self.camera_list_container) 275 276 splitter.addWidget(self.left_scroll) 277 278 self.big_preview_container = QWidget() 279 self.big_preview_layout = QVBoxLayout(self.big_preview_container) 280 self.big_preview_layout.setContentsMargins(10, 0, 0, 0) 281 self.big_preview_label = PreviewLabel() 282 self.big_preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 283 self.big_preview_label.setText(tr("big.select_camera")) 284 self.big_preview_label.setStyleSheet(self._preview_label_style()) 285 self.big_preview_label.setMinimumHeight(360) 286 self.big_preview_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) 287 self._connect_preview_label(self.big_preview_label) 288 self.big_preview_layout.addWidget(self.big_preview_label) 289 290 splitter.addWidget(self.big_preview_container) 291 splitter.setStretchFactor(0, 1) 292 splitter.setStretchFactor(1, 4) 293 splitter.setSizes([300, 900]) 294 295 content_layout.addWidget(splitter) 296 cameras_layout.addLayout(content_layout) 297 298 # Status Bar 299 self.statusBar().showMessage(tr("status.ready")) 300 301 # Timer für Status-Updates 302 self.status_timer = QTimer() 303 self.status_timer.timeout.connect(self.update_status_display) 304 self.status_timer.start(2000) # Alle 2 Sekunden 305 306 def _create_detection_config_group(self): 307 group = QGroupBox(tr("group.detection_config")) 308 self.detection_group = group 309 layout = QVBoxLayout() 310 311 row1 = QHBoxLayout() 312 self.detection_model_label = QLabel(tr("label.detection_model")) 313 self.detection_model_input = QLineEdit(str(self.detection_config.get("model", "yolo11n.pt"))) 314 self.detection_model_input.setMaximumWidth(180) 315 row1.addWidget(self.detection_model_label) 316 row1.addWidget(self.detection_model_input) 317 self.detection_model_test_btn = QPushButton(tr("btn.model_test")) 318 self.detection_model_test_btn.clicked.connect(self.test_detection_model) 319 row1.addWidget(self.detection_model_test_btn) 320 321 self.detection_device_label = QLabel(tr("label.detection_device")) 322 self.detection_device_combo = QComboBox() 323 for label, value in ( 324 (tr("detection.device.auto"), "auto"), 325 (tr("detection.device.cuda"), "cuda:0"), 326 (tr("detection.device.cpu"), "cpu"), 327 ): 328 self.detection_device_combo.addItem(label, value) 329 row1.addWidget(self.detection_device_label) 330 row1.addWidget(self.detection_device_combo) 331 332 self.detection_imgsz_label = QLabel(tr("label.detection_imgsz")) 333 self.detection_imgsz_spin = QSpinBox() 334 self.detection_imgsz_spin.setRange(320, 1536) 335 self.detection_imgsz_spin.setSingleStep(32) 336 self.detection_imgsz_spin.setValue(int(self.detection_config.get("imgsz", 640))) 337 row1.addWidget(self.detection_imgsz_label) 338 row1.addWidget(self.detection_imgsz_spin) 339 row1.addStretch() 340 layout.addLayout(row1) 341 342 row2 = QHBoxLayout() 343 self.detection_conf_label = QLabel(tr("label.detection_conf")) 344 self.detection_conf_spin = QDoubleSpinBox() 345 self.detection_conf_spin.setRange(0.05, 0.95) 346 self.detection_conf_spin.setSingleStep(0.05) 347 self.detection_conf_spin.setDecimals(2) 348 self.detection_conf_spin.setValue(float(self.detection_config.get("confidence", 0.4))) 349 row2.addWidget(self.detection_conf_label) 350 row2.addWidget(self.detection_conf_spin) 351 352 self.detection_fps_label = QLabel(tr("label.detection_fps")) 353 self.detection_fps_spin = QDoubleSpinBox() 354 self.detection_fps_spin.setRange(0.2, 15.0) 355 self.detection_fps_spin.setSingleStep(0.5) 356 self.detection_fps_spin.setDecimals(1) 357 self.detection_fps_spin.setValue(float(self.detection_config.get("analysis_fps_per_camera", 3.0))) 358 row2.addWidget(self.detection_fps_label) 359 row2.addWidget(self.detection_fps_spin) 360 361 self.detection_cooldown_label = QLabel(tr("label.detection_cooldown")) 362 self.detection_cooldown_spin = QSpinBox() 363 self.detection_cooldown_spin.setRange(0, 3600) 364 self.detection_cooldown_spin.setValue(int(self.detection_config.get("cooldown_seconds", 180))) 365 row2.addWidget(self.detection_cooldown_label) 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) 381 row2.addStretch() 382 layout.addLayout(row2) 383 384 row3 = QHBoxLayout() 385 self.detection_classes_label = QLabel(tr("label.detection_classes")) 386 self.detection_classes_input = QLineEdit(", ".join(self.detection_config.get("classes", []))) 387 row3.addWidget(self.detection_classes_label) 388 row3.addWidget(self.detection_classes_input) 389 layout.addLayout(row3) 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 407 group.setLayout(layout) 408 for widget in ( 409 self.detection_model_input, 410 self.detection_device_combo, 411 self.detection_imgsz_spin, 412 self.detection_conf_spin, 413 self.detection_fps_spin, 414 self.detection_cooldown_spin, 415 self.detection_suppress_spin, 416 self.detection_clip_spin, 417 self.detection_classes_input, 418 self.detection_motion_classes_input, 419 self.detection_motion_pixels_spin, 420 ): 421 if isinstance(widget, QLineEdit): 422 widget.editingFinished.connect(self._save_detection_config_from_ui) 423 else: 424 widget.valueChanged.connect(self._save_detection_config_from_ui) if hasattr(widget, "valueChanged") else widget.currentIndexChanged.connect(self._save_detection_config_from_ui) 425 return group 426 427 def _create_email_config_group(self): 428 group = QGroupBox(tr("group.email_config")) 429 self.email_group = group 430 layout = QVBoxLayout() 431 432 row1 = QHBoxLayout() 433 self.email_enabled_check = QCheckBox(tr("label.email_enabled")) 434 self.email_enabled_check.setChecked(bool(self.email_config.get("enabled", False))) 435 row1.addWidget(self.email_enabled_check) 436 self.email_host_label = QLabel(tr("label.smtp_host")) 437 self.email_host_input = QLineEdit(str(self.email_config.get("smtp_host", ""))) 438 row1.addWidget(self.email_host_label) 439 row1.addWidget(self.email_host_input) 440 self.email_port_label = QLabel(tr("label.smtp_port")) 441 self.email_port_spin = QSpinBox() 442 self.email_port_spin.setRange(1, 65535) 443 self.email_port_spin.setValue(int(self.email_config.get("smtp_port", 587))) 444 row1.addWidget(self.email_port_label) 445 row1.addWidget(self.email_port_spin) 446 self.email_tls_check = QCheckBox(tr("label.smtp_tls")) 447 self.email_tls_check.setChecked(bool(self.email_config.get("use_tls", True))) 448 row1.addWidget(self.email_tls_check) 449 layout.addLayout(row1) 450 451 row2 = QHBoxLayout() 452 self.email_user_label = QLabel(tr("label.smtp_username")) 453 self.email_user_input = QLineEdit(str(self.email_config.get("smtp_username", ""))) 454 row2.addWidget(self.email_user_label) 455 row2.addWidget(self.email_user_input) 456 self.email_password_label = QLabel(tr("label.smtp_password")) 457 self.email_password_input = QLineEdit(str(self.email_config.get("smtp_password", ""))) 458 self.email_password_input.setEchoMode(QLineEdit.EchoMode.Password) 459 row2.addWidget(self.email_password_label) 460 row2.addWidget(self.email_password_input) 461 layout.addLayout(row2) 462 463 row3 = QHBoxLayout() 464 self.email_from_label = QLabel(tr("label.email_from")) 465 self.email_from_input = QLineEdit(str(self.email_config.get("from", ""))) 466 row3.addWidget(self.email_from_label) 467 row3.addWidget(self.email_from_input) 468 self.email_to_label = QLabel(tr("label.email_to")) 469 to_value = self.email_config.get("to", []) 470 if isinstance(to_value, list): 471 to_value = ", ".join(to_value) 472 self.email_to_input = QLineEdit(str(to_value)) 473 row3.addWidget(self.email_to_label) 474 row3.addWidget(self.email_to_input) 475 self.email_test_btn = QPushButton(tr("btn.email_test")) 476 self.email_test_btn.clicked.connect(self.send_test_email) 477 row3.addWidget(self.email_test_btn) 478 layout.addLayout(row3) 479 480 group.setLayout(layout) 481 for widget in ( 482 self.email_enabled_check, 483 self.email_tls_check, 484 self.email_host_input, 485 self.email_port_spin, 486 self.email_user_input, 487 self.email_password_input, 488 self.email_from_input, 489 self.email_to_input, 490 ): 491 if isinstance(widget, QLineEdit): 492 widget.editingFinished.connect(self._save_email_config_from_ui) 493 elif isinstance(widget, QCheckBox): 494 widget.stateChanged.connect(self._save_email_config_from_ui) 495 else: 496 widget.valueChanged.connect(self._save_email_config_from_ui) 497 return group 498 499 def _is_qobject_deleted(self, obj) -> bool: 500 if obj is None: 501 return True 502 if sip is None: 503 return False 504 try: 505 return bool(sip.isdeleted(obj)) 506 except Exception: 507 return False 508 509 def _save_detection_config_from_ui(self): 510 if not hasattr(self, "detection_model_input"): 511 return 512 classes = [ 513 item.strip() 514 for item in self.detection_classes_input.text().split(",") 515 if item.strip() 516 ] 517 motion_classes = [ 518 item.strip() 519 for item in self.detection_motion_classes_input.text().split(",") 520 if item.strip() 521 ] 522 self.detection_config.update({ 523 "model": self.detection_model_input.text().strip() or "yolo11n.pt", 524 "device": self.detection_device_combo.currentData() or "auto", 525 "imgsz": int(self.detection_imgsz_spin.value()), 526 "confidence": float(self.detection_conf_spin.value()), 527 "analysis_fps_per_camera": float(self.detection_fps_spin.value()), 528 "cooldown_seconds": int(self.detection_cooldown_spin.value()), 529 "event_suppress_seconds": int(self.detection_suppress_spin.value()), 530 "event_clip_seconds": int(self.detection_clip_spin.value()), 531 "classes": classes, 532 "motion_required_classes": motion_classes, 533 "motion_min_pixels": float(self.detection_motion_pixels_spin.value()), 534 "recording_path": self.recording_path, 535 "model_dir": str(default_model_dir(self.recording_path)), 536 }) 537 self.save_config() 538 539 def _save_email_config_from_ui(self): 540 if not hasattr(self, "email_enabled_check"): 541 return 542 recipients = [ 543 item.strip() 544 for item in self.email_to_input.text().split(",") 545 if item.strip() 546 ] 547 self.email_config.update({ 548 "enabled": bool(self.email_enabled_check.isChecked()), 549 "smtp_host": self.email_host_input.text().strip(), 550 "smtp_port": int(self.email_port_spin.value()), 551 "smtp_username": self.email_user_input.text().strip(), 552 "smtp_password": self.email_password_input.text(), 553 "use_tls": bool(self.email_tls_check.isChecked()), 554 "from": self.email_from_input.text().strip(), 555 "to": recipients, 556 }) 557 self.save_config() 558 559 def _sync_detection_config_ui(self): 560 if not hasattr(self, "detection_model_input"): 561 return 562 widgets = [ 563 self.detection_model_input, 564 self.detection_device_combo, 565 self.detection_imgsz_spin, 566 self.detection_conf_spin, 567 self.detection_fps_spin, 568 self.detection_cooldown_spin, 569 self.detection_suppress_spin, 570 self.detection_clip_spin, 571 self.detection_classes_input, 572 self.detection_motion_classes_input, 573 self.detection_motion_pixels_spin, 574 ] 575 for widget in widgets: 576 widget.blockSignals(True) 577 try: 578 self.detection_model_input.setText(str(self.detection_config.get("model", "yolo11n.pt"))) 579 idx = self.detection_device_combo.findData(str(self.detection_config.get("device", "auto"))) 580 if idx >= 0: 581 self.detection_device_combo.setCurrentIndex(idx) 582 self.detection_imgsz_spin.setValue(int(self.detection_config.get("imgsz", 640))) 583 self.detection_conf_spin.setValue(float(self.detection_config.get("confidence", 0.4))) 584 self.detection_fps_spin.setValue(float(self.detection_config.get("analysis_fps_per_camera", 3.0))) 585 self.detection_cooldown_spin.setValue(int(self.detection_config.get("cooldown_seconds", 180))) 586 self.detection_suppress_spin.setValue(int(self.detection_config.get("event_suppress_seconds", 30))) 587 self.detection_clip_spin.setValue(int(self.detection_config.get("event_clip_seconds", 30))) 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))) 591 finally: 592 for widget in widgets: 593 widget.blockSignals(False) 594 595 def _sync_email_config_ui(self): 596 if not hasattr(self, "email_enabled_check"): 597 return 598 widgets = [ 599 self.email_enabled_check, 600 self.email_host_input, 601 self.email_port_spin, 602 self.email_tls_check, 603 self.email_user_input, 604 self.email_password_input, 605 self.email_from_input, 606 self.email_to_input, 607 ] 608 for widget in widgets: 609 widget.blockSignals(True) 610 try: 611 self.email_enabled_check.setChecked(bool(self.email_config.get("enabled", False))) 612 self.email_host_input.setText(str(self.email_config.get("smtp_host", ""))) 613 self.email_port_spin.setValue(int(self.email_config.get("smtp_port", 587))) 614 self.email_tls_check.setChecked(bool(self.email_config.get("use_tls", True))) 615 self.email_user_input.setText(str(self.email_config.get("smtp_username", ""))) 616 self.email_password_input.setText(str(self.email_config.get("smtp_password", ""))) 617 self.email_from_input.setText(str(self.email_config.get("from", ""))) 618 to_value = self.email_config.get("to", []) 619 if isinstance(to_value, list): 620 to_value = ", ".join(to_value) 621 self.email_to_input.setText(str(to_value)) 622 finally: 623 for widget in widgets: 624 widget.blockSignals(False) 625 626 def _runtime_detection_config(self): 627 config = dict(self.detection_config) 628 config["recording_path"] = self.recording_path 629 config["model_dir"] = str(default_model_dir(self.recording_path)) 630 return config 631 632 def test_detection_model(self): 633 self._save_detection_config_from_ui() 634 if hasattr(self, "detection_model_test_btn"): 635 self.detection_model_test_btn.setEnabled(False) 636 self.statusBar().showMessage(tr("status.model_test_running")) 637 638 model_name = str(self.detection_config.get("model", "yolo11n.pt")) 639 model_dir = default_model_dir(self.recording_path) 640 641 def run(): 642 try: 643 model_path = prepare_model_path(model_name, model_dir) 644 self.model_test_finished.emit( 645 True, 646 tr("status.model_test_ok", path=model_path), 647 str(model_path), 648 ) 649 except Exception as exc: 650 self.model_test_finished.emit( 651 False, 652 tr("status.model_test_failed", error=exc), 653 "", 654 ) 655 656 threading.Thread(target=run, daemon=True).start() 657 658 def _on_model_test_finished(self, ok: bool, message: str, model_path: str): 659 if hasattr(self, "detection_model_test_btn"): 660 self.detection_model_test_btn.setEnabled(True) 661 if ok and model_path: 662 self.detection_config["model"] = model_path 663 if hasattr(self, "detection_model_input"): 664 self.detection_model_input.setText(model_path) 665 self.save_config() 666 self.statusBar().showMessage(message) 667 668 def send_test_email(self): 669 self._save_email_config_from_ui() 670 if hasattr(self, "email_test_btn"): 671 self.email_test_btn.setEnabled(False) 672 self.statusBar().showMessage(tr("status.email_test_sending")) 673 674 config = dict(self.email_config) 675 subject = tr("email.test.subject") 676 body = tr("email.test.body") 677 678 def send(): 679 try: 680 send_detection_email(config, subject, body, require_enabled=False) 681 self.email_test_finished.emit(True, tr("status.email_test_sent")) 682 except Exception as exc: 683 self.email_test_finished.emit(False, tr("status.email_test_failed", error=exc)) 684 685 threading.Thread(target=send, daemon=True).start() 686 687 def _on_email_test_finished(self, ok: bool, message: str): 688 if hasattr(self, "email_test_btn"): 689 self.email_test_btn.setEnabled(True) 690 self.statusBar().showMessage(message) 691 692 def _connect_preview_label(self, label: PreviewLabel): 693 label.double_clicked.connect(self._on_big_preview_label_double_clicked) 694 label.region_selected.connect(self._on_big_preview_region_selected) 695 696 def _detection_worker_is_running(self) -> bool: 697 return bool(self.detection_worker is not None and self.detection_worker.isRunning()) 698 699 def _camera_detection_enabled(self, camera_id) -> bool: 700 try: 701 camera_id = int(camera_id) 702 except Exception: 703 return False 704 camera = next((c for c in self.cameras if int(c.get("id", -1)) == camera_id), None) 705 return bool(camera and camera.get("detection_enabled", False)) 706 707 def _any_camera_detection_enabled(self) -> bool: 708 return any(bool(camera.get("detection_enabled", False)) for camera in self.cameras) 709 710 def _preview_label_style(self, camera_id=None): 711 border_color = "#2196f3" if camera_id is not None and self._camera_detection_enabled(camera_id) else "#555" 712 return f"border: 2px solid {border_color}; background-color: black;" 713 714 def _apply_detection_visual_state(self): 715 for widget in self.camera_widgets.values(): 716 try: 717 widget.set_detection_active(self._camera_detection_enabled(widget.camera_id)) 718 except RuntimeError: 719 continue 720 labels = [] 721 label = getattr(self, "big_preview_label", None) 722 if label is not None and not self._is_qobject_deleted(label): 723 labels.append(label) 724 for label in self.multi_view_labels.values(): 725 if label is not None and not self._is_qobject_deleted(label): 726 labels.append(label) 727 for label in labels: 728 try: 729 camera_id = getattr(label, "camera_id", None) 730 if camera_id is None: 731 camera_id = self._get_active_big_preview_camera_id() 732 label.setStyleSheet(self._preview_label_style(camera_id)) 733 except RuntimeError: 734 pass 735 736 def _get_active_big_preview_camera_id(self): 737 if self.selected_camera_ids: 738 if self.zoomed_camera_id is not None and self.zoomed_camera_id in self.selected_camera_ids: 739 return self.zoomed_camera_id 740 if len(self.selected_camera_ids) == 1: 741 return self.selected_camera_ids[0] 742 return None 743 return self.selected_camera_id 744 745 def _get_preview_label_for_camera(self, camera_id): 746 if camera_id is None: 747 return None 748 749 label = getattr(self, "big_preview_label", None) 750 if label is not None and not self._is_qobject_deleted(label): 751 try: 752 if getattr(label, "camera_id", None) == camera_id: 753 return label 754 except RuntimeError: 755 pass 756 757 label = self.multi_view_labels.get(camera_id) 758 if label is not None and not self._is_qobject_deleted(label): 759 return label 760 return None 761 762 def _clear_big_preview_crop(self): 763 self.preview_crop_camera_id = None 764 self.preview_crop_rect = None 765 766 def _sync_big_preview_crop_state(self, active_camera_id): 767 if active_camera_id is None or self.preview_crop_camera_id != active_camera_id: 768 self._clear_big_preview_crop() 769 770 def _update_big_preview_selection_state(self): 771 active_camera_id = self._get_active_big_preview_camera_id() 772 known_labels = [] 773 label = getattr(self, "big_preview_label", None) 774 if label is not None and not self._is_qobject_deleted(label): 775 known_labels.append(label) 776 for multi_label in self.multi_view_labels.values(): 777 if multi_label is not None and not self._is_qobject_deleted(multi_label): 778 known_labels.append(multi_label) 779 for known_label in known_labels: 780 try: 781 known_label.set_selection_enabled(False) 782 except RuntimeError: 783 continue 784 785 label = self._get_preview_label_for_camera(active_camera_id) 786 if label is not None: 787 try: 788 label.set_selection_enabled( 789 self.preview_crop_rect is None and self.preview_crop_camera_id is None 790 ) 791 except RuntimeError: 792 pass 793 794 def _refresh_big_preview_from_last_frame(self, camera_id): 795 widget = self.camera_widgets.get(camera_id) 796 if widget is None or widget.last_frame is None: 797 return 798 799 if self.selected_camera_ids: 800 self._update_multi_view_frame(widget.last_frame, camera_id) 801 elif self.selected_camera_id == camera_id: 802 self._update_big_preview_frame(widget.last_frame) 803 804 def _render_frame_to_label(self, label, frame, crop_rect=None): 805 if label is None or self._is_qobject_deleted(label): 806 return 807 808 display_w = max(1, label.width()) 809 display_h = max(1, label.height()) 810 811 src_h, src_w = frame.shape[:2] 812 if src_w <= 0 or src_h <= 0: 813 label.clear_frame_display_rect() 814 return 815 816 crop_x = 0 817 crop_y = 0 818 crop_w = src_w 819 crop_h = src_h 820 821 if crop_rect is not None: 822 crop_x = max(0, min(src_w - 1, crop_rect.x())) 823 crop_y = max(0, min(src_h - 1, crop_rect.y())) 824 max_crop_w = max(1, src_w - crop_x) 825 max_crop_h = max(1, src_h - crop_y) 826 crop_w = max(1, min(max_crop_w, crop_rect.width())) 827 crop_h = max(1, min(max_crop_h, crop_rect.height())) 828 829 frame_view = frame[crop_y:crop_y + crop_h, crop_x:crop_x + crop_w] 830 if frame_view.size == 0: 831 label.clear_frame_display_rect() 832 return 833 834 scale = min(display_w / crop_w, display_h / crop_h) 835 new_w = max(1, int(crop_w * scale)) 836 new_h = max(1, int(crop_h * scale)) 837 838 frame_resized = cv2.resize(frame_view, (new_w, new_h)) 839 rgb_small = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB) 840 rgb_frame = np.zeros((display_h, display_w, 3), dtype=np.uint8) 841 x = (display_w - new_w) // 2 842 y = (display_h - new_h) // 2 843 rgb_frame[y:y + new_h, x:x + new_w] = rgb_small 844 845 h, w, ch = rgb_frame.shape 846 bytes_per_line = ch * w 847 qt_image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy() 848 pixmap = QPixmap.fromImage(qt_image) 849 pixmap.setDevicePixelRatio(1.0) 850 label.setPixmap(pixmap) 851 label.set_frame_display_rect(QRect(x, y, new_w, new_h)) 852 853 def _get_or_create_camera_widget(self, camera: dict) -> CameraWidget: 854 camera_id = int(camera.get('id')) 855 camera_name = camera.get('name', tr("camera.default_name.id", id=camera_id)) 856 857 existing = self.camera_widgets.get(camera_id) 858 if existing is not None and not self._is_qobject_deleted(existing): 859 return existing 860 861 # Check if battery camera 862 model = camera.get('model', '') 863 is_battery = _is_battery_camera(model, camera_name) 864 865 widget = CameraWidget(camera_id, camera_name, is_battery=is_battery) 866 widget.remove_btn.clicked.connect(lambda checked, cid=camera_id: self.remove_camera(cid)) 867 widget.edit_btn.clicked.connect(lambda checked, cid=camera_id: self.edit_camera(cid)) 868 widget.stream_toggled.connect(self.toggle_camera_stream) 869 widget.clicked.connect(self.select_camera) 870 widget.snapshot_requested.connect(self.save_camera_snapshot) 871 widget.selection_changed.connect(self.on_camera_selection_changed) 872 widget.detection_toggled.connect(self.toggle_camera_detection) 873 widget.record_btn.clicked.connect(lambda checked, cid=camera_id, w=widget: self._on_record_btn_clicked(cid, w, checked)) 874 widget.set_detection_active(self._camera_detection_enabled(camera_id)) 875 self.camera_widgets[camera_id] = widget 876 return widget 877 878 def _remove_camera_list_item(self, camera_id: int): 879 if not hasattr(self, "camera_list_container"): 880 return 881 layout = self.camera_list_container.layout_ref 882 for index in range(layout.count()): 883 item = layout.itemAt(index) 884 widget = item.widget() if item else None 885 if widget is not None and getattr(widget, "camera_id", None) == camera_id: 886 layout.takeAt(index) 887 widget.setParent(None) 888 return 889 890 def _on_record_btn_clicked(self, camera_id: int, widget: CameraWidget, checked: bool): 891 thread = self.camera_threads.get(camera_id) 892 if thread is None: 893 return 894 self.toggle_camera_recording(thread, widget, checked) 895 896 def _allocate_camera_id(self) -> int: 897 camera_id = self.next_camera_id 898 self.next_camera_id += 1 899 return camera_id 900 901 def _add_camera_entry(self, camera_entry: dict): 902 self.cameras.append(camera_entry) 903 self._get_or_create_camera_widget(camera_entry) 904 905 def _build_discovered_camera_entry(self, camera_info: dict, username: str, password: str) -> dict: 906 rtsp_port = 554 if 554 in camera_info['ports'] else ( 907 8554 if 8554 in camera_info['ports'] else 554 908 ) 909 camera_entry = { 910 'id': self._allocate_camera_id(), 911 'url': _build_rtsp_url( 912 host=camera_info['ip'], 913 port=rtsp_port, 914 username=username, 915 password=password, 916 path="h264Preview_01_main" 917 ), 918 'name': camera_info['name'], 919 'uid': camera_info.get('uid', ''), 920 'model': camera_info.get('model', ''), 921 'manufacturer': camera_info.get('manufacturer', ''), 922 'detection_enabled': False, 923 } 924 normalize_reolinkproxy_camera(camera_entry, username=username, password=password) 925 return camera_entry 926 927 def _build_manual_camera_entry(self, url: str, name: str, uid: str) -> dict: 928 camera_id = self._allocate_camera_id() 929 camera_entry = { 930 'id': camera_id, 931 'url': url, 932 'name': name if name else tr("camera.default_name.id", id=camera_id), 933 'uid': uid, 934 'model': '', 935 'detection_enabled': False, 936 } 937 normalize_reolinkproxy_camera(camera_entry) 938 return camera_entry 939 940 def _normalize_edited_camera_data(self, camera_data: dict, updated_data: dict) -> dict: 941 normalized = dict(updated_data) 942 normalized.setdefault('model', camera_data.get('model', '')) 943 normalized.setdefault('manufacturer', camera_data.get('manufacturer', '')) 944 normalize_reolinkproxy_camera(normalized) 945 return normalized 946 947 def _start_camera_thread(self, camera: dict) -> CameraThread: 948 camera_id = camera['id'] 949 thread = CameraThread(camera_id, camera['url'], camera.get('uid', '')) 950 thread.frame_ready.connect(lambda frame, cid=camera_id: self.update_camera_frame(frame, cid)) 951 thread.connection_status.connect(lambda connected, cid, msg: self.update_camera_status(connected, cid, msg)) 952 thread.start() 953 self.camera_threads[camera_id] = thread 954 if camera_id in self.camera_widgets: 955 self.camera_widgets[camera_id].set_stream_active(True) 956 return thread 957 958 def _clear_big_preview_label(self, text: str = ""): 959 self.big_preview_label.setPixmap(QPixmap()) 960 self.big_preview_label.clear_frame_display_rect() 961 if text: 962 self.big_preview_label.setText(text) 963 964 def _camera_waiting_text(self, camera_name: str) -> str: 965 return f"{camera_name}\n{tr('camera.preview.waiting')}" 966 967 def _camera_click_to_start_text(self, camera_name: str) -> str: 968 return f"{camera_name}\n{tr('camera.preview.click_to_start')}" 969 970 def show_discovery_dialog(self): 971 """Kamera-Suche Dialog anzeigen""" 972 dialog = CameraDiscoveryDialog(self) 973 974 if dialog.exec() == QDialog.DialogCode.Accepted: 975 selected_cameras = dialog.get_selected_cameras() 976 977 if not selected_cameras: 978 return 979 980 # Zugangsdaten aus Dialog 981 username = dialog.username_input.text() 982 password = dialog.password_input.text() 983 984 added_count = 0 985 battery_cameras = [] 986 987 for camera_info in selected_cameras: 988 name = camera_info['name'] 989 model = camera_info.get('model', '') 990 991 # Check if battery camera 992 if _is_battery_camera(model, name): 993 battery_cameras.append((name, model)) 994 995 camera_entry = self._build_discovered_camera_entry(camera_info, username, password) 996 997 # Prüfe ob Kamera bereits existiert 998 if any(c['url'] == camera_entry['url'] for c in self.cameras): 999 continue 1000 1001 self._add_camera_entry(camera_entry) 1002 1003 added_count += 1 1004 1005 if added_count > 0: 1006 self.update_grid_layout() 1007 self.update_status_display() 1008 self.save_config() 1009 self.statusBar().showMessage(tr("status.auto_added", count=added_count)) 1010 1011 # Show battery camera warning if any detected 1012 if battery_cameras: 1013 cam_list = "\n".join([f"{name} ({model})" for name, model in battery_cameras]) 1014 QMessageBox.warning( 1015 self, 1016 tr("battery.warning.title"), 1017 f"{len(battery_cameras)} {tr('battery.indicator')}:\n\n{cam_list}\n\n" + 1018 tr("battery.warning.message", name="", model="").replace("Die Kamera '' () ist eine Akku-betriebene Kamera.", 1019 "Diese Kameras sind Akku-betrieben.").replace("Camera '' () is battery-powered.", 1020 "These cameras are battery-powered.") 1021 ) 1022 1023 def add_camera(self): 1024 """Kamera hinzufügen""" 1025 url = self.url_input.text().strip() 1026 name = self.name_input.text().strip() 1027 uid = self.uid_input.text().strip() 1028 1029 if not url: 1030 QMessageBox.warning(self, tr("dialog.title.error"), tr("label.rtsp_url")) 1031 return 1032 1033 camera_name = name if name else tr("camera.default_name.id", id=self.next_camera_id) 1034 1035 # Check if battery camera and show warning 1036 if _is_battery_camera("", camera_name): 1037 reply = QMessageBox.question( 1038 self, 1039 tr("battery.warning.title"), 1040 tr("battery.warning.message", name=camera_name, model="Battery Camera"), 1041 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, 1042 QMessageBox.StandardButton.No 1043 ) 1044 if reply == QMessageBox.StandardButton.No: 1045 return 1046 1047 camera_entry = self._build_manual_camera_entry(url, name, uid) 1048 self._add_camera_entry(camera_entry) 1049 camera_name = camera_entry['name'] 1050 1051 # Im Grid platzieren 1052 self.update_grid_layout() 1053 1054 # Eingabe leeren 1055 self.url_input.clear() 1056 self.name_input.clear() 1057 self.uid_input.clear() 1058 1059 self.update_status_display() 1060 self.statusBar().showMessage(tr("status.camera_added", name=camera_name)) 1061 self.save_config() 1062 1063 def edit_camera(self, camera_id): 1064 """Kamera bearbeiten""" 1065 # Kamera-Daten finden 1066 camera_data = next((c for c in self.cameras if c['id'] == camera_id), None) 1067 if not camera_data: 1068 return 1069 1070 # Stream stoppen falls aktiv 1071 was_running = False 1072 if camera_id in self.camera_threads: 1073 thread = self.camera_threads[camera_id] 1074 if thread.isRunning(): 1075 was_running = True 1076 if thread.stop(timeout_ms=3000): 1077 del self.camera_threads[camera_id] 1078 else: 1079 QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.") 1080 return 1081 1082 # Edit Dialog öffnen 1083 dialog = CameraEditDialog(camera_data, self) 1084 1085 if dialog.exec() == QDialog.DialogCode.Accepted: 1086 updated_data = self._normalize_edited_camera_data(camera_data, dialog.get_camera_data()) 1087 1088 # Daten aktualisieren 1089 for camera in self.cameras: 1090 if camera['id'] == camera_id: 1091 camera.update(updated_data) 1092 break 1093 1094 # Widget aktualisieren 1095 if camera_id in self.camera_widgets: 1096 widget = self.camera_widgets[camera_id] 1097 widget.camera_name = updated_data['name'] 1098 widget.info_label.setText(f"{updated_data['name']} - {tr('camera.status.offline')}") 1099 if widget.stream_active: 1100 widget.video_label.setText(self._camera_waiting_text(updated_data['name'])) 1101 else: 1102 widget.video_label.setText(self._camera_click_to_start_text(updated_data['name'])) 1103 1104 if self.selected_camera_id == camera_id: 1105 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning(): 1106 self.big_preview_label.setText(self._camera_waiting_text(updated_data['name'])) 1107 else: 1108 self.big_preview_label.setText(self._camera_click_to_start_text(updated_data['name'])) 1109 1110 self.save_config() 1111 self.statusBar().showMessage(tr("status.camera_updated", name=updated_data['name'])) 1112 1113 # Stream neu starten falls vorher aktiv 1114 if was_running: 1115 QTimer.singleShot(500, lambda: self.start_single_stream(camera_id)) 1116 else: 1117 # Bei Abbruch: Stream wieder starten falls vorher aktiv 1118 if was_running: 1119 QTimer.singleShot(500, lambda: self.start_single_stream(camera_id)) 1120 1121 def start_single_stream(self, camera_id): 1122 """Einzelnen Stream starten""" 1123 camera = next((c for c in self.cameras if c['id'] == camera_id), None) 1124 if not camera: 1125 return 1126 1127 # Skip wenn bereits läuft 1128 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning(): 1129 return 1130 1131 if camera_id in self.camera_widgets and self.camera_widgets[camera_id].record_btn.isChecked(): 1132 self.camera_widgets[camera_id].record_btn.setChecked(False) 1133 self.camera_widgets[camera_id].toggle_recording() 1134 1135 self._start_camera_thread(camera) 1136 self.statusBar().showMessage(tr("status.stream_started", name=camera['name'])) 1137 1138 def stop_single_stream(self, camera_id): 1139 if camera_id in self.camera_threads: 1140 if self.camera_threads[camera_id].stop(timeout_ms=3000): 1141 del self.camera_threads[camera_id] 1142 else: 1143 self.statusBar().showMessage("Stream wird noch beendet...") 1144 return 1145 1146 if self.preview_crop_camera_id == camera_id: 1147 self._clear_big_preview_crop() 1148 1149 if camera_id in self.camera_widgets: 1150 widget = self.camera_widgets[camera_id] 1151 if widget.record_btn.isChecked(): 1152 widget.record_btn.setChecked(False) 1153 widget.toggle_recording() 1154 widget.set_stream_active(False) 1155 widget.update_status(False, tr("camera.status.stopped")) 1156 1157 if self.selected_camera_id == camera_id: 1158 widget = self.camera_widgets.get(camera_id) 1159 if widget: 1160 self._clear_big_preview_label(self._camera_click_to_start_text(widget.camera_name)) 1161 1162 def toggle_camera_stream(self, camera_id, enabled): 1163 if enabled: 1164 self.start_single_stream(camera_id) 1165 else: 1166 self.stop_single_stream(camera_id) 1167 1168 def remove_camera(self, camera_id): 1169 """Einzelne Kamera entfernen""" 1170 camera = next((c for c in self.cameras if c.get('id') == camera_id), None) 1171 camera_name = None 1172 if camera: 1173 camera_name = camera.get('name') 1174 if not camera_name and camera_id in self.camera_widgets: 1175 camera_name = self.camera_widgets[camera_id].camera_name 1176 if not camera_name: 1177 camera_name = tr("camera.default_name.id", id=camera_id) 1178 1179 reply = QMessageBox.question( 1180 self, 1181 tr('dialog.title.confirm'), 1182 tr('dialog.confirm.remove_one', name=camera_name), 1183 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, 1184 ) 1185 if reply != QMessageBox.StandardButton.Yes: 1186 return 1187 1188 # Thread stoppen falls aktiv 1189 if camera_id in self.camera_threads: 1190 if self.camera_threads[camera_id].stop(timeout_ms=3000): 1191 del self.camera_threads[camera_id] 1192 else: 1193 QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.") 1194 return 1195 1196 # Widget entfernen 1197 if camera_id in self.camera_widgets: 1198 widget = self.camera_widgets[camera_id] 1199 self._remove_camera_list_item(camera_id) 1200 widget.deleteLater() 1201 del self.camera_widgets[camera_id] 1202 1203 if self.selected_camera_id == camera_id: 1204 self.selected_camera_id = None 1205 if self.preview_crop_camera_id == camera_id: 1206 self._clear_big_preview_crop() 1207 self._clear_big_preview_label(tr("big.select_camera")) 1208 1209 if camera_id in self.selected_camera_ids: 1210 self.selected_camera_ids.remove(camera_id) 1211 if self.zoomed_camera_id == camera_id: 1212 self.zoomed_camera_id = None 1213 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id()) 1214 self._rebuild_multi_view_layout() 1215 1216 # Aus Liste entfernen 1217 self.cameras = [c for c in self.cameras if c['id'] != camera_id] 1218 if not self._any_camera_detection_enabled(): 1219 self.stop_detection() 1220 1221 self.update_grid_layout() 1222 self.update_status_display() 1223 self.save_config() 1224 self.statusBar().showMessage(tr("status.camera_removed", id=camera_id)) 1225 1226 def update_grid_layout(self): 1227 """Kamera-Liste neu aufbauen""" 1228 if not hasattr(self, "camera_list_container"): 1229 return 1230 1231 self._rebuilding_camera_list = True 1232 layout = self.camera_list_container.layout_ref 1233 1234 # Detach existing widgets from layout without deleting them 1235 while layout.count(): 1236 item = layout.takeAt(0) 1237 w = item.widget() if item else None 1238 if w is not None: 1239 w.setParent(None) 1240 1241 for camera in self.cameras: 1242 widget = self._get_or_create_camera_widget(camera) 1243 if self._is_qobject_deleted(widget): 1244 continue 1245 layout.addWidget(widget) 1246 1247 self._rebuilding_camera_list = False 1248 1249 def clear_cameras(self): 1250 """Alle Kameras entfernen""" 1251 if not self.cameras: 1252 return 1253 1254 reply = QMessageBox.question(self, tr('dialog.title.confirm'), 1255 tr('dialog.confirm.remove_all'), 1256 QMessageBox.StandardButton.Yes | 1257 QMessageBox.StandardButton.No) 1258 1259 if reply == QMessageBox.StandardButton.Yes: 1260 self.stop_all_streams() 1261 1262 for widget in self.camera_widgets.values(): 1263 widget.deleteLater() 1264 1265 self.camera_widgets.clear() 1266 self.cameras.clear() 1267 self.next_camera_id = 1 1268 self.selected_camera_id = None 1269 self.selected_camera_ids = [] 1270 self.zoomed_camera_id = None 1271 self._clear_big_preview_crop() 1272 self._clear_big_preview_label(tr("big.select_camera")) 1273 if hasattr(self, "camera_list_container"): 1274 layout = self.camera_list_container.layout_ref 1275 while layout.count(): 1276 item = layout.takeAt(0) 1277 w = item.widget() if item else None 1278 if w is not None: 1279 w.setParent(None) 1280 self.update_status_display() 1281 self.save_config() 1282 self.statusBar().showMessage(tr("status.cameras_removed")) 1283 1284 def start_all_streams(self): 1285 """Alle Streams parallel starten""" 1286 if not self.cameras: 1287 QMessageBox.information(self, tr("dialog.title.info"), tr("dialog.msg.no_cameras")) 1288 return 1289 1290 # Threads parallel starten 1291 for camera in self.cameras: 1292 camera_id = camera['id'] 1293 1294 # Skip wenn bereits läuft 1295 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning(): 1296 continue 1297 1298 self._start_camera_thread(camera) 1299 1300 self.statusBar().showMessage(tr("status.streams_starting", count=len(self.cameras))) 1301 1302 def stop_all_streams(self): 1303 """Alle Streams stoppen""" 1304 threads = list(self.camera_threads.values()) 1305 force_stop = bool(getattr(self, "_closing", False)) 1306 1307 for thread in threads: 1308 thread.request_stop() 1309 1310 deadline = time.monotonic() + (2.5 if force_stop else 5.0) 1311 for thread in threads: 1312 remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) 1313 if thread.isRunning(): 1314 thread.wait(remaining_ms) 1315 self.camera_threads = { 1316 camera_id: thread 1317 for camera_id, thread in self.camera_threads.items() 1318 if thread.isRunning() 1319 } 1320 if self.camera_threads: 1321 self.statusBar().showMessage("Streams werden noch beendet...") 1322 return 1323 self._clear_big_preview_crop() 1324 1325 for camera_id, widget in list(self.camera_widgets.items()): 1326 try: 1327 if widget.record_btn.isChecked(): 1328 widget.record_btn.setChecked(False) 1329 widget.toggle_recording() 1330 widget.set_stream_active(False) 1331 widget.update_status(False, tr("camera.status.stopped")) 1332 except RuntimeError: 1333 # Widget already deleted during shutdown/rebuild 1334 continue 1335 1336 if self.selected_camera_id is not None: 1337 widget = self.camera_widgets.get(self.selected_camera_id) 1338 if widget: 1339 self._clear_big_preview_label(self._camera_click_to_start_text(widget.camera_name)) 1340 self._update_big_preview_selection_state() 1341 1342 self.update_status_display() 1343 self.statusBar().showMessage(tr("status.streams_stopped")) 1344 1345 def update_camera_frame(self, frame, camera_id): 1346 """Frame einer Kamera aktualisieren""" 1347 widget = self.camera_widgets.get(camera_id) 1348 if widget is not None: 1349 try: 1350 widget.update_frame(frame) 1351 except RuntimeError: 1352 return 1353 1354 if self._detection_worker_is_running() and self._camera_detection_enabled(camera_id): 1355 camera_name = widget.camera_name if widget is not None else str(camera_id) 1356 self.detection_worker.submit_frame(camera_id, camera_name, frame) 1357 1358 # Update big preview for single camera selection (old behavior) 1359 if self.selected_camera_id == camera_id and len(self.selected_camera_ids) == 0: 1360 self._update_big_preview_frame(frame) 1361 1362 # Update multi-view if camera is selected via checkbox 1363 if camera_id in self.selected_camera_ids: 1364 self._update_multi_view_frame(frame, camera_id) 1365 1366 def update_camera_status(self, connected, camera_id, message): 1367 """Status einer Kamera aktualisieren""" 1368 widget = self.camera_widgets.get(camera_id) 1369 if widget is not None: 1370 try: 1371 widget.update_status(connected, message) 1372 except RuntimeError: 1373 return 1374 1375 def toggle_camera_recording(self, thread, widget, checked): 1376 """Aufnahme einer einzelnen Kamera umschalten""" 1377 if checked: 1378 filename = thread.start_recording(self.recording_path) 1379 if filename: 1380 self.statusBar().showMessage(tr("status.recording", name=os.path.basename(filename))) 1381 else: 1382 thread.stop_recording() 1383 self.statusBar().showMessage(tr("status.recording_stopped", name=widget.camera_name)) 1384 1385 def toggle_all_recording(self): 1386 """Alle Aufnahmen umschalten""" 1387 recording = self.record_all_btn.isChecked() 1388 1389 count = 0 1390 for camera_id, widget in self.camera_widgets.items(): 1391 if widget.record_btn.isEnabled() and camera_id in self.camera_threads: 1392 widget.record_btn.setChecked(recording) 1393 widget.toggle_recording() 1394 1395 thread = self.camera_threads[camera_id] 1396 if recording: 1397 thread.start_recording(self.recording_path) 1398 else: 1399 thread.stop_recording() 1400 count += 1 1401 1402 if recording: 1403 self.record_all_btn.setText(tr("btn.record_all_stop")) 1404 self.record_all_btn.setIcon(load_svg_icon("stop.svg")) 1405 self.record_all_btn.setIconSize(QSize(18, 18)) 1406 self.record_all_btn.setStyleSheet("background-color: #d32f2f; color: white; font-weight: bold;") 1407 self.statusBar().showMessage(tr("status.recordings_started", count=count)) 1408 else: 1409 self.record_all_btn.setText(tr("btn.record_all")) 1410 self.record_all_btn.setIcon(load_svg_icon("record.svg")) 1411 self.record_all_btn.setIconSize(QSize(18, 18)) 1412 self.record_all_btn.setStyleSheet("") 1413 self.statusBar().showMessage(tr("status.recordings_stopped", count=count)) 1414 1415 def update_status_display(self): 1416 """Statusanzeige aktualisieren""" 1417 total = len(self.cameras) 1418 active = len([t for t in self.camera_threads.values() if t.isRunning()]) 1419 self.camera_count_label.setText(tr("label.camera_count", total=total, active=active)) 1420 1421 def select_recording_path(self): 1422 """Speicherort für Aufnahmen wählen""" 1423 path = QFileDialog.getExistingDirectory(self, tr("dialog.path.choose"), self.recording_path) 1424 if path: 1425 self.recording_path = path 1426 self.snapshot_path = snapshot_path_for(self.recording_path) 1427 self.event_path = os.path.join(self.recording_path, "events") 1428 os.makedirs(self.snapshot_path, exist_ok=True) 1429 os.makedirs(self.event_path, exist_ok=True) 1430 self.save_config() 1431 self.statusBar().showMessage(tr("status.path", path=path)) 1432 1433 def toggle_camera_detection(self, camera_id: int, enabled: bool): 1434 camera = next((c for c in self.cameras if int(c.get("id", -1)) == int(camera_id)), None) 1435 if camera is None: 1436 return 1437 camera["detection_enabled"] = bool(enabled) 1438 widget = self.camera_widgets.get(camera_id) 1439 if widget is not None: 1440 widget.set_detection_active(enabled) 1441 1442 if enabled: 1443 self._model_retry_attempt = 0 1444 self._model_retry_scheduled = False 1445 self._ensure_detection_worker() 1446 if camera_id not in self.camera_threads or not self.camera_threads[camera_id].isRunning(): 1447 self.start_single_stream(camera_id) 1448 elif not self._any_camera_detection_enabled(): 1449 self.stop_detection() 1450 self._apply_detection_visual_state() 1451 self.save_config() 1452 1453 def _ensure_detection_worker(self): 1454 if self._detection_worker_is_running(): 1455 return 1456 self.detection_worker = DetectionWorker(self._runtime_detection_config(), self) 1457 self.detection_worker.detected.connect(self.handle_detection_event) 1458 self.detection_worker.status.connect(self._on_detection_status) 1459 self.detection_worker.start() 1460 1461 def stop_detection(self): 1462 worker = self.detection_worker 1463 self.detection_worker = None 1464 if worker is not None: 1465 worker.stop(timeout_ms=3000) 1466 self._apply_detection_visual_state() 1467 self.save_config() 1468 self.statusBar().showMessage(tr("status.detection_stopped")) 1469 1470 def _on_detection_status(self, message: str): 1471 if "deaktiviert" in message or "disabled" in message or "Modell-Laden fehlgeschlagen" in message: 1472 self.detection_worker = None 1473 self._schedule_detection_retry(message) 1474 self._apply_detection_visual_state() 1475 self.save_config() 1476 return 1477 elif "aktiv" in message or "active" in message: 1478 self._model_retry_attempt = 0 1479 self._model_retry_scheduled = False 1480 self.statusBar().showMessage(message) 1481 1482 def _schedule_detection_retry(self, reason: str): 1483 if not self._any_camera_detection_enabled(): 1484 return 1485 if self._model_retry_scheduled: 1486 return 1487 delay = self._model_retry_delays[min(self._model_retry_attempt, len(self._model_retry_delays) - 1)] 1488 self._model_retry_attempt += 1 1489 self._model_retry_scheduled = True 1490 self.statusBar().showMessage(tr("status.model_retry_scheduled", seconds=delay)) 1491 QTimer.singleShot(delay * 1000, self._retry_detection_worker) 1492 1493 def _retry_detection_worker(self): 1494 self._model_retry_scheduled = False 1495 if not self._any_camera_detection_enabled(): 1496 return 1497 if self._detection_worker_is_running(): 1498 return 1499 self._ensure_detection_worker() 1500 1501 def handle_detection_event(self, event): 1502 os.makedirs(self.snapshot_path, exist_ok=True) 1503 os.makedirs(self.event_path, exist_ok=True) 1504 timestamp = datetime.fromtimestamp(event.timestamp).strftime("%Y%m%d_%H%M%S") 1505 safe_camera = "".join(c if (c.isalnum() or c in "-_") else "_" for c in event.camera_name) 1506 safe_label = "".join(c if (c.isalnum() or c in "-_") else "_" for c in event.label) 1507 snapshot_file = os.path.join( 1508 self.snapshot_path, 1509 f"detect_{safe_camera}_{event.camera_id}_{safe_label}_{timestamp}.jpg", 1510 ) 1511 1512 try: 1513 cv2.imwrite(snapshot_file, event.annotated_frame) 1514 except Exception as exc: 1515 self.statusBar().showMessage(tr("status.snapshot_error", error=exc)) 1516 return 1517 1518 clip_file = None 1519 thread = self.camera_threads.get(event.camera_id) 1520 if thread is not None: 1521 clip_seconds = min(180.0, max(1.0, float(self.detection_config.get("event_clip_seconds", 30)))) 1522 pre_seconds = min(clip_seconds - 1.0, max(0.0, float(self.detection_config.get("pre_event_seconds", 8)))) 1523 post_seconds = max(1.0, clip_seconds - pre_seconds) 1524 clip_file = thread.start_event_clip( 1525 self.event_path, 1526 event.label, 1527 camera_name=event.camera_name, 1528 pre_seconds=pre_seconds, 1529 post_seconds=post_seconds, 1530 max_seconds=clip_seconds, 1531 ) 1532 1533 self.statusBar().showMessage( 1534 tr( 1535 "status.detection_event", 1536 label=event.label, 1537 camera=event.camera_name, 1538 confidence=f"{event.confidence:.2f}", 1539 ) 1540 ) 1541 self._send_detection_email_async(event, snapshot_file, clip_file) 1542 1543 def _send_detection_email_async(self, event, snapshot_file: str, clip_file: str | None): 1544 if not self.email_config.get("enabled"): 1545 return 1546 1547 subject = tr("email.detection.subject", label=event.label, camera=event.camera_name) 1548 lines = [ 1549 tr( 1550 "email.detection.body", 1551 label=event.label, 1552 camera=event.camera_name, 1553 confidence=f"{event.confidence:.2f}", 1554 ), 1555 "", 1556 f"Snapshot: {snapshot_file}", 1557 ] 1558 if clip_file: 1559 lines.append(f"Clip: {clip_file}") 1560 1561 def send(): 1562 try: 1563 send_detection_email(self.email_config, subject, "\n".join(lines), snapshot_file) 1564 except Exception as exc: 1565 print(f"Detection email failed: {exc}") 1566 1567 threading.Thread(target=send, daemon=True).start() 1568 1569 def save_camera_snapshot(self, camera_id): 1570 widget = self.camera_widgets.get(camera_id) 1571 if not widget or widget.last_frame is None: 1572 self.statusBar().showMessage(tr("status.no_image")) 1573 return 1574 1575 os.makedirs(self.snapshot_path, exist_ok=True) 1576 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 1577 safe_name = "".join(c if (c.isalnum() or c in "-_") else "_" for c in widget.camera_name) 1578 filename = os.path.join(self.snapshot_path, f"{safe_name}_{camera_id}_{timestamp}.jpg") 1579 1580 try: 1581 cv2.imwrite(filename, widget.last_frame) 1582 self.statusBar().showMessage(tr("status.snapshot_saved", name=os.path.basename(filename))) 1583 except Exception as e: 1584 self.statusBar().showMessage(tr("status.snapshot_error", error=e)) 1585 1586 def save_config(self): 1587 """Konfiguration speichern""" 1588 try: 1589 save_config_data(config_payload(self)) 1590 except Exception as e: 1591 print(f"Fehler beim Speichern: {e}") 1592 1593 def load_config(self): 1594 """Konfiguration laden""" 1595 try: 1596 config, fixed_config = load_config_data() 1597 if config is None: 1598 return 1599 1600 self.language = config.get('language', self.language) 1601 set_language(self.language) 1602 self.cameras = config.get('cameras', []) 1603 self.recording_path = config.get('recording_path', self.recording_path) 1604 self.snapshot_path = config.get('snapshot_path', snapshot_path_for(self.recording_path)) 1605 self.event_path = os.path.join(self.recording_path, "events") 1606 self.detection_config = {**DEFAULT_DETECTION_CONFIG, **config.get('detection', {})} 1607 self.email_config = {**DEFAULT_EMAIL_CONFIG, **config.get('email', {})} 1608 self._sync_detection_config_ui() 1609 self._sync_email_config_ui() 1610 self.cameras_per_row = config.get('cameras_per_row', 3) 1611 self._restore_preview_camera_ids = config.get('preview_camera_ids', []) 1612 self.selected_camera_id = config.get('selected_camera_id') 1613 self._order_custom = bool(config.get('order_custom', False)) 1614 self.next_camera_id = config.get('next_camera_id', 1) 1615 1616 self.grid_cols_spin.setValue(self.cameras_per_row) 1617 if hasattr(self, "language_combo"): 1618 idx = self.language_combo.findData(self.language) 1619 if idx >= 0: 1620 self.language_combo.blockSignals(True) 1621 self.language_combo.setCurrentIndex(idx) 1622 self.language_combo.blockSignals(False) 1623 1624 for camera in self.cameras: 1625 widget = self._get_or_create_camera_widget(camera) 1626 widget.retranslate_ui() 1627 1628 self.update_grid_layout() 1629 self.update_status_display() 1630 self.retranslate_ui() 1631 self._restore_preview_state() 1632 1633 if fixed_config: 1634 self.save_config() 1635 if self._any_camera_detection_enabled(): 1636 QTimer.singleShot(300, self._ensure_detection_worker) 1637 except Exception as e: 1638 print(f"Fehler beim Laden: {e}") 1639 1640 def closeEvent(self, event): 1641 """Beim Schließen alle Threads sauber beenden""" 1642 running_threads = [t for t in self.camera_threads.values() if t.isRunning()] 1643 if self.detection_worker is not None: 1644 self.detection_worker.stop(timeout_ms=3000) 1645 self.detection_worker = None 1646 if not running_threads: 1647 event.accept() 1648 return 1649 1650 if not self._closing: 1651 self._closing = True 1652 self._shutdown_started_at = time.monotonic() 1653 self.save_config() 1654 for thread in running_threads: 1655 thread.request_stop() 1656 self.setEnabled(False) 1657 self._show_shutdown_dialog() 1658 QTimer.singleShot(100, self._finish_close_when_streams_stopped) 1659 1660 event.ignore() 1661 1662 def _finish_close_when_streams_stopped(self): 1663 self.camera_threads = { 1664 camera_id: thread 1665 for camera_id, thread in self.camera_threads.items() 1666 if thread.isRunning() 1667 } 1668 if self.camera_threads: 1669 elapsed = time.monotonic() - (self._shutdown_started_at or time.monotonic()) 1670 message = f"Closing app. Please wait...\nStopping camera streams ({elapsed:.1f}s)" 1671 self.statusBar().showMessage(message.replace("\n", " ")) 1672 if self._shutdown_dialog is not None: 1673 self._shutdown_dialog.setLabelText(message) 1674 QTimer.singleShot(100, self._finish_close_when_streams_stopped) 1675 return 1676 1677 if self._shutdown_dialog is not None: 1678 self._shutdown_dialog.close() 1679 self._shutdown_dialog = None 1680 self.close() 1681 1682 def _show_shutdown_dialog(self): 1683 self.statusBar().showMessage("Closing app. Please wait...") 1684 dialog = QProgressDialog("Closing app. Please wait...\nStopping camera streams", None, 0, 0, self) 1685 dialog.setWindowTitle("Closing app") 1686 dialog.setWindowModality(Qt.WindowModality.ApplicationModal) 1687 dialog.setCancelButton(None) 1688 dialog.setMinimumDuration(0) 1689 dialog.setAutoClose(False) 1690 dialog.setAutoReset(False) 1691 dialog.show() 1692 self._shutdown_dialog = dialog 1693 1694 def select_camera(self, camera_id): 1695 # If multi-view is active (checkboxes), don't use old single-camera selection 1696 if len(self.selected_camera_ids) > 0: 1697 return 1698 1699 self.selected_camera_id = camera_id 1700 self._sync_big_preview_crop_state(camera_id) 1701 self._apply_detection_visual_state() 1702 1703 for cid, widget in self.camera_widgets.items(): 1704 if cid == camera_id: 1705 widget.set_selected(True) 1706 else: 1707 widget.set_selected(False) 1708 1709 widget = self.camera_widgets.get(camera_id) 1710 if widget and hasattr(self, 'big_preview_label') and self.big_preview_label is not None: 1711 try: 1712 self.big_preview_label.clear_frame_display_rect() 1713 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning(): 1714 self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}") 1715 else: 1716 self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.click_to_start')}") 1717 except RuntimeError: 1718 # Label was deleted during multi-view rebuild 1719 pass 1720 1721 if camera_id not in self.camera_threads or not self.camera_threads[camera_id].isRunning(): 1722 self.start_single_stream(camera_id) 1723 else: 1724 self._refresh_big_preview_from_last_frame(camera_id) 1725 self.save_config() 1726 1727 def _restore_preview_state(self): 1728 restore_ids = [ 1729 cid for cid in getattr(self, "_restore_preview_camera_ids", []) 1730 if cid in self.camera_widgets 1731 ] 1732 if restore_ids: 1733 self.selected_camera_ids = restore_ids 1734 self.selected_camera_id = None 1735 for cid, widget in self.camera_widgets.items(): 1736 checked = cid in restore_ids 1737 widget.view_checkbox.blockSignals(True) 1738 widget.view_checkbox.setChecked(checked) 1739 widget.is_selected_for_view = checked 1740 widget.view_checkbox.blockSignals(False) 1741 widget.set_selected(False) 1742 if checked: 1743 widget.video_label.setPixmap(QPixmap()) 1744 widget.video_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}") 1745 self._rebuild_multi_view_layout() 1746 QTimer.singleShot(300, self._start_restored_preview_streams) 1747 return 1748 1749 if self.selected_camera_id in self.camera_widgets: 1750 camera_id = self.selected_camera_id 1751 for cid, widget in self.camera_widgets.items(): 1752 widget.set_selected(cid == camera_id) 1753 widget = self.camera_widgets.get(camera_id) 1754 if widget and hasattr(self, "big_preview_label"): 1755 self.big_preview_label.clear_frame_display_rect() 1756 self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}") 1757 QTimer.singleShot(300, lambda cid=camera_id: self.start_single_stream(cid)) 1758 1759 def _start_restored_preview_streams(self): 1760 for cid in list(self.selected_camera_ids): 1761 if cid not in self.camera_threads or not self.camera_threads[cid].isRunning(): 1762 self.start_single_stream(cid) 1763 1764 def _on_language_changed(self): 1765 if not hasattr(self, "language_combo"): 1766 return 1767 lang = self.language_combo.currentData() 1768 if not lang: 1769 return 1770 self.language = lang 1771 set_language(lang) 1772 self.retranslate_ui() 1773 for w in self.camera_widgets.values(): 1774 w.retranslate_ui() 1775 self.save_config() 1776 1777 def retranslate_ui(self): 1778 self.setWindowTitle(tr("app.title")) 1779 if hasattr(self, "tabs"): 1780 self.tabs.setTabText(0, tr("tab.cameras")) 1781 self.tabs.setTabText(1, tr("tab.config")) 1782 if hasattr(self, "config_group"): 1783 self.config_group.setTitle(tr("group.camera_config")) 1784 if hasattr(self, "detection_group"): 1785 self.detection_group.setTitle(tr("group.detection_config")) 1786 if hasattr(self, "email_group"): 1787 self.email_group.setTitle(tr("group.email_config")) 1788 if hasattr(self, "url_label"): 1789 self.url_label.setText(tr("label.rtsp_url")) 1790 if hasattr(self, "name_label"): 1791 self.name_label.setText(tr("label.name")) 1792 if hasattr(self, "detection_model_label"): 1793 self.detection_model_label.setText(tr("label.detection_model")) 1794 self.detection_device_label.setText(tr("label.detection_device")) 1795 self.detection_imgsz_label.setText(tr("label.detection_imgsz")) 1796 self.detection_conf_label.setText(tr("label.detection_conf")) 1797 self.detection_fps_label.setText(tr("label.detection_fps")) 1798 self.detection_cooldown_label.setText(tr("label.detection_cooldown")) 1799 self.detection_suppress_label.setText(tr("label.detection_suppress")) 1800 self.detection_clip_label.setText(tr("label.detection_clip")) 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")) 1804 if hasattr(self, "detection_model_test_btn"): 1805 self.detection_model_test_btn.setText(tr("btn.model_test")) 1806 current_device = self.detection_device_combo.currentData() 1807 self.detection_device_combo.blockSignals(True) 1808 self.detection_device_combo.clear() 1809 self.detection_device_combo.addItem(tr("detection.device.auto"), "auto") 1810 self.detection_device_combo.addItem(tr("detection.device.cuda"), "cuda:0") 1811 self.detection_device_combo.addItem(tr("detection.device.cpu"), "cpu") 1812 idx = self.detection_device_combo.findData(current_device or self.detection_config.get("device", "auto")) 1813 if idx >= 0: 1814 self.detection_device_combo.setCurrentIndex(idx) 1815 self.detection_device_combo.blockSignals(False) 1816 if hasattr(self, "email_enabled_check"): 1817 self.email_enabled_check.setText(tr("label.email_enabled")) 1818 self.email_host_label.setText(tr("label.smtp_host")) 1819 self.email_port_label.setText(tr("label.smtp_port")) 1820 self.email_tls_check.setText(tr("label.smtp_tls")) 1821 self.email_user_label.setText(tr("label.smtp_username")) 1822 self.email_password_label.setText(tr("label.smtp_password")) 1823 self.email_from_label.setText(tr("label.email_from")) 1824 self.email_to_label.setText(tr("label.email_to")) 1825 if hasattr(self, "email_test_btn"): 1826 self.email_test_btn.setText(tr("btn.email_test")) 1827 if hasattr(self, "grid_cols_label"): 1828 self.grid_cols_label.setText(tr("label.cameras_per_row")) 1829 if hasattr(self, "url_input"): 1830 self.url_input.setPlaceholderText(tr("placeholder.rtsp_url")) 1831 if hasattr(self, "name_input"): 1832 self.name_input.setPlaceholderText(tr("placeholder.name.short")) 1833 if hasattr(self, "add_btn"): 1834 self.add_btn.setText(tr("btn.add")) 1835 if hasattr(self, "discover_btn"): 1836 self.discover_btn.setText(tr("btn.discover")) 1837 if hasattr(self, "clear_btn"): 1838 self.clear_btn.setText(tr("btn.clear_all")) 1839 if hasattr(self, "path_btn"): 1840 self.path_btn.setText(tr("btn.path")) 1841 if hasattr(self, "language_label"): 1842 self.language_label.setText(tr("label.language")) 1843 if hasattr(self, "language_combo"): 1844 current = self.language_combo.currentData() 1845 self.language_combo.blockSignals(True) 1846 self.language_combo.clear() 1847 self.language_combo.addItem(tr("language.de"), "de") 1848 self.language_combo.addItem(tr("language.en"), "en") 1849 idx = self.language_combo.findData(current or self.language) 1850 if idx >= 0: 1851 self.language_combo.setCurrentIndex(idx) 1852 self.language_combo.blockSignals(False) 1853 if hasattr(self, "start_all_btn"): 1854 self.start_all_btn.setText(tr("btn.start_all")) 1855 if hasattr(self, "stop_all_btn"): 1856 self.stop_all_btn.setText(tr("btn.stop_all")) 1857 if hasattr(self, "record_all_btn"): 1858 if self.record_all_btn.isChecked(): 1859 self.record_all_btn.setText(tr("btn.record_all_stop")) 1860 else: 1861 self.record_all_btn.setText(tr("btn.record_all")) 1862 self.update_status_display() 1863 if self.selected_camera_id is None and hasattr(self, "big_preview_label"): 1864 if not self.big_preview_label.pixmap() or self.big_preview_label.pixmap().isNull(): 1865 self.big_preview_label.setText(tr("big.select_camera")) 1866 1867 def _on_camera_order_changed(self, ordered_ids): 1868 if getattr(self, "_closing", False): 1869 return 1870 if getattr(self, "_rebuilding_camera_list", False): 1871 return 1872 1873 try: 1874 ordered_ids = [int(x) for x in ordered_ids] 1875 except Exception: 1876 return 1877 1878 if not ordered_ids: 1879 return 1880 1881 camera_by_id = {int(c.get('id')): c for c in self.cameras if c.get('id') is not None} 1882 new_cameras = [] 1883 for cid in ordered_ids: 1884 cam = camera_by_id.get(cid) 1885 if cam: 1886 new_cameras.append(cam) 1887 1888 leftover = [c for c in self.cameras if int(c.get('id', -1)) not in set(ordered_ids)] 1889 self.cameras = new_cameras + leftover 1890 self._order_custom = True 1891 self.save_config() 1892 self.update_grid_layout() 1893 1894 def on_camera_selection_changed(self, camera_id, selected): 1895 """Handle checkbox selection change for multi-camera view""" 1896 if selected: 1897 if camera_id not in self.selected_camera_ids: 1898 self.selected_camera_ids.append(camera_id) 1899 else: 1900 if camera_id in self.selected_camera_ids: 1901 self.selected_camera_ids.remove(camera_id) 1902 1903 if self.zoomed_camera_id is not None and self.zoomed_camera_id not in self.selected_camera_ids: 1904 self.zoomed_camera_id = None 1905 1906 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id()) 1907 1908 self._rebuild_multi_view_layout() 1909 self.save_config() 1910 1911 # Start streams for selected cameras 1912 for cid in self.selected_camera_ids: 1913 if cid not in self.camera_threads or not self.camera_threads[cid].isRunning(): 1914 self.start_single_stream(cid) 1915 1916 def _create_multi_view_close_button(self, camera_id, label): 1917 close_btn = QPushButton() 1918 close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarCloseButton)) 1919 close_btn.setIconSize(QSize(14, 14)) 1920 close_btn.setFixedSize(28, 28) 1921 close_btn.setStyleSheet(""" 1922 QPushButton { 1923 background-color: rgba(220, 53, 69, 220); 1924 border: 2px solid white; 1925 border-radius: 14px; 1926 } 1927 QPushButton:hover { 1928 background-color: rgba(200, 35, 51, 255); 1929 border: 2px solid #ffcccc; 1930 } 1931 """) 1932 close_btn.setCursor(Qt.CursorShape.PointingHandCursor) 1933 close_btn.clicked.connect(lambda checked, cid=camera_id: self._close_multi_view_camera(cid)) 1934 close_btn.setParent(label) 1935 close_btn.raise_() 1936 if not hasattr(self, 'multi_view_close_buttons'): 1937 self.multi_view_close_buttons = {} 1938 self.multi_view_close_buttons[camera_id] = close_btn 1939 label.installEventFilter(self) 1940 return close_btn 1941 1942 def _rebuild_multi_view_layout(self): 1943 """Rebuild the big preview layout based on selected cameras""" 1944 # Clear existing layout 1945 while self.big_preview_layout.count(): 1946 item = self.big_preview_layout.takeAt(0) 1947 if item.widget(): 1948 item.widget().deleteLater() 1949 elif item.layout(): 1950 self._clear_layout(item.layout()) 1951 1952 self.multi_view_labels.clear() 1953 if hasattr(self, 'multi_view_close_buttons'): 1954 self.multi_view_close_buttons.clear() 1955 1956 selected_ids = list(self.selected_camera_ids) 1957 if self.zoomed_camera_id is not None and self.zoomed_camera_id in selected_ids: 1958 selected_ids = [self.zoomed_camera_id] 1959 1960 self._sync_big_preview_crop_state(selected_ids[0] if len(selected_ids) == 1 else None) 1961 1962 num_selected = len(selected_ids) 1963 1964 if num_selected == 0: 1965 # No selection - show default message 1966 label = PreviewLabel() 1967 label.setAlignment(Qt.AlignmentFlag.AlignCenter) 1968 label.setText(tr("big.select_camera")) 1969 label.setStyleSheet(self._preview_label_style()) 1970 label.setMinimumHeight(360) 1971 label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) 1972 self._connect_preview_label(label) 1973 self.big_preview_layout.addWidget(label) 1974 self.big_preview_label = label 1975 elif num_selected == 1: 1976 # Single camera - full view 1977 camera_id = selected_ids[0] 1978 label = PreviewLabel(camera_id) 1979 label.setAlignment(Qt.AlignmentFlag.AlignCenter) 1980 label.setStyleSheet(self._preview_label_style(camera_id)) 1981 label.setMinimumHeight(360) 1982 label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) 1983 self._connect_preview_label(label) 1984 self.big_preview_layout.addWidget(label) 1985 self.big_preview_label = label 1986 self.multi_view_labels[camera_id] = label 1987 1988 self._create_multi_view_close_button(camera_id, label) 1989 else: 1990 # Multi-camera grid view 1991 # Calculate grid: 2 cameras = 1 row x 2 cols, 3-4 = 2 rows, 5-6 = 3 rows, etc. 1992 cols = 2 1993 rows = (num_selected + 1) // 2 1994 1995 for row in range(rows): 1996 row_layout = QHBoxLayout() 1997 row_layout.setSpacing(4) 1998 1999 # No left spacer - single camera should be on left side 2000 2001 for col in range(cols): 2002 idx = row * cols + col 2003 if idx >= num_selected: 2004 # Add right spacer to fill remaining space (makes single camera take 1/2 width) 2005 spacer = QWidget() 2006 spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 2007 row_layout.addWidget(spacer, 1) 2008 continue 2009 2010 camera_id = selected_ids[idx] 2011 2012 # Container for label + close button 2013 container = QWidget() 2014 container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 2015 container_layout = QVBoxLayout(container) 2016 container_layout.setContentsMargins(0, 0, 0, 0) 2017 container_layout.setSpacing(0) 2018 2019 # Label for video 2020 label = PreviewLabel(camera_id) 2021 label.setAlignment(Qt.AlignmentFlag.AlignCenter) 2022 label.setStyleSheet(self._preview_label_style(camera_id)) 2023 label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 2024 label.setMinimumHeight(180) 2025 label.setMinimumWidth(0) 2026 label.setMaximumWidth(16777215) # Qt max 2027 label.setScaledContents(False) 2028 self._connect_preview_label(label) 2029 2030 widget = self.camera_widgets.get(camera_id) 2031 if widget: 2032 label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}") 2033 2034 self._create_multi_view_close_button(camera_id, label) 2035 2036 container_layout.addWidget(label) 2037 row_layout.addWidget(container, 1) 2038 self.multi_view_labels[camera_id] = label 2039 if self.big_preview_label is None or self._is_qobject_deleted(self.big_preview_label): 2040 self.big_preview_label = label 2041 2042 # Set equal stretch for all rows 2043 self.big_preview_layout.addLayout(row_layout, 1) 2044 2045 self._update_big_preview_selection_state() 2046 self._apply_detection_visual_state() 2047 2048 for camera_id in selected_ids: 2049 widget = self.camera_widgets.get(camera_id) 2050 if widget is not None and widget.last_frame is not None: 2051 self._update_multi_view_frame(widget.last_frame, camera_id) 2052 2053 def eventFilter(self, obj, event): 2054 """Event filter to reposition close buttons on label resize""" 2055 if event.type() in (QEvent.Type.Show, QEvent.Type.Resize): 2056 # Check if this is a multi-view label 2057 for camera_id, label in self.multi_view_labels.items(): 2058 if obj == label and hasattr(self, 'multi_view_close_buttons'): 2059 close_btn = self.multi_view_close_buttons.get(camera_id) 2060 if close_btn: 2061 # Position button at top-right corner 2062 close_btn.move(label.width() - close_btn.width() - 4, 4) 2063 close_btn.raise_() 2064 return super().eventFilter(obj, event) 2065 2066 def _close_multi_view_camera(self, camera_id): 2067 """Close a camera from multi-view and rebuild grid""" 2068 # Remove from selected list 2069 if camera_id in self.selected_camera_ids: 2070 self.selected_camera_ids.remove(camera_id) 2071 2072 if self.zoomed_camera_id == camera_id: 2073 self.zoomed_camera_id = None 2074 2075 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id()) 2076 2077 # Uncheck the checkbox in the camera widget 2078 widget = self.camera_widgets.get(camera_id) 2079 if widget and widget.view_checkbox: 2080 widget.view_checkbox.blockSignals(True) 2081 widget.view_checkbox.setChecked(False) 2082 widget.view_checkbox.blockSignals(False) 2083 2084 # Clear close button reference 2085 if hasattr(self, 'multi_view_close_buttons') and camera_id in self.multi_view_close_buttons: 2086 del self.multi_view_close_buttons[camera_id] 2087 2088 # Rebuild the multi-view layout 2089 self._rebuild_multi_view_layout() 2090 self.save_config() 2091 2092 def _on_big_preview_label_double_clicked(self, camera_id): 2093 if self.zoomed_camera_id is None: 2094 if len(self.selected_camera_ids) > 1 and camera_id in self.selected_camera_ids: 2095 self.zoomed_camera_id = camera_id 2096 self._rebuild_multi_view_layout() 2097 self.save_config() 2098 return 2099 2100 active_camera_id = self._get_active_big_preview_camera_id() 2101 if active_camera_id is None or camera_id != active_camera_id: 2102 return 2103 2104 if self.preview_crop_camera_id == camera_id and self.preview_crop_rect is not None: 2105 self._clear_big_preview_crop() 2106 self._update_big_preview_selection_state() 2107 self._refresh_big_preview_from_last_frame(camera_id) 2108 return 2109 2110 if self.zoomed_camera_id is not None: 2111 self.zoomed_camera_id = None 2112 self._rebuild_multi_view_layout() 2113 self.save_config() 2114 2115 def _on_big_preview_region_selected(self, camera_id, selection_rect): 2116 active_camera_id = self._get_active_big_preview_camera_id() 2117 if active_camera_id is None or camera_id != active_camera_id: 2118 return 2119 if self.preview_crop_rect is not None: 2120 return 2121 2122 label = self._get_preview_label_for_camera(camera_id) 2123 widget = self.camera_widgets.get(camera_id) 2124 if label is None or widget is None or widget.last_frame is None: 2125 return 2126 2127 frame_rect = label.frame_display_rect() 2128 if frame_rect.isNull(): 2129 return 2130 2131 selection_rect = selection_rect.intersected(frame_rect) 2132 if selection_rect.width() < 8 or selection_rect.height() < 8: 2133 return 2134 2135 frame_h, frame_w = widget.last_frame.shape[:2] 2136 rel_x = (selection_rect.x() - frame_rect.x()) / frame_rect.width() 2137 rel_y = (selection_rect.y() - frame_rect.y()) / frame_rect.height() 2138 rel_w = selection_rect.width() / frame_rect.width() 2139 rel_h = selection_rect.height() / frame_rect.height() 2140 2141 crop_x = int(round(rel_x * frame_w)) 2142 crop_y = int(round(rel_y * frame_h)) 2143 crop_w = int(round(rel_w * frame_w)) 2144 crop_h = int(round(rel_h * frame_h)) 2145 2146 crop_x = max(0, min(frame_w - 1, crop_x)) 2147 crop_y = max(0, min(frame_h - 1, crop_y)) 2148 crop_w = max(24, min(frame_w - crop_x, crop_w)) 2149 crop_h = max(24, min(frame_h - crop_y, crop_h)) 2150 2151 if crop_w <= 0 or crop_h <= 0: 2152 return 2153 2154 self.preview_crop_camera_id = camera_id 2155 self.preview_crop_rect = QRect(crop_x, crop_y, crop_w, crop_h) 2156 self._update_big_preview_selection_state() 2157 self._refresh_big_preview_from_last_frame(camera_id) 2158 2159 def _clear_layout(self, layout): 2160 """Recursively clear a layout""" 2161 while layout.count(): 2162 item = layout.takeAt(0) 2163 if item.widget(): 2164 item.widget().deleteLater() 2165 elif item.layout(): 2166 self._clear_layout(item.layout()) 2167 2168 def _update_big_preview_frame(self, frame): 2169 label = getattr(self, "big_preview_label", None) 2170 if label is None or self._is_qobject_deleted(label): 2171 return 2172 2173 crop_rect = None 2174 active_camera_id = self._get_active_big_preview_camera_id() 2175 if ( 2176 active_camera_id is not None 2177 and self.preview_crop_camera_id == active_camera_id 2178 and self.preview_crop_rect is not None 2179 ): 2180 crop_rect = self.preview_crop_rect 2181 2182 self._render_frame_to_label(label, frame, crop_rect=crop_rect) 2183 label.setStyleSheet(self._preview_label_style(active_camera_id)) 2184 self._update_big_preview_selection_state() 2185 2186 def _update_multi_view_frame(self, frame, camera_id): 2187 """Update frame in multi-camera view with aspect ratio preservation""" 2188 label = self.multi_view_labels.get(camera_id) 2189 if not label: 2190 return 2191 2192 crop_rect = None 2193 if self.zoomed_camera_id == camera_id and self.preview_crop_rect is not None: 2194 if self.preview_crop_camera_id == camera_id: 2195 crop_rect = self.preview_crop_rect 2196 2197 self._render_frame_to_label(label, frame, crop_rect=crop_rect) 2198 label.set_selection_enabled( 2199 camera_id == self._get_active_big_preview_camera_id() 2200 and len(self.multi_view_labels) == 1 2201 and self.preview_crop_rect is None 2202 and self.preview_crop_camera_id is None 2203 )