from __future__ import annotations import time from datetime import datetime from typing import Any import cv2 import numpy as np from PySide6.QtCore import Qt, QTimer, Slot from PySide6.QtGui import QAction, QImage, QPixmap from PySide6.QtWidgets import ( QApplication, QHBoxLayout, QLabel, QFileDialog, QMainWindow, QMessageBox, QPushButton, QTextEdit, QToolButton, QVBoxLayout, QWidget, QStyle, ) from app.camera import CameraWorker, DetectionWorker from app.config import AppConfig from app.detection import DetectionResult from app.media import MediaStore, VideoRecorder from app.settings_dialog import SettingsDialog class MainWindow(QMainWindow): def __init__(self, app_config: AppConfig) -> None: super().__init__() self.app_config = app_config self.config = app_config.data self.last_frame: np.ndarray | None = None self.overlay_result: DetectionResult | None = None self.last_detection: DetectionResult | None = None self.detecting = False self.detection_frame_count = 0 self.fps_frame_count = 0 self.fps_last_time = time.monotonic() self.display_fps = 0.0 self.video_capture: cv2.VideoCapture | None = None self.video_timer = QTimer(self) self.video_timer.timeout.connect(self._read_video_frame) self.video_playing = False self.media_store = MediaStore(self.config, self.app_config) self.video_recorder = VideoRecorder(self.config, self.app_config) self.setWindowTitle("Duck Stain YOLO") self.resize(1280, 720) self._build_ui() self.worker = CameraWorker(self.config, self.app_config) self.worker.frame_ready.connect(self.on_frame_ready) self.worker.camera_error.connect(self.on_camera_error) self.worker.start() self.detection_worker = DetectionWorker(self.config, self.app_config) self.detection_worker.detection_ready.connect(self.on_detection_ready) self.detection_worker.start() def _build_ui(self) -> None: self.stage = QWidget() self.setCentralWidget(self.stage) self.video_label = QLabel(self.stage) self.video_label.setAlignment(Qt.AlignCenter) self.video_label.setStyleSheet("background: #111; color: #ddd;") self.video_label.setText("Kamera") self.result_panel = QWidget(self.stage) self.result_panel.setObjectName("resultPanel") self.result_panel.setStyleSheet( """ QWidget#resultPanel { background: rgba(20, 20, 20, 170); border-radius: 8px; } QTextEdit { background: transparent; color: white; border: 0; font-size: 13px; } QPushButton { min-height: 28px; padding: 4px 12px; } """ ) panel_layout = QVBoxLayout(self.result_panel) self.result_text = QTextEdit() self.result_text.setReadOnly(True) self.result_text.setFixedHeight(118) panel_layout.addWidget(self.result_text) panel_buttons = QHBoxLayout() self.detect_button = QPushButton("wykryj") self.ok_button = QPushButton("ok") panel_buttons.addStretch(1) panel_buttons.addWidget(self.detect_button) panel_buttons.addWidget(self.ok_button) panel_layout.addLayout(panel_buttons) self.detect_button.clicked.connect(self.start_detection) self.ok_button.clicked.connect(self.accept_detection) self.toolbar = QWidget(self.stage) self.toolbar.setObjectName("bottomToolbar") self.toolbar.setStyleSheet( """ QWidget#bottomToolbar { background: rgba(20, 20, 20, 175); border-radius: 8px; } QToolButton { min-width: 44px; min-height: 38px; padding: 4px; } """ ) toolbar_layout = QHBoxLayout(self.toolbar) toolbar_layout.setContentsMargins(8, 6, 8, 6) self.load_video_button = self._tool_button(QStyle.SP_DirOpenIcon, "Wczytaj film") self.video_play_button = self._tool_button(QStyle.SP_MediaPlay, "Play/pauza filmu") self.photo_button = self._tool_button(QStyle.SP_DialogSaveButton, "Zrob zdjecie") self.record_button = self._tool_button(QStyle.SP_MediaPlay, "Start/stop nagrywania") self.settings_button = self._tool_button(QStyle.SP_FileDialogDetailedView, "Ustawienia obrazu") toolbar_layout.addWidget(self.load_video_button) toolbar_layout.addWidget(self.video_play_button) toolbar_layout.addWidget(self.photo_button) toolbar_layout.addWidget(self.record_button) toolbar_layout.addWidget(self.settings_button) self.video_play_button.setEnabled(False) self.load_video_button.clicked.connect(self.load_video) self.video_play_button.clicked.connect(self.toggle_video_playback) self.photo_button.clicked.connect(self.take_photo) self.record_button.clicked.connect(self.toggle_recording) self.settings_button.clicked.connect(self.open_settings) quit_action = QAction("Zamknij", self) quit_action.triggered.connect(self.close) self.addAction(quit_action) def _tool_button(self, icon_id: QStyle.StandardPixmap, tooltip: str) -> QToolButton: button = QToolButton() button.setIcon(self.style().standardIcon(icon_id)) button.setToolTip(tooltip) return button def resizeEvent(self, event: Any) -> None: super().resizeEvent(event) self.video_label.setGeometry(self.stage.rect()) panel_width = min(420, max(280, self.stage.width() // 3)) self.result_panel.setGeometry(self.stage.width() - panel_width - 18, 18, panel_width, 190) self.toolbar.adjustSize() toolbar_size = self.toolbar.sizeHint() self.toolbar.setGeometry( (self.stage.width() - toolbar_size.width()) // 2, self.stage.height() - toolbar_size.height() - 18, toolbar_size.width(), toolbar_size.height(), ) def closeEvent(self, event: Any) -> None: if self.video_recorder.is_recording: self.video_recorder.stop(self.current_metadata("video")) self.video_timer.stop() self._close_video_capture() self._stop_camera_worker() self.detection_worker.stop() self.detection_worker.wait(2000) super().closeEvent(event) @Slot(object) def on_frame_ready(self, frame: np.ndarray) -> None: self._update_fps() self.last_frame = frame.copy() if self.video_recorder.is_recording: self.video_recorder.write(frame) self._maybe_request_detection(frame) self._show_frame(frame) @Slot(object) def on_detection_ready(self, result: DetectionResult) -> None: if not self.detecting: return self.last_detection = result self.overlay_result = result if result.xyxy else None self._update_result_text(result) @Slot(str) def on_camera_error(self, message: str) -> None: self.result_text.setPlainText(message) def start_detection(self) -> None: self.overlay_result = None self.detecting = True self.detection_frame_count = 0 self.result_text.setPlainText("Wykrywanie...") def accept_detection(self) -> None: self.detecting = False self.overlay_result = None if self.last_detection: self._update_result_text(self.last_detection, accepted=True) def take_photo(self) -> None: if self.last_frame is None: QMessageBox.warning(self, "Zdjecie", "Brak klatki z kamery") return path = self.media_store.save_photo(self.last_frame, self.current_metadata("photo")) self.statusBar().showMessage(f"Zapisano zdjecie: {path}", 5000) def toggle_recording(self) -> None: if self.last_frame is None: QMessageBox.warning(self, "Wideo", "Brak klatki z kamery") return if self.video_recorder.is_recording: path = self.video_recorder.stop(self.current_metadata("video")) self.record_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) self.statusBar().showMessage(f"Zapisano film: {path}", 5000) return try: path = self.video_recorder.start(self.last_frame) except RuntimeError as exc: QMessageBox.warning(self, "Wideo", str(exc)) return self.record_button.setIcon(self.style().standardIcon(QStyle.SP_MediaStop)) self.statusBar().showMessage(f"Nagrywanie: {path}", 5000) def load_video(self) -> None: path, _ = QFileDialog.getOpenFileName( self, "Wczytaj film", "", "Filmy (*.mp4 *.avi *.mov *.mkv *.m4v);;Wszystkie pliki (*)", ) if not path: return capture = cv2.VideoCapture(path) if not capture.isOpened(): QMessageBox.warning(self, "Film", "Nie mozna otworzyc pliku wideo") capture.release() return if self.video_recorder.is_recording: self.video_recorder.stop(self.current_metadata("video")) self.record_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) self._stop_camera_worker() self._close_video_capture() self.video_capture = capture self.video_play_button.setEnabled(True) self._set_video_playing(False) self.overlay_result = None self.last_detection = None self.result_text.setPlainText(f"Wczytano film: {path}") self._read_video_frame() def toggle_video_playback(self) -> None: if self.video_capture is None: return self._set_video_playing(not self.video_playing) def open_settings(self) -> None: dialog = SettingsDialog(self.config, self) dialog.settings_saved.connect(self.save_camera_settings) dialog.exec() @Slot(dict) def save_camera_settings(self, camera_config: dict[str, Any]) -> None: self.config["camera"] = camera_config self.app_config.save(self.config) if self.worker is not None: self.worker.update_camera_config(camera_config) def _read_video_frame(self) -> None: if self.video_capture is None: return ok, frame = self.video_capture.read() if not ok or frame is None: self._set_video_playing(False) self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, 0) self.statusBar().showMessage("Koniec filmu", 3000) return self.on_frame_ready(frame) def _set_video_playing(self, playing: bool) -> None: self.video_playing = playing if self.video_capture is None: self.video_timer.stop() self.video_play_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) self.video_play_button.setEnabled(False) return if playing: fps = self.video_capture.get(cv2.CAP_PROP_FPS) if fps <= 0: fps = float(self.config["camera"].get("fps", 30)) interval_ms = max(1, int(round(1000 / fps))) self.video_timer.start(interval_ms) self.video_play_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPause)) else: self.video_timer.stop() self.video_play_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) def _close_video_capture(self) -> None: self._set_video_playing(False) if self.video_capture is not None: self.video_capture.release() self.video_capture = None self.video_play_button.setEnabled(False) def _stop_camera_worker(self) -> None: if self.worker is None: return self.worker.stop() self.worker.wait(2000) self.worker = None def _maybe_request_detection(self, frame: np.ndarray) -> None: if not self.detecting: return frame_stride = max(1, int(self.config["detection"].get("frame_stride", 5))) self.detection_frame_count += 1 if self.detection_frame_count % frame_stride != 0: return self.detection_worker.request_detection(frame) def _update_fps(self) -> None: self.fps_frame_count += 1 now = time.monotonic() elapsed = now - self.fps_last_time if elapsed < 1.0: return self.display_fps = self.fps_frame_count / elapsed self.fps_frame_count = 0 self.fps_last_time = now def current_metadata(self, media_type: str) -> dict[str, Any]: return { "media_type": media_type, "created_at": datetime.now().isoformat(timespec="seconds"), "detection": self.last_detection.to_metadata() if self.last_detection else None, "camera": { "width": self.config["camera"].get("width"), "height": self.config["camera"].get("height"), "fps": self.config["camera"].get("fps"), "properties": self.config["camera"].get("properties", {}), }, "detection_config": self.config.get("detection", {}), } def _update_result_text(self, result: DetectionResult, accepted: bool = False) -> None: status = "Zatwierdzono" if accepted else "Wynik" lines = [status] if result.error: lines.append(f"Komunikat: {result.error}") if result.confidence is not None: lines.append(f"YOLO confidence: {result.confidence:.3f}") if result.ocr_engine: lines.append(f"OCR: {result.ocr_engine}") if result.ocr_confidence is not None: lines.append(f"OCR confidence: {result.ocr_confidence:.3f}") if result.ocr_elapsed_ms is not None: lines.append(f"OCR czas: {result.ocr_elapsed_ms:.0f} ms") if result.parsed: lines.append(f"Zamowienie: {result.parsed.order_number or '-'}") color_score = _format_score(result.parsed.color_score) model_score = _format_score(result.parsed.product_model_score) lines.append(f"Kolor: {result.parsed.color_code or '-'}{color_score}") lines.append(f"Model: {result.parsed.product_model or '-'}{model_score}") if result.raw_text: lines.append("") lines.append(result.raw_text) self.result_text.setPlainText("\n".join(lines)) def _show_frame(self, frame_bgr: np.ndarray) -> None: display_frame = frame_bgr.copy() if self.overlay_result is not None: self._draw_detection(display_frame, self.overlay_result) if self.config.get("display", {}).get("show_fps", True): self._draw_fps(display_frame) frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) h, w, channels = frame_rgb.shape image = QImage(frame_rgb.data, w, h, channels * w, QImage.Format_RGB888).copy() pixmap = QPixmap.fromImage(image) self.video_label.setPixmap( pixmap.scaled(self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) ) def _draw_detection(self, frame_bgr: np.ndarray, result: DetectionResult) -> None: mode = self.config["detection"].get("mode", "best") boxes = result.all_boxes if mode == "all" else [result.to_metadata()] for item in boxes: xyxy = item.get("xyxy") or item.get("bbox_xyxy") if not xyxy: continue x1, y1, x2, y2 = [int(value) for value in xyxy] confidence = item.get("confidence") class_name = item.get("class_name") or "label" cv2.rectangle(frame_bgr, (x1, y1), (x2, y2), (0, 220, 0), 3) caption = f"{class_name} {confidence:.2f}" if confidence is not None else class_name cv2.putText( frame_bgr, caption, (x1, max(24, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 220, 0), 2, cv2.LINE_AA, ) def _draw_fps(self, frame_bgr: np.ndarray) -> None: label = f"FPS: {self.display_fps:.1f}" cv2.rectangle(frame_bgr, (12, 12), (142, 46), (0, 0, 0), -1) cv2.putText( frame_bgr, label, (20, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA, ) def run_app(app_config: AppConfig) -> int: app = QApplication([]) window = MainWindow(app_config) window.show() return app.exec() def _format_score(score: float | None) -> str: if score is None: return "" return f" ({score:.2f})"