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
This commit is contained in:
2026-05-10 13:00:12 +02:00
parent b095c36776
commit eaa7c75868
14 changed files with 458 additions and 0 deletions

148
app/camera.py Normal file
View File

@@ -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)

46
app/logging_config.py Normal file
View File

@@ -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)

188
app/main_window.py Normal file
View File

@@ -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")