About Multi-camera viewer optimized for RTSP streams
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)