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

456 lines
17 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, 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})"