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

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ captures/videos/*
runs/
*.onnx
*.engine
*.log

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

12
assets/circle.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="24"
height="24">
<circle
cx="12"
cy="12"
r="9"
stroke="white"
stroke-width="2"
fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 210 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" stroke-width="3" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00191 9.41621C7.61138 9.02569 7.61138 8.39252 8.00191 8.002C8.39243 7.61147 9.0256 7.61147 9.41612 8.002L12.0057 10.5916L14.5896 8.00771C14.9801 7.61719 15.6133 7.61719 16.0038 8.00771C16.3943 8.39824 16.3943 9.0314 16.0038 9.42193L13.4199 12.0058L16.0039 14.5897C16.3944 14.9803 16.3944 15.6134 16.0039 16.004C15.6133 16.3945 14.9802 16.3945 14.5896 16.004L12.0057 13.42L9.42192 16.0038C9.03139 16.3943 8.39823 16.3943 8.00771 16.0038C7.61718 15.6133 7.61718 14.9801 8.00771 14.5896L10.5915 12.0058L8.00191 9.41621Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 4C23 2.34315 21.6569 1 20 1H4C2.34315 1 1 2.34315 1 4V20C1 21.6569 2.34315 23 4 23H20C21.6569 23 23 21.6569 23 20V4Z" stroke="white" />
</svg>

After

Width:  |  Height:  |  Size: 978 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 8.2C3 7.07989 3 6.51984 3.21799 6.09202C3.40973 5.71569 3.71569 5.40973 4.09202 5.21799C4.51984 5 5.0799 5 6.2 5H9.67452C10.1637 5 10.4083 5 10.6385 5.05526C10.8425 5.10425 11.0376 5.18506 11.2166 5.29472C11.4184 5.4184 11.5914 5.59135 11.9373 5.93726L12.0627 6.06274C12.4086 6.40865 12.5816 6.5816 12.7834 6.70528C12.9624 6.81494 13.1575 6.89575 13.3615 6.94474C13.5917 7 13.8363 7 14.3255 7H17.8C18.9201 7 19.4802 7 19.908 7.21799C20.2843 7.40973 20.5903 7.71569 20.782 8.09202C21 8.51984 21 9.0799 21 10.2V15.8C21 16.9201 21 17.4802 20.782 17.908C20.5903 18.2843 20.2843 18.5903 19.908 18.782C19.4802 19 18.9201 19 17.8 19H6.2C5.07989 19 4.51984 19 4.09202 18.782C3.71569 18.5903 3.40973 18.2843 3.21799 17.908C3 17.4802 3 16.9201 3 15.8V8.2Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6V18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18V6Z" stroke="white" stroke-width="3"/>
<path d="M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6V18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 19.8856 14 18V6Z" stroke="white" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 847 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6582 9.28638C18.098 10.1862 18.8178 10.6361 19.0647 11.2122C19.2803 11.7152 19.2803 12.2847 19.0647 12.7878C18.8178 13.3638 18.098 13.8137 16.6582 14.7136L9.896 18.94C8.29805 19.9387 7.49907 20.4381 6.83973 20.385C6.26501 20.3388 5.73818 20.0469 5.3944 19.584C5 19.053 5 18.1108 5 16.2264V7.77357C5 5.88919 5 4.94701 5.3944 4.41598C5.73818 3.9531 6.26501 3.66111 6.83973 3.6149C7.49907 3.5619 8.29805 4.06126 9.896 5.05998L16.6582 9.28638Z" stroke="white" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 739 B

12
assets/record_btn_off.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="24"
height="24">
<circle
cx="12"
cy="12"
r="9"
stroke="white"
stroke-width="2"
fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 210 B

12
assets/record_btn_on.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="24"
height="24">
<circle
cx="12"
cy="12"
r="9"
stroke="white"
stroke-width="2"
fill="red" />
</svg>

After

Width:  |  Height:  |  Size: 209 B

0
captures/photos/.gitkeep Normal file
View File

0
captures/videos/.gitkeep Normal file
View File

21
duck-ocr.py Normal file
View File

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