From eaa7c75868db7d965a4e19dd350ba98f6e3405b4 Mon Sep 17 00:00:00 2001 From: bartool Date: Sun, 10 May 2026 13:00:12 +0200 Subject: [PATCH] Add main application structure and logging configuration - Implement main application entry point in duck-ocr.py - Create logging configuration in logging_config.py - Add video streaming functionality in camera.py - Introduce main window UI in main_window.py - Include SVG assets for UI buttons and icons - Update .gitignore to exclude log files - Add placeholder .gitkeep files for empty directories --- .gitignore | 1 + app/camera.py | 148 ++++++++++++++++++++++ app/logging_config.py | 46 +++++++ app/main_window.py | 188 ++++++++++++++++++++++++++++ assets/circle.svg | 12 ++ assets/close-square-svgrepo-com.svg | 5 + assets/folder-svgrepo-com.svg | 4 + assets/pause-svgrepo-com.svg | 5 + assets/play-svgrepo-com.svg | 4 + assets/record_btn_off.svg | 12 ++ assets/record_btn_on.svg | 12 ++ captures/photos/.gitkeep | 0 captures/videos/.gitkeep | 0 duck-ocr.py | 21 ++++ 14 files changed, 458 insertions(+) create mode 100644 app/camera.py create mode 100644 app/logging_config.py create mode 100644 app/main_window.py create mode 100644 assets/circle.svg create mode 100644 assets/close-square-svgrepo-com.svg create mode 100644 assets/folder-svgrepo-com.svg create mode 100644 assets/pause-svgrepo-com.svg create mode 100644 assets/play-svgrepo-com.svg create mode 100644 assets/record_btn_off.svg create mode 100644 assets/record_btn_on.svg create mode 100644 captures/photos/.gitkeep create mode 100644 captures/videos/.gitkeep create mode 100644 duck-ocr.py diff --git a/.gitignore b/.gitignore index 7e7acbf..4d01637 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ captures/videos/* runs/ *.onnx *.engine +*.log \ No newline at end of file diff --git a/app/camera.py b/app/camera.py new file mode 100644 index 0000000..729aaf2 --- /dev/null +++ b/app/camera.py @@ -0,0 +1,148 @@ +from PySide6.QtCore import QThread, QTimer, QMutex, QWaitCondition, Signal, QObject, QElapsedTimer +import cv2 +import time +import logging +from dataclasses import dataclass +logger = logging.getLogger(__name__) + +@dataclass +class VideoMetrics: + frames_processed: int + frames_dropped: int + last_frame_time: float + last_cap_time: float + fps_average: float + fps_last_time: float + fps_frame_count: float + + def update_fps(self) -> None: + fps_now = time.perf_counter() + elypsed = fps_now - self.fps_last_time + if elypsed < 1.0: + return + + self.fps_frame_count = self.frames_processed - self.fps_frame_count + self.fps_average = self.fps_frame_count / elypsed + self.fps_frame_count = self.frames_processed + self.fps_last_time = fps_now + + +class VideoStreamWorker(QObject): + # Emitowany z wątku roboczego - będzie przenoszony do głównego wątku przez VideoStream + _internal_frame = Signal(object) + + def __init__(self, source): + super().__init__() + self.source = source + self.cap = None + self.fps = 30.0 + self.width = 0 + self.height = 0 + self.running = False + self.metrics = VideoMetrics( + frames_processed=0, + frames_dropped=0, + last_frame_time=0.0, + last_cap_time=0.0, + fps_average=0.0, + fps_last_time=time.perf_counter(), + fps_frame_count=0.0 + ) + + logger.debug(f"VideoStreamWorker initialized with source: {source}") + + def set_source(self, source): + logger.debug(f"Setting new video source: {source}") + self.source = source + if self.running: + self.stop() + self.run() + + def run(self): + """Główna pętla wątku roboczego.""" + if self.source is None: + logger.warning("No video source provided") + return + + self.cap = cv2.VideoCapture(self.source) + if not self.cap.isOpened(): + logger.error(f"Failed to open video source: {self.source}") + return + + self.fps = self.cap.get(cv2.CAP_PROP_FPS) + if self.fps <= 0: + self.fps = 30.0 + + self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + logger.debug(f"Video opened: {self.source} (fps: {self.fps}, size: {self.width}x{self.height})") + + frame_interval = 1.0 / self.fps + self.running = True + frame_emit_time = time.perf_counter() + + while self.running: + next_frame_time = frame_emit_time + frame_interval + + ret, frame = self.cap.read() + + if not ret: + logger.debug("End of video stream or read error") + break + + current_time = time.perf_counter() + self.metrics.last_cap_time = current_time - frame_emit_time + + if current_time < next_frame_time: + sleep_time = next_frame_time - current_time + time.sleep(sleep_time) + else: + self.metrics.frames_dropped += 1 + logger.debug(f"Frame drops counted: {self.metrics.frames_dropped}") + + frame_emit_time = time.perf_counter() + self._internal_frame.emit(frame) + + self.metrics.frames_processed += 1 + self.metrics.update_fps() + + self.cap.release() + + def stop(self): + self.running = False + self.mutex.lock() + self.condition.wakeAll() + self.mutex.unlock() + + +class VideoStream(QObject): + """Klasa fasadowa do użycia w głównym wątku GUI.""" + frame_ready = Signal(object) # To będzie emitowane z głównego wątku + + def __init__(self, source): + super().__init__() + self.worker = VideoStreamWorker(source) + self.worker_thread = QThread() + + # Przenosimy workera do osobnego wątku + self.worker.moveToThread(self.worker_thread) + + # Łączymy sygnały + self.worker._internal_frame.connect(self._on_frame) + self.worker_thread.started.connect(self.worker.run) + self.worker_thread.finished.connect(self.worker.deleteLater) + self.worker_thread.finished.connect(self.worker_thread.deleteLater) + + def start(self): + self.worker_thread.start() + + def stop(self): + self.worker.stop() + self.worker_thread.quit() + self.worker_thread.wait() + + def _on_frame(self, frame): + """Slot wywoływany w głównym wątku po otrzymaniu klatki.""" + # Tutaj możesz dodać korekcję ekspozycji jeśli nie zrobiłeś tego w workerze + self.frame_ready.emit(frame) \ No newline at end of file diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..00cd319 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,46 @@ +# logging_config.py + +import logging +import logging.handlers +from pathlib import Path + + +LOG_DIR = Path("logs") +LOG_DIR.mkdir(exist_ok=True) + +LOG_FILE = LOG_DIR / "app.log" + + +def setup_logging(): + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)-8s | %(threadName)s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # logger główny + logger = logging.getLogger() + + logger.setLevel(logging.DEBUG) + + # zabezpieczenie przed dodaniem handlerów drugi raz + if logger.handlers: + return + + # console + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(formatter) + + # plik + file_handler = logging.handlers.RotatingFileHandler( + LOG_FILE, + maxBytes=5_000_000, + backupCount=3, + encoding="utf-8", + ) + + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(formatter) + + logger.addHandler(console_handler) + logger.addHandler(file_handler) \ No newline at end of file diff --git a/app/main_window.py b/app/main_window.py new file mode 100644 index 0000000..d93f146 --- /dev/null +++ b/app/main_window.py @@ -0,0 +1,188 @@ +from enum import Enum +from typing import Any +import logging + +from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMainWindow, QStyle, QToolButton, QWidget, QVBoxLayout, QPushButton +from PySide6.QtGui import QIcon +from PySide6.QtCore import Qt, QSize + +logger = logging.getLogger(__name__) + +class VideoMode(Enum): + STREAMING = "streaming" + RECORDING = "recording" + PLAYING = "playing" + STOPPED = "stopped" + +icons = { + "load_file":{ + "path": "assets/folder-svgrepo-com.svg", + "tooltip": "Open File", + "size": QSize(48, 48) + }, + "close_file": { + "path": "assets/close-square-svgrepo-com.svg", + "tooltip": "Close File", + "size": QSize(32, 32) + }, + "record_on": { + "path": "assets/record_btn_on.svg", + "tooltip": "Start Recording", + "size": QSize(48, 48) + }, + "record_off": { + "path": "assets/record_btn_off.svg", + "tooltip": "Stop Recording", + "size": QSize(48, 48) + }, + + "play": { + "path": "assets/play-svgrepo-com.svg", + "tooltip": "Play Video", + "size": QSize(48, 48) + }, + "pause": { + "path": "assets/pause-svgrepo-com.svg", + "tooltip": "Pause Video", + "size": QSize(32, 32) + } +} + +def set_icon(button: QToolButton, icon: str): + button.setIcon(QIcon(icons[icon]["path"])) + button.setToolTip(icons[icon]["tooltip"]) + button.setIconSize(icons[icon]["size"]) + + +def create_tool_button(icon: str) -> QToolButton: + button = QToolButton() + set_icon(button, icon) + button.setFixedSize(48, 48) + button.setStyleSheet("background-color: transparent; border: none;") + return button + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Duck Stain OCR") + self.resize(1280, 720) + + self.video_mode = VideoMode.STREAMING + logger.debug(f"Initial video mode: {self.video_mode}") + + self.setup_ui() + + def setup_ui(self): + self.central_widget = QWidget() + self.setCentralWidget(self.central_widget) + self.central_widget.setStyleSheet("background-color: #001e1e;") + + self.toolbar_widget = QWidget(self.central_widget) + self.toolbar_widget.setMinimumWidth(400) + self.toolbar_widget.setObjectName("bottomToolbar") + self.toolbar_widget.setStyleSheet( + """ + QWidget#bottomToolbar { + background: rgba(0, 0, 0, 0.1); + border-radius: 18px; + } + """ + ) + + left_layout = QHBoxLayout() + right_layout = QHBoxLayout() + + self.toolbar_layout = QGridLayout(self.toolbar_widget) + self.toolbar_layout.setContentsMargins(18, 16, 18, 16) + self.toolbar_layout.setColumnStretch(0, 1) + self.toolbar_layout.setColumnStretch(1, 0) + self.toolbar_layout.setColumnStretch(2, 1) + + self.toolbar_layout.addLayout(left_layout, 0, 0) + self.toolbar_layout.addLayout(right_layout, 0, 2) + + # Add buttons to the left layout + self.open_file_button = create_tool_button("load_file") + self.open_file_button.clicked.connect(self.load_video) + left_layout.addWidget(self.open_file_button) + self.close_file_button = create_tool_button("close_file") + self.close_file_button.clicked.connect(self.close_video) + self.close_file_button.setEnabled(False) + left_layout.addWidget(self.close_file_button) + + left_layout.addStretch() + + # Add the record button to the center of the toolbar + self.action_button = create_tool_button("record_off") + self.action_button.clicked.connect(self.toggle_action) + self.toolbar_layout.addWidget(self.action_button, 0, 1, alignment=Qt.AlignmentFlag.AlignCenter) + + # Add buttons to the right layout + right_layout.addStretch() + + + def resizeEvent(self, event: Any) -> None: + super().resizeEvent(event) + + self.toolbar_widget.adjustSize() + toolbar_size = self.toolbar_widget.sizeHint() + self.toolbar_widget.setGeometry( + (self.central_widget.width() - self.toolbar_widget.width()) // 2, + self.central_widget.height() - self.toolbar_widget.height() - 80, + self.toolbar_widget.width(), + self.toolbar_widget.height(), + ) + self.toolbar_widget.raise_() # Ensure the toolbar is above other widgets + + def toggle_action(self): + match self.video_mode: + case VideoMode.STREAMING: + self.start_recording() + case VideoMode.RECORDING: + self.stop_recording() + case VideoMode.PLAYING: + self.pause_video() + case VideoMode.STOPPED: + self.play_video() + case _: + pass + + def start_recording(self): + logger.debug("Starting recording") + self.video_mode = VideoMode.RECORDING + set_icon(self.action_button, "record_on") + + self.open_file_button.setEnabled(False) + self.close_file_button.setEnabled(False) + + def stop_recording(self): + logger.debug("Stopping recording") + self.video_mode = VideoMode.STREAMING + set_icon(self.action_button, "record_off") + + self.open_file_button.setEnabled(True) + self.close_file_button.setEnabled(False) + + def load_video(self): + logger.debug("Loading video") + self.video_mode = VideoMode.STOPPED + set_icon(self.action_button, "play") + + self.close_file_button.setEnabled(True) + + def close_video(self): + logger.debug("Closing video") + self.video_mode = VideoMode.STREAMING + set_icon(self.action_button, "record_off") + + self.close_file_button.setEnabled(False) + + def play_video(self): + logger.debug("Playing video") + self.video_mode = VideoMode.PLAYING + set_icon(self.action_button, "pause") + + def pause_video(self): + logger.debug("Pausing video") + self.video_mode = VideoMode.STOPPED + set_icon(self.action_button, "play") \ No newline at end of file diff --git a/assets/circle.svg b/assets/circle.svg new file mode 100644 index 0000000..398da57 --- /dev/null +++ b/assets/circle.svg @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/assets/close-square-svgrepo-com.svg b/assets/close-square-svgrepo-com.svg new file mode 100644 index 0000000..30c965f --- /dev/null +++ b/assets/close-square-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/folder-svgrepo-com.svg b/assets/folder-svgrepo-com.svg new file mode 100644 index 0000000..cc2c153 --- /dev/null +++ b/assets/folder-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/pause-svgrepo-com.svg b/assets/pause-svgrepo-com.svg new file mode 100644 index 0000000..15a0152 --- /dev/null +++ b/assets/pause-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/play-svgrepo-com.svg b/assets/play-svgrepo-com.svg new file mode 100644 index 0000000..0f6ed67 --- /dev/null +++ b/assets/play-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/record_btn_off.svg b/assets/record_btn_off.svg new file mode 100644 index 0000000..398da57 --- /dev/null +++ b/assets/record_btn_off.svg @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/assets/record_btn_on.svg b/assets/record_btn_on.svg new file mode 100644 index 0000000..61f2f7b --- /dev/null +++ b/assets/record_btn_on.svg @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/captures/photos/.gitkeep b/captures/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/captures/videos/.gitkeep b/captures/videos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/duck-ocr.py b/duck-ocr.py new file mode 100644 index 0000000..2e488ee --- /dev/null +++ b/duck-ocr.py @@ -0,0 +1,21 @@ +import logging +import sys + +from PySide6.QtWidgets import QApplication +from app.main_window import MainWindow +from app.logging_config import setup_logging + +setup_logging() + +logger = logging.getLogger(__name__) + +def main() -> None: + logger.info("Starting application") + + app = QApplication(sys.argv) + main_window = MainWindow() + main_window.show() + sys.exit(app.exec()) + +if __name__ == "__main__": + main() \ No newline at end of file