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 21 kB View raw
1from datetime import datetime 2 3import cv2 4from PyQt6.QtCore import QMimeData, QRect, QSize, Qt, QTimer, pyqtSignal 5from PyQt6.QtGui import QColor, QDrag, QImage, QPainter, QPen, QPixmap 6from PyQt6.QtWidgets import ( 7 QCheckBox, 8 QHBoxLayout, 9 QLabel, 10 QPushButton, 11 QSizePolicy, 12 QVBoxLayout, 13 QWidget, 14) 15 16from i18n import tr 17from ui_resources import load_svg_icon 18 19 20class CameraListContainer(QWidget): 21 order_changed = pyqtSignal(object) # list[int] 22 23 def __init__(self, parent=None): 24 super().__init__(parent) 25 self.setAcceptDrops(True) 26 self._layout = QVBoxLayout(self) 27 self._layout.setSpacing(8) 28 self._layout.setAlignment(Qt.AlignmentFlag.AlignTop) 29 self._layout.setContentsMargins(0, 0, 0, 0) 30 31 @property 32 def layout_ref(self) -> QVBoxLayout: 33 return self._layout 34 35 def dragEnterEvent(self, event): 36 if event.mimeData().hasFormat("application/x-wildcam-camera-id"): 37 event.acceptProposedAction() 38 else: 39 super().dragEnterEvent(event) 40 41 def dragMoveEvent(self, event): 42 if event.mimeData().hasFormat("application/x-wildcam-camera-id"): 43 event.acceptProposedAction() 44 else: 45 super().dragMoveEvent(event) 46 47 def dropEvent(self, event): 48 if not event.mimeData().hasFormat("application/x-wildcam-camera-id"): 49 super().dropEvent(event) 50 return 51 52 data = bytes(event.mimeData().data("application/x-wildcam-camera-id")).decode("utf-8", "ignore") 53 try: 54 dragged_id = int(data) 55 except Exception: 56 event.ignore() 57 return 58 59 ordered_ids = [] 60 for i in range(self._layout.count()): 61 item = self._layout.itemAt(i) 62 w = item.widget() if item else None 63 if w is not None and hasattr(w, "camera_id"): 64 ordered_ids.append(int(getattr(w, "camera_id"))) 65 66 if dragged_id not in ordered_ids: 67 event.ignore() 68 return 69 70 drop_y = event.position().y() if hasattr(event, "position") else event.pos().y() 71 insert_index = len(ordered_ids) 72 for idx in range(self._layout.count()): 73 item = self._layout.itemAt(idx) 74 w = item.widget() if item else None 75 if w is None: 76 continue 77 mid = w.y() + (w.height() / 2) 78 if drop_y < mid: 79 insert_index = idx 80 break 81 82 ordered_ids.remove(dragged_id) 83 if insert_index > len(ordered_ids): 84 insert_index = len(ordered_ids) 85 ordered_ids.insert(insert_index, dragged_id) 86 87 event.acceptProposedAction() 88 QTimer.singleShot(0, lambda: self.order_changed.emit(ordered_ids)) 89 90 91class CameraWidget(QWidget): 92 """Widget für einzelne Kamera-Anzeige""" 93 clicked = pyqtSignal(int) 94 stream_toggled = pyqtSignal(int, bool) 95 snapshot_requested = pyqtSignal(int) 96 selection_changed = pyqtSignal(int, bool) 97 detection_toggled = pyqtSignal(int, bool) 98 99 def __init__(self, camera_id, camera_name="", is_battery=False): 100 super().__init__() 101 self.camera_id = camera_id 102 self.camera_name = camera_name or tr("camera.default_name.id", id=camera_id) 103 self.is_battery = is_battery 104 self.recording = False 105 self.last_frame_time = datetime.now() 106 self.stream_active = False 107 self.last_frame = None 108 self._drag_start_pos = None 109 self._video_drag_start_pos = None 110 self._video_dragging = False 111 self.is_selected_for_view = False 112 self.detection_active = False 113 114 layout = QVBoxLayout() 115 layout.setContentsMargins(2, 2, 2, 2) 116 layout.setSpacing(4) 117 118 # Checkbox für Multi-Kamera-Auswahl 119 checkbox_layout = QHBoxLayout() 120 checkbox_layout.setContentsMargins(4, 2, 4, 2) 121 self.view_checkbox = QCheckBox("") 122 self.view_checkbox.setToolTip("Kamera in großer Ansicht anzeigen") 123 self.view_checkbox.setStyleSheet(""" 124 QCheckBox { 125 font-weight: bold; 126 font-size: 10px; 127 color: #4CAF50; 128 } 129 QCheckBox::indicator { 130 width: 14px; 131 height: 14px; 132 border: 2px solid #4CAF50; 133 border-radius: 2px; 134 background-color: #2b2b2b; 135 } 136 QCheckBox::indicator:checked { 137 background-color: #4CAF50; 138 border-color: #4CAF50; 139 } 140 QCheckBox::indicator:hover { 141 border-color: #66BB6A; 142 } 143 """) 144 self.view_checkbox.stateChanged.connect(self._on_checkbox_changed) 145 checkbox_layout.addWidget(self.view_checkbox) 146 checkbox_layout.addStretch() 147 layout.addLayout(checkbox_layout) 148 149 # Video Label 150 self.video_label = QLabel() 151 self.video_label.setFixedSize(180, 120) 152 self._apply_video_border_style() 153 self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 154 self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 155 self.video_label.setScaledContents(False) 156 self.video_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 157 self.video_label.mousePressEvent = self._on_video_mouse_press 158 self.video_label.mouseMoveEvent = self._on_video_mouse_move 159 self.video_label.mouseReleaseEvent = self._on_video_mouse_release 160 161 # Info Label mit FPS und Battery-Indikator 162 battery_indicator = f" {tr('battery.indicator')}" if is_battery else "" 163 self.info_label = QLabel(f"{self.camera_name}{battery_indicator} - {tr('camera.status.offline')}") 164 label_color = "#ff9800" if is_battery else "red" 165 self.info_label.setStyleSheet(f"color: {label_color}; font-weight: bold; font-size: 11px;") 166 self.info_label.setWordWrap(False) 167 self.info_label.setFixedHeight(18) 168 169 # Button Layout 170 btn_layout = QVBoxLayout() 171 btn_layout.setContentsMargins(0, 0, 0, 0) 172 btn_layout.setSpacing(3) 173 btn_row1 = QHBoxLayout() 174 btn_row1.setContentsMargins(0, 0, 0, 0) 175 btn_row1.setSpacing(4) 176 btn_row2 = QHBoxLayout() 177 btn_row2.setContentsMargins(0, 0, 0, 0) 178 btn_row2.setSpacing(4) 179 180 icon_size = QSize(18, 18) 181 182 # Aufnahme Button 183 self.record_btn = QPushButton() 184 self.record_btn.setCheckable(True) 185 self.record_btn.setEnabled(False) 186 self.record_btn.setMaximumWidth(40) 187 self.record_btn.setFixedHeight(24) 188 self.record_btn.setIcon(load_svg_icon("record.svg")) 189 self.record_btn.setIconSize(icon_size) 190 self.record_btn.setToolTip(tr("camera.tooltip.record")) 191 self.record_btn.clicked.connect(self.toggle_recording) 192 193 self.stream_btn = QPushButton() 194 self.stream_btn.setCheckable(True) 195 self.stream_btn.setMaximumWidth(40) 196 self.stream_btn.setFixedHeight(24) 197 self.stream_btn.setIcon(load_svg_icon("play.svg")) 198 self.stream_btn.setIconSize(icon_size) 199 self.stream_btn.setToolTip(tr("camera.tooltip.stream")) 200 self.stream_btn.clicked.connect(self.toggle_stream) 201 202 self.snapshot_btn = QPushButton() 203 self.snapshot_btn.setMaximumWidth(40) 204 self.snapshot_btn.setFixedHeight(24) 205 self.snapshot_btn.setIcon(load_svg_icon("camera.svg")) 206 self.snapshot_btn.setIconSize(icon_size) 207 self.snapshot_btn.setToolTip(tr("camera.tooltip.snapshot")) 208 self.snapshot_btn.clicked.connect(self._request_snapshot) 209 210 self.detection_btn = QPushButton() 211 self.detection_btn.setCheckable(True) 212 self.detection_btn.setMaximumWidth(40) 213 self.detection_btn.setFixedHeight(24) 214 self.detection_btn.setText("AI") 215 self.detection_btn.setToolTip(tr("camera.tooltip.detection")) 216 self.detection_btn.clicked.connect(self.toggle_detection) 217 218 # Edit Button 219 self.edit_btn = QPushButton() 220 self.edit_btn.setMaximumWidth(40) 221 self.edit_btn.setFixedHeight(24) 222 self.edit_btn.setToolTip(tr("camera.tooltip.edit")) 223 self.edit_btn.setIcon(load_svg_icon("pencil.svg")) 224 self.edit_btn.setIconSize(icon_size) 225 self.edit_btn.setStyleSheet("color: #64b5f6;") 226 227 # Entfernen Button 228 self.remove_btn = QPushButton() 229 self.remove_btn.setMaximumWidth(40) 230 self.remove_btn.setFixedHeight(24) 231 self.remove_btn.setToolTip(tr("camera.tooltip.remove")) 232 self.remove_btn.setIcon(load_svg_icon("trash.svg")) 233 self.remove_btn.setIconSize(icon_size) 234 self.remove_btn.setStyleSheet("color: #999;") 235 236 btn_row1.addWidget(self.stream_btn) 237 btn_row1.addWidget(self.record_btn) 238 btn_row1.addWidget(self.snapshot_btn) 239 btn_row1.addStretch() 240 btn_row2.addWidget(self.detection_btn) 241 btn_row2.addWidget(self.edit_btn) 242 btn_row2.addWidget(self.remove_btn) 243 btn_row2.addStretch() 244 btn_layout.addLayout(btn_row1) 245 btn_layout.addLayout(btn_row2) 246 247 layout.addWidget(self.video_label) 248 layout.addWidget(self.info_label) 249 layout.addLayout(btn_layout) 250 251 self.setLayout(layout) 252 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 253 self.setMinimumWidth(200) 254 self.setFixedHeight(120 + 18 + 24 + 24 + 22 + 24) 255 256 self.set_selected(False) 257 258 def mousePressEvent(self, event): 259 if event.button() == Qt.MouseButton.LeftButton: 260 self._drag_start_pos = event.pos() 261 super().mousePressEvent(event) 262 263 def mouseMoveEvent(self, event): 264 if not (event.buttons() & Qt.MouseButton.LeftButton): 265 super().mouseMoveEvent(event) 266 return 267 268 if self._drag_start_pos is None: 269 super().mouseMoveEvent(event) 270 return 271 272 if (event.pos() - self._drag_start_pos).manhattanLength() < 8: 273 super().mouseMoveEvent(event) 274 return 275 276 mime = QMimeData() 277 mime.setData("application/x-wildcam-camera-id", str(self.camera_id).encode("utf-8")) 278 drag = QDrag(self) 279 drag.setMimeData(mime) 280 drag.exec(Qt.DropAction.MoveAction) 281 282 self._drag_start_pos = None 283 super().mouseMoveEvent(event) 284 285 def _on_video_clicked(self, event): 286 self.clicked.emit(self.camera_id) 287 288 def _on_video_mouse_press(self, event): 289 if event.button() == Qt.MouseButton.LeftButton: 290 self._video_drag_start_pos = event.pos() 291 self._video_dragging = False 292 293 def _on_video_mouse_move(self, event): 294 if not (event.buttons() & Qt.MouseButton.LeftButton): 295 return 296 297 if self._video_drag_start_pos is None: 298 return 299 300 if (event.pos() - self._video_drag_start_pos).manhattanLength() < 8: 301 return 302 303 if self._video_dragging: 304 return 305 306 self._video_dragging = True 307 mime = QMimeData() 308 mime.setData("application/x-wildcam-camera-id", str(self.camera_id).encode("utf-8")) 309 drag = QDrag(self) 310 drag.setMimeData(mime) 311 drag.exec(Qt.DropAction.MoveAction) 312 313 def _on_video_mouse_release(self, event): 314 if event.button() == Qt.MouseButton.LeftButton: 315 if not self._video_dragging: 316 self._on_video_clicked(event) 317 self._video_drag_start_pos = None 318 self._video_dragging = False 319 320 def update_frame(self, frame): 321 """Frame aktualisieren mit FPS-Berechnung""" 322 self.last_frame = frame 323 if self.is_selected_for_view: 324 return 325 326 # FPS berechnen 327 now = datetime.now() 328 fps = 1.0 / (now - self.last_frame_time).total_seconds() if (now - self.last_frame_time).total_seconds() > 0 else 0 329 self.last_frame_time = now 330 331 # Resize für Display 332 display_w = max(1, self.video_label.width()) 333 display_h = max(1, self.video_label.height()) 334 frame_resized = cv2.resize(frame, (display_w, display_h)) 335 336 # Convert BGR to RGB 337 rgb_frame = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB) 338 339 # Aufnahme-Indikator 340 if self.recording: 341 cv2.circle(rgb_frame, (20, 20), 8, (255, 0, 0), -1) 342 cv2.putText(rgb_frame, "REC", (35, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 2) 343 344 # FPS anzeigen 345 cv2.putText(rgb_frame, f"{fps:.1f} FPS", (display_w - 80, 25), 346 cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) 347 348 # Convert to QImage 349 h, w, ch = rgb_frame.shape 350 bytes_per_line = ch * w 351 qt_image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) 352 353 self.video_label.setPixmap(QPixmap.fromImage(qt_image)) 354 355 def update_status(self, connected, message): 356 """Status aktualisieren""" 357 if connected: 358 self.info_label.setText(f"{self.camera_name} - {message}") 359 self.info_label.setStyleSheet("color: green; font-weight: bold; font-size: 11px;") 360 self.record_btn.setEnabled(True) 361 else: 362 self.info_label.setText(f"{self.camera_name} - {message}") 363 self.info_label.setStyleSheet("color: red; font-weight: bold; font-size: 11px;") 364 self.record_btn.setEnabled(False) 365 if self.stream_active: 366 self.video_label.setText(f"{self.camera_name}\n{message}\n{tr('camera.preview.retrying')}") 367 else: 368 self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 369 370 def toggle_recording(self): 371 """Aufnahme umschalten""" 372 self.recording = self.record_btn.isChecked() 373 if self.recording: 374 self.record_btn.setStyleSheet("background-color: #d32f2f; color: white; font-weight: bold;") 375 else: 376 self.record_btn.setStyleSheet("") 377 378 def toggle_detection(self): 379 self.set_detection_active(self.detection_btn.isChecked()) 380 self.detection_toggled.emit(self.camera_id, self.detection_active) 381 382 def toggle_stream(self): 383 self.stream_active = self.stream_btn.isChecked() 384 if self.stream_active: 385 self.stream_btn.setIcon(load_svg_icon("stop.svg")) 386 else: 387 self.stream_btn.setIcon(load_svg_icon("play.svg")) 388 self.stream_toggled.emit(self.camera_id, self.stream_active) 389 390 def set_stream_active(self, active): 391 self.stream_active = active 392 self.stream_btn.blockSignals(True) 393 self.stream_btn.setChecked(active) 394 self.stream_btn.setIcon(load_svg_icon("stop.svg") if active else load_svg_icon("play.svg")) 395 self.stream_btn.blockSignals(False) 396 if not active: 397 self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 398 399 def retranslate_ui(self): 400 self.record_btn.setToolTip(tr("camera.tooltip.record")) 401 self.stream_btn.setToolTip(tr("camera.tooltip.stream")) 402 self.snapshot_btn.setToolTip(tr("camera.tooltip.snapshot")) 403 self.detection_btn.setToolTip(tr("camera.tooltip.detection")) 404 self.edit_btn.setToolTip(tr("camera.tooltip.edit")) 405 self.remove_btn.setToolTip(tr("camera.tooltip.remove")) 406 if not self.stream_active: 407 self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 408 409 def _request_snapshot(self): 410 self.snapshot_requested.emit(self.camera_id) 411 412 def _on_checkbox_changed(self, state): 413 self.is_selected_for_view = (state == Qt.CheckState.Checked.value) 414 if self.is_selected_for_view: 415 self.video_label.setPixmap(QPixmap()) 416 self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.waiting')}") 417 self.selection_changed.emit(self.camera_id, self.is_selected_for_view) 418 419 def set_selected(self, selected): 420 self.is_selected = bool(selected) 421 self._apply_video_border_style() 422 423 def set_detection_active(self, active: bool): 424 self.detection_active = bool(active) 425 self.detection_btn.blockSignals(True) 426 self.detection_btn.setChecked(self.detection_active) 427 self.detection_btn.blockSignals(False) 428 if self.detection_active: 429 self.detection_btn.setStyleSheet("background-color: #1976d2; color: white; font-weight: bold;") 430 else: 431 self.detection_btn.setStyleSheet("") 432 self._apply_video_border_style() 433 434 def _apply_video_border_style(self): 435 if self.detection_active: 436 border_color = "#2196f3" 437 elif getattr(self, "is_selected", False): 438 border_color = "#4CAF50" 439 else: 440 border_color = "#ff9800" if self.is_battery else "#555" 441 self.video_label.setStyleSheet(f"border: 2px solid {border_color}; background-color: black;") 442 443 444class PreviewLabel(QLabel): 445 clicked = pyqtSignal(int) 446 double_clicked = pyqtSignal(int) 447 region_selected = pyqtSignal(int, QRect) 448 449 def __init__(self, camera_id=None, parent=None): 450 super().__init__(parent) 451 self.camera_id = camera_id 452 self._selection_enabled = False 453 self._selection_origin = None 454 self._selection_rect = QRect() 455 self._frame_display_rect = QRect() 456 457 def set_selection_enabled(self, enabled: bool): 458 self._selection_enabled = bool(enabled) 459 self.setCursor( 460 Qt.CursorShape.CrossCursor if self._selection_enabled else Qt.CursorShape.ArrowCursor 461 ) 462 if not self._selection_enabled: 463 self.clear_selection() 464 465 def clear_selection(self): 466 if not self._selection_rect.isNull(): 467 self._selection_rect = QRect() 468 self.update() 469 self._selection_origin = None 470 471 def set_frame_display_rect(self, rect: QRect): 472 self._frame_display_rect = QRect(rect) 473 474 def clear_frame_display_rect(self): 475 self._frame_display_rect = QRect() 476 self.clear_selection() 477 478 def frame_display_rect(self) -> QRect: 479 return QRect(self._frame_display_rect) 480 481 def sizeHint(self): 482 return QSize(0, 0) 483 484 def minimumSizeHint(self): 485 return QSize(0, 0) 486 487 def mousePressEvent(self, event): 488 if ( 489 event.button() == Qt.MouseButton.LeftButton 490 and self.camera_id is not None 491 and self._selection_enabled 492 ): 493 pos = event.position().toPoint() 494 if self._frame_display_rect.contains(pos): 495 self._selection_origin = pos 496 self._selection_rect = QRect(pos, pos) 497 self.update() 498 super().mousePressEvent(event) 499 500 def mouseMoveEvent(self, event): 501 if self._selection_enabled and self._selection_origin is not None: 502 pos = event.position().toPoint() 503 self._selection_rect = QRect(self._selection_origin, pos).normalized() 504 self.update() 505 event.accept() 506 return 507 super().mouseMoveEvent(event) 508 509 def mouseReleaseEvent(self, event): 510 if event.button() == Qt.MouseButton.LeftButton and self._selection_origin is not None: 511 selection_rect = QRect(self._selection_origin, event.position().toPoint()).normalized() 512 selection_rect = selection_rect.intersected(self._frame_display_rect) 513 self.clear_selection() 514 if ( 515 self.camera_id is not None 516 and selection_rect.width() >= 8 517 and selection_rect.height() >= 8 518 ): 519 self.region_selected.emit(int(self.camera_id), selection_rect) 520 event.accept() 521 return 522 super().mouseReleaseEvent(event) 523 524 def mouseDoubleClickEvent(self, event): 525 if event.button() == Qt.MouseButton.LeftButton and self.camera_id is not None: 526 self.clear_selection() 527 self.double_clicked.emit(int(self.camera_id)) 528 super().mouseDoubleClickEvent(event) 529 530 def paintEvent(self, event): 531 super().paintEvent(event) 532 if self._selection_rect.isNull(): 533 return 534 535 painter = QPainter(self) 536 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 537 painter.setPen(QPen(QColor(76, 175, 80), 2)) 538 painter.fillRect(self._selection_rect, QColor(76, 175, 80, 45)) 539 painter.drawRect(self._selection_rect)