347 lines
13 KiB
Python
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()
|