Initial MVP application skeleton
Add PySide6 camera UI, YOLO/Tesseract detection pipeline, capture metadata, configuration, and project gitignore.
This commit is contained in:
292
app/main_window.py
Normal file
292
app/main_window.py
Normal file
@@ -0,0 +1,292 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
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.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.detection_ready.connect(self.on_detection_ready)
|
||||
self.worker.camera_error.connect(self.on_camera_error)
|
||||
self.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.worker.wait(2000)
|
||||
super().closeEvent(event)
|
||||
|
||||
@Slot(object)
|
||||
def on_frame_ready(self, frame: np.ndarray) -> None:
|
||||
self.last_frame = frame.copy()
|
||||
if self.video_recorder.is_recording:
|
||||
self.video_recorder.write(frame)
|
||||
self._show_frame(frame)
|
||||
|
||||
@Slot(object)
|
||||
def on_detection_ready(self, result: DetectionResult) -> None:
|
||||
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.result_text.setPlainText("Wykrywanie...")
|
||||
self.worker.start_detection()
|
||||
|
||||
def accept_detection(self) -> None:
|
||||
self.worker.accept_detection()
|
||||
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 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)
|
||||
|
||||
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 run_app(app_config: AppConfig) -> int:
|
||||
app = QApplication([])
|
||||
window = MainWindow(app_config)
|
||||
window.show()
|
||||
return app.exec()
|
||||
Reference in New Issue
Block a user