Files
duck-stain-yolo/app/main_window.py

347 lines
13 KiB
Python

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, Slot
from PySide6.QtGui import QAction, QImage, QPixmap
from PySide6.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
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.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.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.photo_button)
toolbar_layout.addWidget(self.record_button)
toolbar_layout.addWidget(self.settings_button)
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.worker.stop()
self.detection_worker.stop()
self.worker.wait(2000)
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 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)
self.worker.update_camera_config(camera_config)
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.parsed:
lines.append(f"Zamowienie: {result.parsed.order_number or '-'}")
lines.append(f"Kolor: {result.parsed.color_code or '-'}")
lines.append(f"Model: {result.parsed.product_model or '-'}")
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), (122, 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()