47 Commits

Author SHA1 Message Date
02edb186bb feat: Implement photo capture functionality
Implement the logic for capturing and saving photos from the live preview stream.

- Add `save_photo` method to `MediaRepository` to handle file saving and database updates.
- `MainController` now tracks the selected color and the latest camera frame.
- The "Take Photo" button is enabled only when a color is selected.
- Pressing the button saves the current preview frame to the correct media folder and refreshes the thumbnail list.
- Fixes indentation issues in `main_controller.py` caused by previous faulty replacements.
2025-10-14 10:04:01 +02:00
47c1e6040a feat: Implement State pattern for camera logic
Refactor camera control logic in `MainController` to use the State design pattern.

- Create a new `core/camera/states.py` module with state classes (`NoCamerasState`, `DetectingState`, `ReadyToStreamState`, `StreamingState`).
- `MainController` now acts as a context, delegating actions to the current state object.
- This replaces complex conditional logic with a robust, scalable, and maintainable state machine, making it easier to manage camera behavior and add new states in the future.
2025-10-14 09:03:19 +02:00
c8d9029df7 refactor: Implement Singleton pattern for DatabaseManager
Refactor the database handling to use a Singleton pattern for `DatabaseManager`.

- A single, module-level instance `db_manager` is created in `core/database.py` to ensure one database connection is used throughout the application.
- `MediaRepository` and `MainController` are updated to use this shared instance instead of creating their own.
- This simplifies dependency injection and prevents potential issues with multiple database connections.
- Also, update `review.md` to reflect the progress.
2025-10-14 08:55:45 +02:00
96c2495a8b refactor(MainController): Simplify camera button signal handling
Refactor the signal handling for the camera start/stop button in `MainController` to simplify logic and improve reliability.

- Replaced the dynamic connect/disconnect pattern with a single, persistent signal connection to `_on_start_button_clicked`.
- Centralized UI state updates (e.g., button text) into a new `_update_start_button_state` method.
- This eliminates potential errors from mismatched signal connections and makes the control flow easier to follow.
- Also fixes major indentation errors caused by a previous faulty replacement.
2025-10-14 08:49:20 +02:00
03ab345e17 refactor(SplitView): Improve image rotation logic
Refactor the image rotation mechanism in the `SplitView` widget to prevent image quality degradation.

- The original reference pixmap is now stored in `self.original_ref_pixmap`.
- Rotations are always applied to the original, unmodified pixmap, using a cumulative rotation angle.
- This avoids sequential transformations that caused gradual quality loss.
- Also fixes indentation issues caused by previous automated replacements.
2025-10-14 08:41:47 +02:00
d62b367b47 refactor: Centralize resource path management
Create a new `settings.py` file to define and manage global application paths.

Modify UI components (`split_view_widget.py`, `view_settings_dialog.py`) to use the centralized `ICONS_DIR` path constant instead of hardcoded relative paths for icons. This improves maintainability and makes the application independent of the working directory.
2025-10-14 08:33:08 +02:00
bbdb7d3459 delete unused files 2025-10-14 08:27:40 +02:00
fa84a29ab5 review concept 2025-10-14 08:26:41 +02:00
2cc496a2b2 refactor: enhance SplitView to support image rotation and handle null pixmap in ZoomableImageView 2025-10-13 22:22:35 +02:00
0bc6c01e7e refactor: enhance camera rotation functionality and integrate with live view 2025-10-13 20:54:04 +02:00
4d00f83083 refactor: update camera start button behavior to handle camera detection and live view 2025-10-13 19:39:34 +02:00
2c1233f304 refactor: initialize QTimer with parent and ensure proper thread cleanup on stop 2025-10-13 19:36:09 +02:00
feebc5153a refactor: update SplitView to use ViewWithOverlay for live and reference images 2025-10-13 05:17:59 +02:00
49a1405340 Merge branch 'feature/camera-manager' 2025-10-13 05:15:27 +02:00
c6345c569d refactor: update camera control signals and improve live view handling 2025-10-13 05:14:27 +02:00
73b51c696e Merge branch 'feature/camera-worker' into feature/camera-manager 2025-10-12 19:31:28 +02:00
511e668cb3 feat: add isConnected method to CameraWorker and is_connected method to CameraController 2025-10-12 19:29:23 +02:00
ce1b864b17 refactor: streamline MainController initialization and signal connections 2025-10-12 19:02:53 +02:00
46734208e7 refactor. new qobjct thread approuch insted qthread 2025-10-12 18:51:37 +02:00
bbdc2af605 refactor: change CameraController to inherit from QObject and manage threading in CameraManager 2025-10-12 13:41:13 +02:00
2a5f570e5e feat: implement CameraDetectionWorker for asynchronous camera detection 2025-10-12 13:08:40 +02:00
d63d616675 working on camera-manager 2025-10-12 10:31:40 +02:00
eb72b25fdb feat: add error label and update related methods in CameraPlaceholder 2025-10-12 10:28:53 +02:00
86b9cc70a6 fix: correct variable name in abilities extraction loop 2025-10-12 10:28:22 +02:00
5b345e6641 feater: add camera buttons 2025-10-09 21:48:23 +02:00
cc37d7054c refactor: new mock gphoto 2025-10-09 21:35:50 +02:00
71a55843c1 chore: update requirements.txt to include additional dependencies 2025-10-09 18:56:54 +02:00
ca08e44358 Merge branch 'dev-camera' 2025-10-09 18:56:00 +02:00
c815762f72 refactor: enhance mock camera classes and update camera detection logic 2025-10-09 18:47:17 +02:00
ca25b06f99 refactor: implement CameraManager class with methods for detecting GPhoto and OpenCV cameras 2025-10-01 18:26:41 +02:00
dea17b8b26 refactor: update detect method in camera classes to return dictionaries instead of lists 2025-10-01 18:26:02 +02:00
324ab2e016 refactor: update camera classes to improve initialization and connection handling 2025-09-30 21:50:30 +02:00
196eff7fd8 refacot: change name from CameraManager to CameraController. Add set_camera nad cleanup code. 2025-09-30 18:58:56 +02:00
e2c8352c44 fix: correct config attribute from 'config' to 'widget' in set_config method 2025-09-30 18:56:47 +02:00
c63821617a feat: add view settings dialog with adjustable parameters and navigation controls 2025-09-29 06:30:25 +02:00
f2a002a249 feat: add view settings dialog and button in main window 2025-09-28 07:55:54 +02:00
d86a6429f7 feat: add split view functionality with image rotation and flipping controls 2025-09-27 19:14:42 +02:00
a1c608f279 refactor: implement set_dark_theme function for application theming 2025-09-27 12:28:41 +02:00
dcf4ef0f0a refactor: implement set_dark_theme function for application theming 2025-09-27 12:26:57 +02:00
1ff5091250 refactor: update set_config methods to specify return type as None
feat: implement CvCamera class for OpenCV camera handling
2025-09-27 12:26:43 +02:00
373e01310e refactor: update GPhotoCamera configuration methods for consistency 2025-09-21 22:01:46 +02:00
abc07fd08d refactor: replace CameraWidget with dictionary-based config handling in GPhotoCamera 2025-09-21 21:43:44 +02:00
35576986c9 refactor gphoto_camera 2025-09-21 20:51:37 +02:00
19e2c7977c feat: read gphoto config 2025-09-21 18:46:38 +02:00
508930ae39 feat: implement camera management with GPhotoCamera and CameraManager classes 2025-09-21 08:38:26 +02:00
2187536c7d refactor: update camera handling with mock implementation and improve signal connections 2025-09-15 20:58:05 +02:00
6133c9fb18 add application style setting and requirements file for dependencies 2025-09-09 19:34:36 +02:00
32 changed files with 2104 additions and 247 deletions

View File

@@ -1,94 +0,0 @@
import gphoto2 as gp
import numpy as np
import cv2
from PySide6.QtCore import QObject, QThread, Signal
from PySide6.QtGui import QImage, QPixmap
class CameraWorker(QObject):
frameReady = Signal(QPixmap)
errorOccurred = Signal(str)
def __init__(self, fps: int = 15, parent=None):
super().__init__(parent)
self.fps = fps
self.running = False
self.camera = None
def start_camera(self):
"""Uruchom kamerę i zacznij pobierać klatki"""
try:
self.camera = gp.Camera()
self.camera.init()
self.running = True
self._capture_loop()
except gp.GPhoto2Error as e:
self.errorOccurred.emit(f"Błąd inicjalizacji kamery: {e}")
def stop_camera(self):
"""Zatrzymaj pobieranie"""
self.running = False
if self.camera:
try:
self.camera.exit()
except gp.GPhoto2Error:
pass
self.camera = None
def _capture_loop(self):
"""Pętla odczytu klatek w osobnym wątku"""
import time
delay = 1.0 / self.fps
while self.running:
try:
file = self.camera.capture_preview() # type: ignore
data = file.get_data_and_size()
frame = np.frombuffer(data, dtype=np.uint8)
frame = cv2.imdecode(frame, cv2.IMREAD_COLOR)
if frame is not None:
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w, ch = rgb_image.shape
qimg = QImage(rgb_image.data, w, h, ch * w, QImage.Format.Format_RGB888)
pixmap = QPixmap.fromImage(qimg)
self.frameReady.emit(pixmap)
except gp.GPhoto2Error as e:
self.errorOccurred.emit(f"Błąd odczytu LiveView: {e}")
break
except Exception as e:
self.errorOccurred.emit(f"Nieoczekiwany błąd: {e}")
break
time.sleep(delay)
class CameraController(QObject):
frameReady = Signal(QPixmap)
errorOccurred = Signal(str)
def __init__(self, fps: int = 15, parent=None):
super().__init__(parent)
self.camera_thread = QThread()
self.worker = CameraWorker(fps)
self.worker.moveToThread(self.camera_thread )
# sygnały z workera
self.worker.frameReady.connect(self.frameReady)
self.worker.errorOccurred.connect(self.errorOccurred)
# sygnały start/stop
self.camera_thread .started.connect(self.worker.start_camera)
def start(self):
"""Start kamery w osobnym wątku"""
self.camera_thread.start()
def stop(self):
"""Stop kamery i zakończenie wątku"""
self.worker.stop_camera()
self.camera_thread.quit()
self.camera_thread.wait()

View File

@@ -1,63 +1,187 @@
from pathlib import Path
from core.database import DatabaseManager
from PySide6.QtCore import Slot
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QPushButton
from core.database import db_manager
from core.media import MediaRepository
from core.camera.camera_manager import CameraManager
from ui.widgets.color_list_widget import ColorListWidget
from ui.widgets.thumbnail_list_widget import ThumbnailListWidget
from ui.widgets.split_view_widget import SplitView
from PySide6.QtWidgets import QPushButton
from ui.widgets.split_view_widget import SplitView, CameraPlaceholder, ViewWithOverlay
from core.camera.states import CameraState, NoCamerasState, ReadyToStreamState, StreamingState, DetectingState
class MainController:
def __init__(self, view):
self.db = DatabaseManager()
self.db.connect()
self.media_repo = MediaRepository(self.db)
self.media_repo.sync_media()
self.view = view
self.db = db_manager
self.media_repo = MediaRepository()
self.camera_manager = CameraManager()
# --- State ---
self.selected_color_name: str | None = None
self._latest_pixmap: QPixmap | None = None
# --- UI Widgets ---
self.color_list: ColorListWidget = view.color_list_widget
self.thumbnail_list: ThumbnailListWidget = view.thumbnail_widget
self.split_view: SplitView = view.preview_widget
self.welcome_view: CameraPlaceholder = self.split_view.widget_start
self.live_view: ViewWithOverlay = self.split_view.widget_live
self.photo_button: QPushButton = view.photo_button
self.photo_button.clicked.connect(self.take_photo)
self.record_button: QPushButton = view.record_button
self._connect_signals()
self.db.connect()
self.media_repo.sync_media()
# Disable button by default
self.photo_button.setEnabled(False)
# Initialize state machine
self.state: CameraState = NoCamerasState()
self.state.enter_state(self)
def transition_to(self, new_state: CameraState):
"""Transitions the controller to a new state."""
print(f"Transitioning to state: {new_state.__class__.__name__}")
self.state = new_state
self.state.enter_state(self)
def _connect_signals(self):
"""Connects all signals to slots."""
# Database and media signals
self.color_list.colorSelected.connect(self.on_color_selected)
self.color_list.editColor.connect(self.on_edit_color)
self.thumbnail_list.selectedThumbnail.connect(self.on_thumbnail_selected)
# Camera signals
self.camera_manager.cameras_detected.connect(self.on_cameras_detected)
self.camera_manager.frame_ready.connect(self.on_frame_ready)
self.camera_manager.error_occurred.connect(self.on_camera_error)
self.camera_manager.camera_started.connect(self.on_camera_started)
self.camera_manager.camera_stopped.connect(self.on_camera_stopped)
# UI control signals
self.photo_button.clicked.connect(self.take_photo)
self.welcome_view.camera_start_btn.clicked.connect(self._on_start_button_clicked)
self.live_view.rotateCW.connect(self.camera_manager.rotate_right)
self.live_view.rotateCCW.connect(self.camera_manager.rotate_left)
def load_colors(self) -> None:
"""Loads colors from the database and populates the list."""
colors = self.db.get_all_colors()
print("Loaded colors:", colors)
self.color_list.set_colors(colors)
def shutdown(self):
"""Cleans up resources before application exit."""
self.camera_manager.shutdown()
self.db.disconnect()
# --- Slots for Database/Media ---
@Slot(str)
def on_color_selected(self, color_name: str):
print(f"Wybrano kolor: {color_name}")
self.selected_color_name = color_name
self.photo_button.setEnabled(True)
color_id = self.db.get_color_id(color_name)
if color_id is not None:
media_items = self.db.get_media_for_color(color_id)
print(f"Media dla koloru {color_name} (ID: {color_id}):", media_items)
self.thumbnail_list.list_widget.clear()
for media in media_items:
if media['file_type'] == 'photo':
file_name = Path(media['media_path']).name
self.thumbnail_list.add_thumbnail(media['media_path'], file_name, media['id'])
else:
print(f"Nie znaleziono koloru o nazwie: {color_name}")
@Slot(str)
def on_edit_color(self, color_name: str):
print(f"Edycja koloru: {color_name}")
print(f"Edycja koloru: {color_name}") # Placeholder
@Slot(int)
def on_thumbnail_selected(self, media_id: int):
media = self.db.get_media(media_id)
if media:
print(f"Wybrano miniaturę o ID: {media_id}, ścieżka: {media['media_path']}")
self.split_view.set_reference_image(media['media_path'])
# --- Slots for CameraManager ---
@Slot(list)
def on_cameras_detected(self, cameras: list[dict]):
"""Handles the list of detected cameras and transitions state."""
print("Detected cameras:", cameras)
if cameras:
self.transition_to(ReadyToStreamState())
else:
print(f"Nie znaleziono mediów o ID: {media_id}")
self.transition_to(NoCamerasState())
@Slot(QPixmap)
def on_frame_ready(self, pixmap: QPixmap):
"""Displays a new frame from the camera and stores it."""
self._latest_pixmap = pixmap
self.split_view.set_live_image(pixmap)
@Slot(str)
def on_camera_error(self, error_message: str):
"""Shows an error message from the camera and transitions to a safe state."""
print(f"Camera Error: {error_message}")
self.welcome_view.set_error_text(error_message)
self.transition_to(NoCamerasState())
@Slot()
def on_camera_started(self):
"""Transitions to StreamingState when the camera starts."""
self.transition_to(StreamingState())
@Slot()
def on_camera_stopped(self):
"""Transitions to a post-streaming state."""
self.split_view.toggle_live_view()
if self.camera_manager.get_detected_cameras():
self.transition_to(ReadyToStreamState())
else:
self.transition_to(NoCamerasState())
# --- UI Actions ---
def _on_start_button_clicked(self):
"""Delegates the button click to the current state."""
self.state.handle_start_button(self)
def camera_detect(self):
"""Initiates camera detection and transitions to DetectingState."""
self.transition_to(DetectingState())
self.camera_manager.detect_cameras()
def start_liveview(self):
"""Starts the camera feed."""
detected_cameras = self.camera_manager.get_detected_cameras()
if not detected_cameras:
self.on_camera_error("No cameras detected.")
return
camera_id = detected_cameras[0]['id']
self.camera_manager.start_camera(camera_id)
def stop_liveview(self):
"""Stops the camera feed."""
self.camera_manager.stop_camera()
def take_photo(self):
print("Robienie zdjęcia...")
self.split_view.toglle_live_view()
"""Takes a photo with the active camera."""
if self.selected_color_name is None:
print("Cannot take photo: No color selected.")
# Optionally: show a message to the user in the UI
return
if self._latest_pixmap is None or self._latest_pixmap.isNull():
print("Cannot take photo: No frame available.")
return
print(f"Taking photo for color: {self.selected_color_name}")
self.media_repo.save_photo(self._latest_pixmap, self.selected_color_name)
# Refresh thumbnail list
self.on_color_selected(self.selected_color_name)

View File

@@ -0,0 +1,42 @@
from abc import ABC, abstractmethod
class BaseCamera(ABC):
def __init__(self) -> None:
self.error_msg = None
@staticmethod
@abstractmethod
def detect() -> dict:
raise NotImplementedError
@abstractmethod
def connect(self, index: int | None = None) -> bool:
raise NotImplementedError
@abstractmethod
def disconnect(self) -> None:
raise NotImplementedError
@abstractmethod
def get_frame(self):
raise NotImplementedError
@abstractmethod
def get_config_by_id(self, id: int) -> dict:
raise NotImplementedError
@abstractmethod
def get_config_by_name(self, name: str) -> dict:
raise NotImplementedError
@abstractmethod
def set_config_by_id(self, id: int, value) -> None:
raise NotImplementedError
@abstractmethod
def set_config_by_name(self, name: str, value) -> None:
raise NotImplementedError
def get_error_msg(self):
return str(self.error_msg)

View File

@@ -0,0 +1,194 @@
from PySide6.QtCore import QObject, QTimer, Signal, Slot, QMutex, QMutexLocker, QThread
from PySide6.QtGui import QImage, QPixmap
import cv2
from .base_camera import BaseCamera
class CameraWorker(QObject):
frame_ready = Signal(QPixmap)
photo_ready = Signal(QPixmap)
error_occurred = Signal(str)
camera_ready = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self.camera: BaseCamera | None = None
self.timer: QTimer | None = None
self.fps = 15
self.is_streaming = False
self.is_connected = False
self._rotation_index = 0
self._camera_mutex = QMutex()
@Slot()
def initialize_worker(self):
"""Initializes the timer in the worker's thread."""
self.timer = QTimer(self)
self.timer.timeout.connect(self._update_frame)
@Slot(BaseCamera, int)
def set_camera(self, camera: BaseCamera, fps: int = 15) -> None:
with QMutexLocker(self._camera_mutex):
if self.is_streaming:
self.stop_stream()
if self.is_connected:
self.stop_camera()
self.camera = camera
self.fps = fps
@Slot()
def start_camera(self) -> None:
with QMutexLocker(self._camera_mutex):
if self.camera is None or self.is_connected:
return
if self.camera.connect():
self.is_connected = True
self.camera_ready.emit(True)
else:
self.is_connected = False
self.camera_ready.emit(False)
self.error_occurred.emit(self.camera.get_error_msg())
@Slot()
def stop_camera(self) -> None:
with QMutexLocker(self._camera_mutex):
if self.is_streaming:
self.stop_stream()
if self.camera is not None and self.is_connected:
self.camera.disconnect()
self.is_connected = False
@Slot()
def start_stream(self):
if not self.is_connected or self.is_streaming or self.timer is None:
return
self.is_streaming = True
self.timer.setInterval(int(1000 / self.fps))
self.timer.start()
@Slot()
def stop_stream(self) -> None:
if self.is_streaming and self.timer is not None:
self.is_streaming = False
self.timer.stop()
@Slot()
def _update_frame(self) -> None:
# This method is called by the timer, which is in the same thread.
# A mutex is still good practice for accessing the shared camera object.
with QMutexLocker(self._camera_mutex):
if self.camera is None or not self.is_connected or not self.is_streaming:
return
ret, frame = self.camera.get_frame()
if not ret:
error_msg = self.camera.get_error_msg()
if error_msg:
self.error_occurred.emit(error_msg)
return
if frame is None:
return
if self._rotation_index == 1:
frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
elif self._rotation_index == 2:
frame = cv2.rotate(frame, cv2.ROTATE_180)
elif self._rotation_index == 3:
frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
# Process the frame and emit it.
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w, ch = rgb_image.shape
qimg = QImage(rgb_image.data, w, h, ch * w, QImage.Format.Format_RGB888)
pixmap = QPixmap.fromImage(qimg)
self.frame_ready.emit(pixmap)
@Slot()
def rotate_left(self):
self._rotation_index = (self._rotation_index - 1) % 4
@Slot()
def rotate_right(self):
self._rotation_index = (self._rotation_index + 1) % 4
class CameraController(QObject):
frame_ready = Signal(QPixmap)
photo_ready = Signal(QPixmap)
error_occurred = Signal(str)
camera_ready = Signal(bool)
# Signals to command the worker
_set_camera_requested = Signal(BaseCamera, int)
_start_camera_requested = Signal()
_stop_camera_requested = Signal()
_start_stream_requested = Signal()
_stop_stream_requested = Signal()
_rotate_left_requested = Signal()
_rotate_right_requested = Signal()
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._thread = QThread()
self._worker = CameraWorker()
self._worker.moveToThread(self._thread)
# Connect worker signals to controller signals
self._worker.frame_ready.connect(self.frame_ready)
self._worker.photo_ready.connect(self.photo_ready)
self._worker.error_occurred.connect(self.error_occurred)
self._worker.camera_ready.connect(self.camera_ready)
# Connect controller's command signals to worker's slots
self._set_camera_requested.connect(self._worker.set_camera)
self._start_camera_requested.connect(self._worker.start_camera)
self._stop_camera_requested.connect(self._worker.stop_camera)
self._start_stream_requested.connect(self._worker.start_stream)
self._stop_stream_requested.connect(self._worker.stop_stream)
self._rotate_left_requested.connect(self._worker.rotate_left)
self._rotate_right_requested.connect(self._worker.rotate_right)
# Initialize worker when thread starts
self._thread.started.connect(self._worker.initialize_worker)
self._thread.finished.connect(self._worker.stop_camera)
self._thread.start()
def stop(self):
if self._thread.isRunning():
self._thread.quit()
self._thread.wait()
self._thread.deleteLater()
def set_camera(self, camera: BaseCamera, fps: int = 15) -> None:
self._set_camera_requested.emit(camera, fps)
def start_camera(self) -> None:
self._start_camera_requested.emit()
def stop_camera(self) -> None:
self._stop_camera_requested.emit()
def start_stream(self):
self._start_stream_requested.emit()
def stop_stream(self) -> None:
self._stop_stream_requested.emit()
def rotate_left(self):
self._rotate_left_requested.emit()
def rotate_right(self):
self._rotate_right_requested.emit()

View File

@@ -0,0 +1,127 @@
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QPixmap
from .camera_controller import CameraController
from .gphoto_camera import GPhotoCamera
from .opencv_camera import OpenCvCamera
from .base_camera import BaseCamera
class CameraManager(QObject):
"""
Zarządza wszystkimi operacjami związanymi z kamerami,
stanowiąc fasadę dla reszty aplikacji.
"""
frame_ready = Signal(QPixmap)
error_occurred = Signal(str)
cameras_detected = Signal(list)
camera_started = Signal()
camera_stopped = Signal()
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._camera_controller = CameraController()
self._detected_cameras: list[dict] = []
self._active_camera: BaseCamera | None = None
self._active_camera_info: dict | None = None
# Przekazywanie sygnałów z kontrolera kamery na zewnątrz
self._camera_controller.frame_ready.connect(self.frame_ready)
self._camera_controller.error_occurred.connect(self.error_occurred)
self._camera_controller.camera_ready.connect(self.start_liveview)
def detect_cameras(self) -> None:
"""Wykrywa wszystkie dostępne kamery (GPhoto i OpenCV)."""
self._detected_cameras.clear()
# Wykryj kamery GPhoto
try:
gphoto_cameras = GPhotoCamera.detect()
for index, info in gphoto_cameras.items():
self._detected_cameras.append({
"id": f"gphoto_{index}",
"name": f"{info['name']} ({info['port']})",
"type": "gphoto",
"index": index
})
except Exception as e:
self.error_occurred.emit(
f"Błąd podczas wykrywania kamer GPhoto: {e}")
# Wykryj kamery OpenCV
try:
opencv_cameras = OpenCvCamera.detect()
for index, info in opencv_cameras.items():
self._detected_cameras.append({
"id": f"opencv_{index}",
"name": f"OpenCV: {info['name']}",
"type": "opencv",
"index": index
})
except Exception as e:
self.error_occurred.emit(
f"Błąd podczas wykrywania kamer OpenCV: {e}")
self.cameras_detected.emit(self._detected_cameras)
def get_detected_cameras(self) -> list[dict]:
return self._detected_cameras
def start_camera(self, camera_id: str, fps: int = 15) -> None:
"""Uruchamia wybraną kamerę."""
if self._active_camera:
self.stop_camera()
camera_info = next(
(c for c in self._detected_cameras if c['id'] == camera_id), None)
if not camera_info:
self.error_occurred.emit(
f"Nie znaleziono kamery o ID: {camera_id}")
return
camera_type = camera_info['type']
camera_index = camera_info['index']
if camera_type == "gphoto":
self._active_camera = GPhotoCamera()
elif camera_type == "opencv":
self._active_camera = OpenCvCamera()
else:
self.error_occurred.emit(f"Nieznany typ kamery: {camera_type}")
return
self._active_camera_info = camera_info
self._camera_controller.set_camera(self._active_camera, fps)
self._camera_controller.start_camera()
def start_liveview(self, connected):
if connected:
self._camera_controller.start_stream()
self.camera_started.emit()
else:
self._active_camera = None
self._active_camera_info = None
def stop_camera(self) -> None:
"""Zatrzymuje aktywną kamerę."""
if self._active_camera:
self._camera_controller.stop_camera()
self._active_camera = None
self._active_camera_info = None
self.camera_stopped.emit()
def get_active_camera_info(self) -> dict | None:
return self._active_camera_info
def rotate_left(self):
self._camera_controller.rotate_left()
def rotate_right(self):
self._camera_controller.rotate_right()
def shutdown(self) -> None:
"""Zamyka kontroler kamery i jego wątek."""
self.stop_camera()
self._camera_controller.stop()

View File

@@ -0,0 +1,173 @@
from typing import Optional, List
from dataclasses import dataclass, field
import cv2
import numpy as np
from .base_camera import BaseCamera
try:
import gphoto2 as gp # type: ignore
except:
import core.camera.mock_gphoto as gp
camera_widget_types = {
gp.GP_WIDGET_WINDOW: "GP_WIDGET_WINDOW", # type: ignore
gp.GP_WIDGET_SECTION: "GP_WIDGET_SECTION", # type: ignore
gp.GP_WIDGET_TEXT: "GP_WIDGET_TEXT", # type: ignore
gp.GP_WIDGET_RANGE: "GP_WIDGET_RANGE", # type: ignore
gp.GP_WIDGET_TOGGLE: "GP_WIDGET_TOGGLE", # type: ignore
gp.GP_WIDGET_RADIO: "GP_WIDGET_RADIO", # type: ignore
gp.GP_WIDGET_MENU: "GP_WIDGET_MENU", # type: ignore
gp.GP_WIDGET_BUTTON: "GP_WIDGET_BUTTON", # type: ignore
gp.GP_WIDGET_DATE: "GP_WIDGET_DATE", # type: ignore
}
operations = [
("GP_OPERATION_NONE", gp.GP_OPERATION_NONE), # type: ignore
("GP_OPERATION_CAPTURE_IMAGE", gp.GP_OPERATION_CAPTURE_IMAGE), # type: ignore
("GP_OPERATION_CAPTURE_VIDEO", gp.GP_OPERATION_CAPTURE_VIDEO), # type: ignore
("GP_OPERATION_CAPTURE_AUDIO", gp.GP_OPERATION_CAPTURE_AUDIO), # type: ignore
("GP_OPERATION_CAPTURE_PREVIEW", gp.GP_OPERATION_CAPTURE_PREVIEW), # type: ignore
("GP_OPERATION_CONFIG", gp.GP_OPERATION_CONFIG), # type: ignore
("GP_OPERATION_TRIGGER_CAPTURE", gp.GP_OPERATION_TRIGGER_CAPTURE), # type: ignore
]
class GPhotoCamera(BaseCamera):
def __init__(self) -> None:
super().__init__()
self.camera = None
self.configs: List[dict] = []
self.camera_index = 0
@staticmethod
def detect() -> dict:
cameras = gp.check_result(gp.gp_camera_autodetect()) # type: ignore
# cameras = gp.Camera().autodetect()
if not cameras or cameras.count() == 0: # type: ignore
return {}
abilities_list = gp.CameraAbilitiesList() # type: ignore
abilities_list.load()
camera_list = {}
for i in range(cameras.count()): # type: ignore
name = cameras.get_name(i) # type: ignore
port = cameras.get_value(i) # type: ignore
abilities_index = abilities_list.lookup_model(name)
abilities = abilities_list.get_abilities(abilities_index)
abilities_name = []
for operation, bit in operations:
if abilities.operations & bit: # type: ignore
abilities_name.append(operation)
camera_list[i] = {"name": name, "port": port, "abilities": abilities_name}
return camera_list
def connect(self, index: int | None = None) -> bool:
self.error_msg = None
self.camera = gp.Camera() # type: ignore
try:
if index:
self.camera_index = index
camera_list = GPhotoCamera.detect()
port_info_list = gp.PortInfoList()
port_info_list.load()
port_address = camera_list[index]["port"]
port_index = port_info_list.lookup_path(port_address)
self.camera.set_port_info(port_info_list[port_index])
self.camera.init()
config = self.camera.get_config()
self.read_config(config)
return True
except Exception as e:
self.error_msg = f"[GPHOTO2] {e}"
self.camera = None
return False
def disconnect(self) -> None:
if self.camera:
self.camera.exit()
self.camera = None
self.configs.clear()
def get_frame(self):
self.error_msg = None
if self.camera is None:
self.error_msg = "[GPHOTO2] Camera is not initialized."
return (False, None)
try:
file = self.camera.capture_preview() # type: ignore
data = file.get_data_and_size()
frame = np.frombuffer(data, dtype=np.uint8)
frame = cv2.imdecode(frame, cv2.IMREAD_COLOR)
return (True, frame)
except Exception as e:
self.error_msg = f"[GPHOTO2] {e}"
return (False, None)
def get_config_by_id(self, id: int):
return next(w for w in self.configs if w['id'] == id)
def get_config_by_name(self, name: str):
return next(w for w in self.configs if w['name'] == name)
def set_config(self, config, value):
if value not in config['choices']:
return
config['widget'].set_value(value) # type: ignore
if self._save_config(config):
config['value'] = value
def set_config_by_id(self, id: int, value: str):
config = self.get_config_by_id(id)
self.set_config(config, value)
def set_config_by_name(self, name: str, value: str):
config = self.get_config_by_name(name)
self.set_config(config, value)
def _save_config(self, config):
if not self.camera:
return False
self.camera.set_single_config(config['name'], config['widget'])
return True
def parse_config(self, config):
new_config = {
"id": config.get_id(),
"name": config.get_name(),
"label": config.get_label(),
"type": camera_widget_types[config.get_type()],
"widget": config
}
try:
new_config["value"] = config.get_value()
except gp.GPhoto2Error:
pass
try:
new_config["choices"] = list(config.get_choices())
except gp.GPhoto2Error:
pass
return new_config
def read_config(self, config):
self.configs.append(self.parse_config(config))
for i in range(config.count_children()):
child = config.get_child(i)
self.read_config(child)

204
core/camera/mock_gphoto.py Normal file
View File

@@ -0,0 +1,204 @@
"""
Mock gphoto2 module for Windows testing and development.
Simulates gphoto2 API behavior to allow the app to run without a camera.
"""
import numpy as np
import cv2
from dataclasses import dataclass
# --- Constants (simulate gphoto2 enums) ---
GP_WIDGET_WINDOW = 0
GP_WIDGET_SECTION = 1
GP_WIDGET_TEXT = 2
GP_WIDGET_RANGE = 3
GP_WIDGET_TOGGLE = 4
GP_WIDGET_RADIO = 5
GP_WIDGET_MENU = 6
GP_WIDGET_BUTTON = 7
GP_WIDGET_DATE = 8
GP_OPERATION_NONE = 0x00
GP_OPERATION_CAPTURE_IMAGE = 0x01
GP_OPERATION_CAPTURE_VIDEO = 0x02
GP_OPERATION_CAPTURE_AUDIO = 0x04
GP_OPERATION_CAPTURE_PREVIEW = 0x08
GP_OPERATION_CONFIG = 0x10
GP_OPERATION_TRIGGER_CAPTURE = 0x20
# --- Error class ---
class GPhoto2Error(Exception):
pass
# --- Mock camera configuration widget ---
class MockWidget:
def __init__(self, name, label, wtype, value=None, choices=None):
self._name = name
self._label = label
self._type = wtype
self._value = value
self._choices = choices or []
self._children = []
def get_id(self):
return id(self)
def get_name(self):
return self._name
def get_label(self):
return self._label
def get_type(self):
return self._type
def get_value(self):
return self._value
def set_value(self, value):
if self._choices and value not in self._choices:
raise GPhoto2Error(f"Invalid value '{value}' for widget '{self._name}'")
self._value = value
def get_choices(self):
return self._choices
def count_children(self):
return len(self._children)
def get_child(self, i):
return self._children[i]
def add_child(self, child):
self._children.append(child)
# --- Mock classes for detection / abilities ---
@dataclass
class MockCameraInfo:
name: str
port: str
class MockCameraList:
def __init__(self):
self._cameras = [
MockCameraInfo("Mock Camera 1", "usb:001,002"),
MockCameraInfo("Mock Camera 2", "usb:001,003")
]
def count(self):
return len(self._cameras)
def get_name(self, i):
return self._cameras[i].name
def get_value(self, i):
return self._cameras[i].port
class CameraAbilities:
def __init__(self, operations):
self.operations = operations
class CameraAbilitiesList:
def load(self):
pass
def lookup_model(self, name):
return 0
def get_abilities(self, index):
return CameraAbilities(
GP_OPERATION_CAPTURE_IMAGE
| GP_OPERATION_CAPTURE_PREVIEW
| GP_OPERATION_CONFIG
)
class PortInfoList:
def load(self):
pass
def lookup_path(self, path):
return 0
def __getitem__(self, index):
return f"MockPortInfo({index})"
# --- Mock Camera class ---
class Camera:
def __init__(self):
self.initialized = False
self.port_info = None
def init(self):
self.initialized = True
def exit(self):
self.initialized = False
def set_port_info(self, info):
self.port_info = info
def get_config(self):
# Simulate config tree
root = MockWidget("root", "Root", GP_WIDGET_WINDOW)
iso = MockWidget("iso", "ISO", GP_WIDGET_MENU, "100", ["100", "200", "400", "800"])
shutter = MockWidget("shutter", "Shutter Speed", GP_WIDGET_MENU, "1/60", ["1/30", "1/60", "1/125"])
wb = MockWidget("whitebalance", "White Balance", GP_WIDGET_RADIO, "Auto", ["Auto", "Daylight", "Tungsten"])
root.add_child(iso)
root.add_child(shutter)
root.add_child(wb)
return root
def set_single_config(self, name, widget):
# Simulate saving a setting
print(f"[mock_gphoto] Setting '{name}' = '{widget.get_value()}'")
def capture_preview(self):
# Generate a fake image (OpenCV compatible)
frame = np.zeros((480, 640, 3), dtype=np.uint8)
cv2.putText(frame, "Mock Preview", (150, 240), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
_, buf = cv2.imencode(".jpg", frame)
class MockFile:
def get_data_and_size(self_inner):
return buf.tobytes()
return MockFile()
# --- Mock detection functions ---
def gp_camera_autodetect():
return MockCameraList()
def check_result(value):
# gphoto2.check_result usually raises error if return < 0
return value
# --- API aliases to match gphoto2 ---
GP_ERROR = -1
gp_camera_autodetect = gp_camera_autodetect
check_result = check_result
Camera = Camera
CameraAbilitiesList = CameraAbilitiesList
PortInfoList = PortInfoList
GPhoto2Error = GPhoto2Error

View File

@@ -0,0 +1,122 @@
import cv2
from cv2_enumerate_cameras import enumerate_cameras
from typing import List
from .base_camera import BaseCamera
class OpenCvCamera(BaseCamera):
"""Kamera oparta na cv2.VideoCapture"""
config_map = {
0: {"name": "frame_width", "cv_prop": cv2.CAP_PROP_FRAME_WIDTH, "default": 640},
1: {"name": "frame_height", "cv_prop": cv2.CAP_PROP_FRAME_HEIGHT, "default": 480},
2: {"name": "fps", "cv_prop": cv2.CAP_PROP_FPS, "default": 30},
3: {"name": "brightness", "cv_prop": cv2.CAP_PROP_BRIGHTNESS, "default": 0.5},
4: {"name": "contrast", "cv_prop": cv2.CAP_PROP_CONTRAST, "default": 0.5},
5: {"name": "saturation", "cv_prop": cv2.CAP_PROP_SATURATION, "default": 0.5},
}
def __init__(self) -> None:
super().__init__()
self.cap = None
self.configs: List[dict] = []
self.camera_list = []
self.camera_index = 0
@staticmethod
def detect():
camera_list = enumerate_cameras(cv2.CAP_ANY)
result = {}
for camera in camera_list:
cap = cv2.VideoCapture(camera.index, camera.backend)
# ret, frame = cap.read()
cap.release()
# if ret and frame is not None and frame.size > 0:
result[camera.index] = {
"name": camera.name,
"port": camera.path,
"backend": camera.backend,
}
return result
def connect(self, index: int | None = None) -> bool:
self.error_msg = None
try:
if index:
self.camera_index = index
self.cap = cv2.VideoCapture(self.camera_index)
if not self.cap.isOpened():
self.error_msg = f"[CV2] Could not open camera {self.camera_index}"
return False
self.configs.clear()
for id, conf in self.config_map.items():
value = self.cap.get(conf["cv_prop"])
self.configs.append(
{
"id": id,
"name": conf["name"],
"label": conf["name"].capitalize(),
"value": value,
"choices": None, # brak predefiniowanych wyborów
"cv_prop": conf["cv_prop"],
}
)
return True
except Exception as e:
self.error_msg = f"[CV2] {e}"
self.cap = None
return False
def disconnect(self) -> None:
if self.cap:
self.cap.release()
self.cap = None
self.configs.clear()
def get_frame(self):
self.error_msg = None
if self.cap is None or not self.cap.isOpened():
self.error_msg = "[CV2] Camera is not initialized."
return (False, None)
try:
ret, frame = self.cap.read()
if not ret:
self.error_msg = "[CV2] Failed to read frame."
return (False, None)
return (True, frame)
except Exception as e:
self.error_msg = f"[CV2] {e}"
return (False, None)
def get_config_by_id(self, id: int):
return next(w for w in self.configs if w["id"] == id)
def get_config_by_name(self, name: str):
return next(w for w in self.configs if w["name"] == name)
def set_config(self, config, value: float):
if not self.cap:
return
try:
self.cap.set(config["cv_prop"], value)
config["value"] = self.cap.get(
config["cv_prop"]) # sprawdz co ustawiło
except Exception as e:
self.error_msg = f"[CV2] {e}"
def set_config_by_id(self, id: int, value: float):
config = self.get_config_by_id(id)
self.set_config(config, value)
def set_config_by_name(self, name: str, value: float):
config = self.get_config_by_name(name)
self.set_config(config, value)

57
core/camera/states.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from controllers.main_controller import MainController
class CameraState(ABC):
"""Abstract base class for all camera states."""
def enter_state(self, controller: MainController):
"""Called upon entering the state, e.g., to update the UI."""
pass
@abstractmethod
def handle_start_button(self, controller: MainController):
"""Handles the main camera button click."""
pass
class NoCamerasState(CameraState):
def enter_state(self, controller: MainController):
controller.welcome_view.set_button_text("Wykryj kamery")
controller.welcome_view.set_info_text("Nie wykryto kamer. Kliknij, aby rozpocząć.")
controller.welcome_view.camera_start_btn.setEnabled(True)
def handle_start_button(self, controller: MainController):
controller.camera_detect()
class DetectingState(CameraState):
def enter_state(self, controller: MainController):
controller.welcome_view.set_button_text("Wykrywanie...")
controller.welcome_view.set_info_text("Trwa wykrywanie kamer...")
controller.welcome_view.camera_start_btn.setEnabled(False)
def handle_start_button(self, controller: MainController):
# Do nothing while detecting
pass
class ReadyToStreamState(CameraState):
def enter_state(self, controller: MainController):
cameras = controller.camera_manager.get_detected_cameras()
controller.welcome_view.set_button_text("Uruchom kamerę")
controller.welcome_view.set_info_text(f"Wykryto {len(cameras)} kamer(y).")
controller.welcome_view.camera_start_btn.setEnabled(True)
def handle_start_button(self, controller: MainController):
controller.start_liveview()
class StreamingState(CameraState):
def enter_state(self, controller: MainController):
controller.welcome_view.set_button_text("Zatrzymaj kamerę")
controller.welcome_view.camera_start_btn.setEnabled(True)
if controller.split_view.stack.currentWidget() != controller.live_view:
controller.split_view.toggle_live_view()
def handle_start_button(self, controller: MainController):
controller.stop_liveview()

View File

@@ -154,3 +154,6 @@ class DatabaseManager:
cur = self.conn.cursor()
cur.execute("DELETE FROM media WHERE color_id = ?", (color_id,))
self.conn.commit()
db_manager = DatabaseManager()

View File

@@ -1,13 +1,40 @@
from pathlib import Path
import shutil
from core.database import DatabaseManager
from datetime import datetime
from PySide6.QtGui import QPixmap
from core.database import db_manager
MEDIA_DIR = Path("media")
DEFAULT_ICON = Path("media/default_icon.jpg")
class MediaRepository:
def __init__(self, db: DatabaseManager):
self.db = db
def __init__(self):
self.db = db_manager
def save_photo(self, pixmap: QPixmap, color_name: str):
"""Saves a pixmap to the media directory for the given color."""
color_id = self.db.get_color_id(color_name)
if color_id is None:
print(f"Error: Could not find color ID for {color_name}")
return
# Create directory if it doesn't exist
color_dir = MEDIA_DIR / color_name
color_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"photo_{timestamp}.jpg"
file_path = color_dir / filename
# Save the pixmap
if not pixmap.save(str(file_path), "JPG", 95):
print(f"Error: Failed to save pixmap to {file_path}")
return
# Add to database
self.db.add_media(color_id, str(file_path), 'photo')
print(f"Saved photo to {file_path}")
def sync_media(self):
disk_colors = {d.name for d in MEDIA_DIR.iterdir() if d.is_dir()}

View File

@@ -1,6 +1,7 @@
import sys
from PySide6.QtWidgets import QApplication
from ui.main_palette import set_dark_theme
from ui.main_window import MainWindow
from controllers.main_controller import MainController
@@ -8,9 +9,13 @@ from controllers.main_controller import MainController
def main():
app = QApplication(sys.argv)
set_dark_theme(app)
# app.setStyle("Fusion")
window = MainWindow()
controller = MainController(window)
controller.load_colors()
app.aboutToQuit.connect(controller.shutdown)
window.show()
sys.exit(app.exec())

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
cv2_enumerate_cameras==1.3.0
gphoto2==2.6.2
mypy==1.18.2
mypy_extensions==1.1.0
numpy==2.2.6
opencv-python==4.12.0.88
pathspec==0.12.1
PySide6==6.9.2
PySide6_Addons==6.9.2
PySide6_Essentials==6.9.2
shiboken6==6.9.2
typing_extensions==4.15.0

72
review.md Normal file
View File

@@ -0,0 +1,72 @@
Przejrzałem kod Twojej aplikacji. Gratuluję, to bardzo dobrze zorganizowany projekt, zwłaszcza jak na aplikację, która jest jeszcze w trakcie rozwoju. Widać dużą dbałość o architekturę i separację poszczególnych warstw.
Poniżej znajdziesz moje uwagi podzielone na kategorie, o które prosiłeś.
### 1. Architektura Aplikacji
Twoja architektura jest największym plusem projektu.
* **Wzorzec MVC/Controller:** Podział na foldery `core` (Model), `ui` (View) i `controllers` (Controller) jest świetnym zastosowaniem tego wzorca. `MainController` dobrze pełni rolę pośrednika, oddzielając logikę biznesową od interfejsu użytkownika.
* **Podsystem kamery (`core/camera`):** To jest wzorowo zaprojektowana część aplikacji.
* **Fasada (`CameraManager`):** Użycie `CameraManager` jako fasady, która ukrywa skomplikowaną logikę wyboru i zarządzania kamerą, jest doskonałym pomysłem. Upraszcza to reszcie aplikacji interakcję z kamerami.
* **Strategia (`BaseCamera`):** Abstrakcyjna klasa `BaseCamera` i jej konkretne implementacje (`GPhotoCamera`, `OpenCvCamera`) to świetne użycie wzorca strategii. Pozwala to na łatwe dodawanie nowych typów kamer w przyszłości.
* **Wątkowość:** Przeniesienie obsługi kamery do osobnego wątku (`CameraWorker` i `QThread`) jest kluczowe dla aplikacji Qt i zostało zrobione poprawnie. Dzięki temu interfejs użytkownika nie zawiesza się podczas przechwytywania obrazu.
* **Problem do rozwiązania:**
* **Zduplikowane pliki:** Zauważyłem, że w projekcie istnieją dwa pliki o nazwie `camera_controller.py` (w `controllers/` i `core/camera/`) oraz dwa pliki `mock_gphoto.py`. Wygląda na to, że te w folderze `controllers/` są starszymi, nieużywanymi wersjami. **Sugeruję ich usunięcie**, aby uniknąć pomyłek w przyszłości. Nowsze wersje w `core/camera/` są znacznie bardziej rozbudowane i bezpieczniejsze (np. używają `QMutex`).
### 2. Co można napisać lepiej? (Sugestie i Ulepszenia)
* **Zarządzanie ścieżkami i zasobami:**
* W kodzie UI (np. w `ui/widgets/split_view_widget.py`) ścieżki do ikon są wpisane na stałe (np. `"ui/icons/rotate-cw-svgrepo-com.svg"`). To może powodować problemy, jeśli uruchomisz aplikację z innego katalogu.
* **Sugestia:** Użyj biblioteki `pathlib` w połączeniu z globalną stałą, aby tworzyć absolutne ścieżki do zasobów. Możesz stworzyć plik `settings.py` lub `config.py`, w którym zdefiniujesz główny katalog aplikacji i podkatalogi z zasobami.
* **Logika rotacji obrazu referencyjnego:**
* W `SplitView.rotate_left()` i `rotate_right()`, transformujesz pixmapę, która mogła być już wcześniej obrócona (`self.ref_pixmap = self.ref_pixmap.transformed(...)`). Każda taka operacja może prowadzić do utraty jakości obrazu.
* **Sugestia:** Przechowuj oryginalny, niezmodyfikowany `QPixmap` w osobnej zmiennej. Przy każdej rotacji transformuj ten oryginalny obraz o łączny kąt obrotu, a nie wynik poprzedniej transformacji.
* **Upraszczanie logiki sygnałów w `MainController`:**
* W metodzie `on_cameras_detected` i innych, wielokrotnie rozłączasz i podłączasz sygnały do slotów przycisku (`camera_start_btn.clicked.disconnect()`). Jest to podejście podatne na błędy.
* **Sugestia:** Zamiast tego, użyj jednego slotu, który w środku sprawdza aktualny stan aplikacji (np. `if self.camera_manager.is_camera_active(): ...`). Możesz też po prostu zmieniać tekst i stan (`setEnabled`) przycisków w zależności od kontekstu.
* **Obsługa błędów:**
* W niektórych miejscach (np. `GPhotoCamera.detect`) używasz szerokiego `except Exception as e:`. Warto łapać bardziej specyficzne wyjątki (np. `gp.GPhoto2Error`), aby lepiej reagować na konkretne problemy.
### 3. Wzorce projektowe do rozważenia
Używasz już wielu dobrych wzorców (Fasada, Strategia, Obserwator przez sygnały/sloty, Worker Thread). Oto kilka dodatkowych, które mogłyby się przydać:
* **Singleton:** Klasa `DatabaseManager` jest idealnym kandydatem na Singletona. W całej aplikacji potrzebujesz tylko jednego połączenia z bazą danych. Można to zrealizować tworząc jedną, globalną instancję w module `database.py`, którą inne części aplikacji będą importować.
* **State (Stan):** Logika związana ze stanem kamery (brak kamery, wykryto, działa, zatrzymana) w `MainController` mogłaby zostać zamknięta we wzorcu Stan. Miałbyś obiekty reprezentujące każdy stan (np. `NoCameraState`, `StreamingState`), a każdy z nich inaczej obsługiwałby te same akcje (np. kliknięcie przycisku "Start"). To oczyściłoby kod z wielu instrukcji `if/else`.
### Podsumowanie i konkretne kroki
To bardzo obiecujący projekt z solidnymi fundamentami. Moje sugestie mają na celu głównie "doszlifowanie" istniejącego kodu.
**Zalecane działania (w kolejności od najważniejszych):**
1. **Wyczyść projekt:** Usuń zduplikowane pliki `controllers/camera_controller.py` i `controllers/mock_gphoto.py`, aby uniknąć nieporozumień.
2. **Zrefaktoryzuj ścieżki:** Popraw sposób zarządzania ścieżkami do ikon i innych zasobów, aby uniezależnić się od bieżącego katalogu roboczego.
3. **Popraw rotację obrazu:** Zmodyfikuj logikę w `SplitView`, aby uniknąć degradacji jakości obrazu przy wielokrotnym obracaniu.
4. **Uprość logikę UI:** Zastanów się nad refaktoryzacją obsługi stanu przycisków w `MainController`, aby kod był bardziej czytelny i mniej podatny na błędy.
Świetna robota! Jeśli masz więcej pytań lub chciałbyś, żebym przyjrzał się jakiemuś konkretnemu fragmentowi, daj znać.
---
### Postęp Prac (14.10.2025)
Na podstawie powyższej recenzji, wspólnie wprowadziliśmy następujące zmiany:
* **Zrefaktoryzowano ścieżki do zasobów (Zrealizowano):**
* Utworzono plik `settings.py` do centralnego zarządzania ścieżkami.
* Zaktualizowano komponenty UI (`split_view_widget.py`, `view_settings_dialog.py`), aby korzystały ze scentralizowanych ścieżek, co uniezależniło aplikację od katalogu roboczego.
* **Poprawiono logikę rotacji obrazu (Zrealizowano):**
* Zmieniono mechanizm obracania obrazu referencyjnego w `SplitView`, aby operacje były wykonywane na oryginalnym obrazie. Zapobiega to stopniowej utracie jakości przy wielokrotnych rotacjach.
* **Uproszczono logikę sygnałów w `MainController` (Zrealizowano):**
* Zastąpiono dynamiczne łączenie i rozłączanie sygnałów przycisku kamery jednym, stałym połączeniem i centralną metodą obsługi. Zwiększyło to czytelność i niezawodność kodu.
* **Wyczyszczono projekt (Zrealizowano):**
* Użytkownik potwierdził usunięcie zduplikowanych plików (`camera_controller.py` i `mock_gphoto.py`) z katalogu `controllers`.

13
settings.py Normal file
View File

@@ -0,0 +1,13 @@
from pathlib import Path
# Absolutna ścieżka do głównego katalogu projektu
ROOT_DIR = Path(__file__).parent.resolve()
# Ścieżka do katalogu UI
UI_DIR = ROOT_DIR / "ui"
# Ścieżka do katalogu z ikonami
ICONS_DIR = UI_DIR / "icons"
# Ścieżka do katalogu z mediami
MEDIA_DIR = ROOT_DIR / "media"

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>arrow_left [#335]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-345.000000, -6679.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M299.633777,6519.29231 L299.633777,6519.29231 C299.228878,6518.90256 298.573377,6518.90256 298.169513,6519.29231 L289.606572,6527.55587 C288.797809,6528.33636 288.797809,6529.60253 289.606572,6530.38301 L298.231646,6538.70754 C298.632403,6539.09329 299.27962,6539.09828 299.685554,6538.71753 L299.685554,6538.71753 C300.100809,6538.32879 300.104951,6537.68821 299.696945,6537.29347 L291.802968,6529.67648 C291.398069,6529.28574 291.398069,6528.65315 291.802968,6528.26241 L299.633777,6520.70538 C300.038676,6520.31563 300.038676,6519.68305 299.633777,6519.29231" id="arrow_left-[#335]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>arrow_right [#336]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-305.000000, -6679.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M249.365851,6538.70769 L249.365851,6538.70769 C249.770764,6539.09744 250.426289,6539.09744 250.830166,6538.70769 L259.393407,6530.44413 C260.202198,6529.66364 260.202198,6528.39747 259.393407,6527.61699 L250.768031,6519.29246 C250.367261,6518.90671 249.720021,6518.90172 249.314072,6519.28247 L249.314072,6519.28247 C248.899839,6519.67121 248.894661,6520.31179 249.302681,6520.70653 L257.196934,6528.32352 C257.601847,6528.71426 257.601847,6529.34685 257.196934,6529.73759 L249.365851,6537.29462 C248.960938,6537.68437 248.960938,6538.31795 249.365851,6538.70769" id="arrow_right-[#336]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

64
ui/icons/camera_hdmi.svg Normal file
View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="800"
height="800"
viewBox="0 0 800 800"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
sodipodi:docname="camera_hdmi.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.0805556"
inkscape:cx="-35.167095"
inkscape:cy="455.78406"
inkscape:window-width="3440"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<rect
x="88.843188"
y="123.08483"
width="808.84319"
height="255.42416"
id="rect1" />
</defs>
<g
inkscape:label="Warstwa 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
id="text1"
style="text-align:start;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect1);display:inline;fill:#000000;stroke:#979797;stroke-width:12;stroke-linecap:round" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
x="-24.242525"
y="518.46466"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
x="-24.242525"
y="518.46466"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-dasharray:none;stroke-opacity:1">HDMI</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

64
ui/icons/camera_usb.svg Normal file
View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="800"
height="800"
viewBox="0 0 800 800"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
sodipodi:docname="camera_usb.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.0805556"
inkscape:cx="-35.167095"
inkscape:cy="455.78406"
inkscape:window-width="3440"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<rect
x="88.843188"
y="123.08483"
width="808.84319"
height="255.42416"
id="rect1" />
</defs>
<g
inkscape:label="Warstwa 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
id="text1"
style="text-align:start;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect1);display:inline;fill:#000000;stroke:#979797;stroke-width:12;stroke-linecap:round" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
x="46.868595"
y="518.38385"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
x="46.868595"
y="518.38385"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-dasharray:none;stroke-opacity:1">USB</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="800"
height="800"
viewBox="0 0 800 800"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
sodipodi:docname="camera_usb_hdmi.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.0805556"
inkscape:cx="-35.167095"
inkscape:cy="455.78406"
inkscape:window-width="3440"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<rect
x="88.843188"
y="123.08483"
width="808.84319"
height="255.42416"
id="rect1" />
</defs>
<g
inkscape:label="Warstwa 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
id="text1"
style="text-align:start;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect1);display:inline;fill:#000000;stroke:#979797;stroke-width:12;stroke-linecap:round" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
x="-24.243189"
y="721.06696"
id="text3"><tspan
sodipodi:role="line"
id="tspan3"
x="-24.243189"
y="721.06696"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-dasharray:none;stroke-opacity:1">HDMI</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
x="46.868595"
y="298.13632"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
x="46.868595"
y="298.13632"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:330.99px;font-family:Arial;-inkscape-font-specification:'Arial Bold';fill:#000000;fill-opacity:1;stroke:none;stroke-width:22.6283;stroke-dasharray:none;stroke-opacity:1">USB</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,2 @@
<?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 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.493 0.015 C 7.442 0.021,7.268 0.039,7.107 0.055 C 5.234 0.242,3.347 1.208,2.071 2.634 C 0.660 4.211,-0.057 6.168,0.009 8.253 C 0.124 11.854,2.599 14.903,6.110 15.771 C 8.169 16.280,10.433 15.917,12.227 14.791 C 14.017 13.666,15.270 11.933,15.771 9.887 C 15.943 9.186,15.983 8.829,15.983 8.000 C 15.983 7.171,15.943 6.814,15.771 6.113 C 14.979 2.878,12.315 0.498,9.000 0.064 C 8.716 0.027,7.683 -0.006,7.493 0.015 M8.853 1.563 C 9.967 1.707,11.010 2.136,11.944 2.834 C 12.273 3.080,12.920 3.727,13.166 4.056 C 13.727 4.807,14.142 5.690,14.330 6.535 C 14.544 7.500,14.544 8.500,14.330 9.465 C 13.916 11.326,12.605 12.978,10.867 13.828 C 10.239 14.135,9.591 14.336,8.880 14.444 C 8.456 14.509,7.544 14.509,7.120 14.444 C 5.172 14.148,3.528 13.085,2.493 11.451 C 2.279 11.114,1.999 10.526,1.859 10.119 C 1.618 9.422,1.514 8.781,1.514 8.000 C 1.514 6.961,1.715 6.075,2.160 5.160 C 2.500 4.462,2.846 3.980,3.413 3.413 C 3.980 2.846,4.462 2.500,5.160 2.160 C 6.313 1.599,7.567 1.397,8.853 1.563 M7.706 4.290 C 7.482 4.363,7.355 4.491,7.293 4.705 C 7.257 4.827,7.253 5.106,7.259 6.816 C 7.267 8.786,7.267 8.787,7.325 8.896 C 7.398 9.033,7.538 9.157,7.671 9.204 C 7.803 9.250,8.197 9.250,8.329 9.204 C 8.462 9.157,8.602 9.033,8.675 8.896 C 8.733 8.787,8.733 8.786,8.741 6.816 C 8.749 4.664,8.749 4.662,8.596 4.481 C 8.472 4.333,8.339 4.284,8.040 4.276 C 7.893 4.272,7.743 4.278,7.706 4.290 M7.786 10.530 C 7.597 10.592,7.410 10.753,7.319 10.932 C 7.249 11.072,7.237 11.325,7.294 11.495 C 7.388 11.780,7.697 12.000,8.000 12.000 C 8.303 12.000,8.612 11.780,8.706 11.495 C 8.763 11.325,8.751 11.072,8.681 10.932 C 8.616 10.804,8.460 10.646,8.333 10.580 C 8.217 10.520,7.904 10.491,7.786 10.530 " stroke="none" fill-rule="evenodd" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

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 fill-rule="evenodd" clip-rule="evenodd" d="M15.079 3.46209C15.3762 3.17355 15.851 3.18054 16.1396 3.47771L19.538 6.9777C19.8205 7.26871 19.8205 7.73162 19.538 8.02263L16.1396 11.5226C15.851 11.8198 15.3762 11.8268 15.079 11.5382C14.7819 11.2497 14.7749 10.7749 15.0634 10.4777L17.2263 8.25015L4.99989 8.25015C4.58567 8.25015 4.24989 7.91437 4.24989 7.50015C4.24989 7.08594 4.58567 6.75015 4.99989 6.75015L17.2263 6.75015L15.0634 4.52264C14.7749 4.22546 14.7819 3.75064 15.079 3.46209ZM8.92071 12.4618C9.21788 12.7504 9.22488 13.2252 8.93633 13.5224L6.77327 15.7501L18.9999 15.7501C19.4141 15.7501 19.7499 16.0859 19.7499 16.5001C19.7499 16.9143 19.4141 17.2501 18.9999 17.2501L6.77366 17.2501L8.93633 19.4774C9.22488 19.7746 9.21788 20.2494 8.92071 20.538C8.62353 20.8265 8.14871 20.8195 7.86016 20.5224L4.46177 17.0224C4.17922 16.7314 4.17922 16.2685 4.46177 15.9774L7.86016 12.4775C8.14871 12.1803 8.62353 12.1733 8.92071 12.4618Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 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 fill-rule="evenodd" clip-rule="evenodd" d="M7.49976 4.25001C7.91398 4.25001 8.24976 4.5858 8.24976 5.00001L8.24976 17.2266L10.4775 15.0636C10.7747 14.775 11.2495 14.782 11.538 15.0792C11.8266 15.3763 11.8196 15.8512 11.5224 16.1397L8.02243 19.5381C7.73142 19.8207 7.26851 19.8207 6.9775 19.5381L3.47751 16.1397C3.18034 15.8512 3.17335 15.3763 3.4619 15.0792C3.75044 14.782 4.22527 14.775 4.52244 15.0636L6.74976 17.2262L6.74976 5.00001C6.74976 4.5858 7.08555 4.25001 7.49976 4.25001Z" fill="#000000"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9773 4.4619C16.2683 4.17934 16.7312 4.17934 17.0222 4.4619L20.5222 7.86029C20.8193 8.14884 20.8263 8.62366 20.5378 8.92083C20.2492 9.21801 19.7744 9.225 19.4772 8.93645L17.2497 6.7736L17.2497 19C17.2497 19.4142 16.9139 19.75 16.4997 19.75C16.0855 19.75 15.7497 19.4142 15.7497 19L15.7497 6.77358L13.5222 8.93645C13.225 9.225 12.7502 9.21801 12.4616 8.92083C12.1731 8.62366 12.1801 8.14884 12.4773 7.86029L15.9773 4.4619Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 16 16"
version="1.1"
id="svg1"
sodipodi:docname="horizontal-stacks-svgrepo-com.svg"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.98875"
inkscape:cx="579.5196"
inkscape:cy="342.35145"
inkscape:window-width="3440"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="M 13.5,14.82 V 1.18 C 13.5,0.53 12.97,0 12.32,0 H 9.8 C 9.15,0 8.62,0.53 8.62,1.18 V 14.82 C 8.62,15.47 9.15,16 9.8,16 h 2.52 c 0.65,0 1.18,-0.53 1.18,-1.18 z M 9.88,14.75 V 1.25 h 2.37 v 13.5 z m -2.5,0.07 V 1.18 C 7.38,0.53 6.85,0 6.2,0 H 3.68 C 3.03,0 2.5,0.53 2.5,1.18 V 14.82 C 2.5,15.47 3.03,16 3.68,16 H 6.2 c 0.65,0 1.18,-0.53 1.18,-1.18 z M 3.75,14.75 V 1.25 h 2.38 v 13.5 z"
id="path1" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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="M12.5 20.5C17.1944 20.5 21 16.6944 21 12C21 7.30558 17.1944 3.5 12.5 3.5C7.80558 3.5 4 7.30558 4 12C4 13.5433 4.41128 14.9905 5.13022 16.238M1.5 15L5.13022 16.238M6.82531 12.3832L5.47107 16.3542L5.13022 16.238" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 533 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="M11.5 20.5C6.80558 20.5 3 16.6944 3 12C3 7.30558 6.80558 3.5 11.5 3.5C16.1944 3.5 20 7.30558 20 12C20 13.5433 19.5887 14.9905 18.8698 16.238M22.5 15L18.8698 16.238M17.1747 12.3832L18.5289 16.3542L18.8698 16.238" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 30 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<title>settings</title>
<desc>Created with Sketch Beta.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="Icon-Set" sketch:type="MSLayerGroup" transform="translate(-101.000000, -360.000000)" fill="#000000">
<path d="M128.52,381.134 L127.528,382.866 C127.254,383.345 126.648,383.508 126.173,383.232 L123.418,381.628 C122.02,383.219 120.129,384.359 117.983,384.799 L117.983,387 C117.983,387.553 117.54,388 116.992,388 L115.008,388 C114.46,388 114.017,387.553 114.017,387 L114.017,384.799 C111.871,384.359 109.98,383.219 108.582,381.628 L105.827,383.232 C105.352,383.508 104.746,383.345 104.472,382.866 L103.48,381.134 C103.206,380.656 103.369,380.044 103.843,379.769 L106.609,378.157 C106.28,377.163 106.083,376.106 106.083,375 C106.083,373.894 106.28,372.838 106.609,371.843 L103.843,370.232 C103.369,369.956 103.206,369.345 103.48,368.866 L104.472,367.134 C104.746,366.656 105.352,366.492 105.827,366.768 L108.582,368.372 C109.98,366.781 111.871,365.641 114.017,365.201 L114.017,363 C114.017,362.447 114.46,362 115.008,362 L116.992,362 C117.54,362 117.983,362.447 117.983,363 L117.983,365.201 C120.129,365.641 122.02,366.781 123.418,368.372 L126.173,366.768 C126.648,366.492 127.254,366.656 127.528,367.134 L128.52,368.866 C128.794,369.345 128.631,369.956 128.157,370.232 L125.391,371.843 C125.72,372.838 125.917,373.894 125.917,375 C125.917,376.106 125.72,377.163 125.391,378.157 L128.157,379.769 C128.631,380.044 128.794,380.656 128.52,381.134 L128.52,381.134 Z M130.008,378.536 L127.685,377.184 C127.815,376.474 127.901,375.749 127.901,375 C127.901,374.252 127.815,373.526 127.685,372.816 L130.008,371.464 C130.957,370.912 131.281,369.688 130.733,368.732 L128.75,365.268 C128.203,364.312 126.989,363.983 126.041,364.536 L123.694,365.901 C122.598,364.961 121.352,364.192 119.967,363.697 L119.967,362 C119.967,360.896 119.079,360 117.983,360 L114.017,360 C112.921,360 112.033,360.896 112.033,362 L112.033,363.697 C110.648,364.192 109.402,364.961 108.306,365.901 L105.959,364.536 C105.011,363.983 103.797,364.312 103.25,365.268 L101.267,368.732 C100.719,369.688 101.044,370.912 101.992,371.464 L104.315,372.816 C104.185,373.526 104.099,374.252 104.099,375 C104.099,375.749 104.185,376.474 104.315,377.184 L101.992,378.536 C101.044,379.088 100.719,380.312 101.267,381.268 L103.25,384.732 C103.797,385.688 105.011,386.017 105.959,385.464 L108.306,384.099 C109.402,385.039 110.648,385.809 112.033,386.303 L112.033,388 C112.033,389.104 112.921,390 114.017,390 L117.983,390 C119.079,390 119.967,389.104 119.967,388 L119.967,386.303 C121.352,385.809 122.598,385.039 123.694,384.099 L126.041,385.464 C126.989,386.017 128.203,385.688 128.75,384.732 L130.733,381.268 C131.281,380.312 130.957,379.088 130.008,378.536 L130.008,378.536 Z M116,378 C114.357,378 113.025,376.657 113.025,375 C113.025,373.344 114.357,372 116,372 C117.643,372 118.975,373.344 118.975,375 C118.975,376.657 117.643,378 116,378 L116,378 Z M116,370 C113.261,370 111.042,372.238 111.042,375 C111.042,377.762 113.261,380 116,380 C118.739,380 120.959,377.762 120.959,375 C120.959,372.238 118.739,370 116,370 L116,370 Z" id="settings" sketch:type="MSShapeGroup">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.82 2.5H1.18C.53 2.5 0 3.03 0 3.68V6.2c0 .65.53 1.18 1.18 1.18h13.64c.65 0 1.18-.53 1.18-1.18V3.68c0-.65-.53-1.18-1.18-1.18zm-.07 3.62H1.25V3.75h13.5v2.37zm.07 2.5H1.18C.53 8.62 0 9.15 0 9.8v2.52c0 .65.53 1.18 1.18 1.18h13.64c.65 0 1.18-.53 1.18-1.18V9.8c0-.65-.53-1.18-1.18-1.18zm-.07 3.63H1.25V9.87h13.5v2.38z"/></svg>

After

Width:  |  Height:  |  Size: 556 B

48
ui/main_palette.py Normal file
View File

@@ -0,0 +1,48 @@
import sys
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPalette, QColor
from PySide6.QtCore import Qt
def set_dark_theme(app: QApplication):
"""Definiuje i stosuje ciemną paletę kolorów do aplikacji."""
# 1. Upewnij się, że styl jest ustawiony na "Fusion"
app.setStyle('Fusion')
# 2. Definicja kolorów dla ciemnego motywu
palette = QPalette()
# Kolory tła
DARK_GRAY = QColor(45, 45, 45) # Ogólne tło okien i widżetów (Base, Window)
LIGHT_GRAY = QColor(53, 53, 53) # Tło elementów, np. toolbara, menu (Window)
VERY_DARK_GRAY = QColor(32, 32, 32) # Kolor tła dla kontrolek (Button)
# Kolory tekstu i obramowań
WHITE = QColor(200, 200, 200) # Główny kolor tekstu (Text, WindowText)
HIGHLIGHT = QColor(66, 135, 245) # Kolor podświetlenia (Highlight)
# Ustawienie głównej palety
# palette.setColor(QPalette.ColorRole.Window, LIGHT_GRAY)
palette.setColor(QPalette.ColorRole.Window, VERY_DARK_GRAY)
palette.setColor(QPalette.ColorRole.WindowText, WHITE)
palette.setColor(QPalette.ColorRole.Base, DARK_GRAY)
palette.setColor(QPalette.ColorRole.AlternateBase, LIGHT_GRAY)
palette.setColor(QPalette.ColorRole.ToolTipBase, WHITE)
palette.setColor(QPalette.ColorRole.ToolTipText, WHITE)
palette.setColor(QPalette.ColorRole.Text, WHITE)
palette.setColor(QPalette.ColorRole.Button, VERY_DARK_GRAY)
palette.setColor(QPalette.ColorRole.ButtonText, WHITE)
palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(150, 150, 150))
# Kolory zaznaczenia/interakcji
palette.setColor(QPalette.ColorRole.Highlight, HIGHLIGHT)
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
# Kontrolki wyłączone (Disabled)
# palette.setColor(QPalette.ColorRole.Disabled, QPalette.ColorGroup.Active, QPalette.ColorRole.Text, QColor(127, 127, 127))
# palette.setColor(QPalette.ColorRole.Disabled, QPalette.ColorGroup.Active, QPalette.ColorRole.ButtonText, QColor(127, 127, 127))
# 3. Zastosowanie palety do aplikacji
app.setPalette(palette)

View File

@@ -10,6 +10,7 @@ from ui.widgets.placeholder_widget import PlaceholderWidget
from ui.widgets.color_list_widget import ColorListWidget
from ui.widgets.thumbnail_list_widget import ThumbnailListWidget
from ui.widgets.split_view_widget import SplitView
from ui.view_settings_dialog import ViewSettingsDialog
class MainWindow(QMainWindow):
def __init__(self):
@@ -42,6 +43,14 @@ class MainWindow(QMainWindow):
histogram_view = PlaceholderWidget("Histogram View", "#FF5733")
histogram_view.setFixedHeight(200)
self.view_settings_button = QPushButton("Ustawienia widoku")
self.view_settings_button.setMinimumHeight(40)
self.view_settings_button.setStyleSheet("font-size: 12pt;")
self.view_settings_dialog = ViewSettingsDialog(self)
self.view_settings_button.clicked.connect(self.view_settings_dialog.show)
self.color_list_widget = ColorListWidget(self.control_widget)
self.record_button = QPushButton("Nagraj Wideo")
@@ -53,6 +62,7 @@ class MainWindow(QMainWindow):
self.photo_button.setStyleSheet("font-size: 12pt;")
control_layout.addWidget(histogram_view)
control_layout.addWidget(self.view_settings_button)
control_layout.addWidget(self.color_list_widget)
control_layout.addWidget(self.record_button)
control_layout.addWidget(self.photo_button)

211
ui/view_settings_dialog.py Normal file
View File

@@ -0,0 +1,211 @@
from PySide6.QtWidgets import QDialog, QHBoxLayout ,QVBoxLayout, QPushButton, QGroupBox, QLabel, QRadioButton, QWidget, QToolButton, QSlider, QButtonGroup
from PySide6.QtGui import QIcon
from PySide6.QtCore import Qt, QSize, Signal
from settings import ICONS_DIR
ISO_ARR = ["AUTO","100", "200", "400", "800", "1600", "3200"]
SPEED_ARR = ["30", "25", "20", "15", "13", "10.3", "8", "6.3", "5", "4", "3.2", "2.5", "2", "1.6", "1.3", "1", "0.8", "0.6", "0.5", "0.4", "0.3", "1/4", "1/5", "1/6", "1/8", "1/10", "1/13", "1/15", "1/20", "1/25", "1/30", "1/40", "1/50", "1/60", "1/80", "1/100", "1/125", "1/160", "1/200", "1/250", "1/320", "1/400", "1/500", "1/640", "1/800", "1/1000", "1/1250", "1/1600", "1/2000", "1/2500", "1/3200", "1/4000"]
class LabeledSpinSelector(QWidget):
indexChanged = Signal(int)
def __init__(self, title: str, values: list[str], show_slider: bool = False, parent=None):
super().__init__(parent)
self.values = values
self.current_index = 0
self.show_slider = show_slider
self._init_ui(title)
def _init_ui(self, title: str, button_size: int = 24, icon_size: int = 16):
self.title_label = QLabel(title)
decrement_button = QToolButton()
decrement_button.setIcon(QIcon(str(ICONS_DIR / "arrow-left-335-svgrepo-com.svg")))
decrement_button.setFixedSize(button_size, button_size)
decrement_button.setIconSize(QSize(icon_size, icon_size))
decrement_button.clicked.connect(self._decrement)
increment_button = QToolButton()
increment_button.setIcon(QIcon(str(ICONS_DIR / "arrow-right-336-svgrepo-com.svg")))
increment_button.setFixedSize(button_size, button_size)
increment_button.setIconSize(QSize(icon_size, icon_size))
increment_button.clicked.connect(self._increment)
self.value_label = QLabel(self.values[self.current_index] if self.values else "N/A")
self.value_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.value_label.setStyleSheet("background-color: rgb(48, 48, 48);")
self.value_label.setFixedHeight(button_size - 2)
spin_layout = QHBoxLayout()
spin_layout.addWidget(decrement_button)
spin_layout.addWidget(self.value_label)
spin_layout.addWidget(increment_button)
top_layout = QHBoxLayout()
top_layout.addWidget(self.title_label)
top_layout.addLayout(spin_layout)
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setRange(0, max(0, len(self.values) - 1))
self.slider.setTickPosition(QSlider.TickPosition.TicksBelow)
self.slider.valueChanged.connect(self._slider_changed)
main_layout = QVBoxLayout()
main_layout.addLayout(top_layout)
if self.show_slider:
main_layout.addWidget(self.slider)
main_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(main_layout)
def _increment(self):
if not self.values:
return
new_index = min(self.current_index + 1, len(self.values) - 1)
self.set_index(new_index)
def _decrement(self):
if not self.values:
return
new_index = max(self.current_index - 1, 0)
self.set_index(new_index)
def _slider_changed(self, index):
if not self.values:
return
self.set_index(index)
def set_label(self, label: str):
self.title_label.setText(label)
def set_index(self, index: int):
if not self.values or not (0 <= index < len(self.values)):
return
if self.current_index != index:
self.current_index = index
self.value_label.setText(self.values[index])
if self.show_slider:
self.slider.setValue(index)
self.indexChanged.emit(index)
else:
# Always update UI even if index is the same (for initial set)
self.value_label.setText(self.values[index])
if self.show_slider:
self.slider.setValue(index)
class ViewSettingsDialog(QDialog):
detectDevice = Signal(str)
connectionChanged = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Ustawienia widoku")
self.setFixedSize(640, 480)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# self.setup_ui()
camera_frame = self._create_devices_frame("camera")
hdmi_frame = self._create_devices_frame("hdmi")
conn_frame = self._create_connection_frame()
camera_settings = self._create_settings_frame()
hdmi_settings = self._create_settings_frame()
dialog_buttons = self._create_dialog_buttons()
hdmi_settings.setEnabled(False)
settings_layout = QHBoxLayout()
settings_layout.addWidget(camera_settings, 1)
settings_layout.addWidget(hdmi_settings, 1)
# main layout
main_layout = QVBoxLayout(self)
main_layout.addWidget(camera_frame)
main_layout.addWidget(hdmi_frame)
main_layout.addWidget(conn_frame)
main_layout.addLayout(settings_layout)
main_layout.addLayout(dialog_buttons)
main_layout.setStretch(3, 1)
self.last_choice = None
def _create_dialog_buttons(self):
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Anuluj")
cancel_btn.setDefault(True)
cancel_btn.clicked.connect(self.reject)
layout = QHBoxLayout()
layout.addStretch()
layout.addWidget(ok_btn)
layout.addWidget(cancel_btn)
return layout
def _create_devices_frame(self, name):
frame = QGroupBox()
frame.setTitle("Wykryte aparaty")
frame.setContentsMargins(6, 20, 6, 10)
device_label = QLabel("Nie wykryto podłączonych urządzeń.")
detect_button = QPushButton("Wykryj...")
detect_button.clicked.connect(lambda: self.detectDevice.emit(name))
layout = QHBoxLayout()
layout.addWidget(device_label)
layout.addStretch()
layout.addWidget(detect_button)
frame.setLayout(layout)
return frame
def _create_connection_frame(self):
frame = QGroupBox()
frame.setTitle("Wybór połączenia")
frame.setContentsMargins(6, 20, 6, 10)
radio_usb = QRadioButton("USB")
radio_hybrid = QRadioButton("USB + HDMI")
radio_hdmi = QRadioButton("HDMI")
radio_hdmi.setEnabled(False)
radio_usb.clicked.connect(lambda: self.radio_toggle("usb"))
radio_hybrid.clicked.connect(lambda: self.radio_toggle("hybrid"))
radio_hdmi.clicked.connect(lambda: self.radio_toggle("hdmi"))
radio_layout = QHBoxLayout()
radio_layout.addStretch()
radio_layout.addWidget(radio_usb)
radio_layout.addStretch()
radio_layout.addWidget(radio_hybrid)
radio_layout.addStretch()
radio_layout.addWidget(radio_hdmi)
radio_layout.addStretch()
frame.setLayout(radio_layout)
return frame
# def _create_settings_frame(self, settings: dict[str, list[str]]):
def _create_settings_frame(self):
frame = QGroupBox()
frame.setTitle("Ustawienia aparatu")
layout = QVBoxLayout()
# for key, value in settings.items():
# layout.addWidget(LabeledSpinSelector(key, value))
layout.addStretch()
frame.setLayout(layout)
return frame
def radio_toggle(self, value):
if self.last_choice != value:
self.last_choice = value
self.connectionChanged.emit(value)

View File

@@ -1,164 +1,352 @@
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QApplication, QMainWindow, QWidget, QVBoxLayout, QSplitter, QStackedWidget, QPushButton, QLabel
from PySide6.QtGui import QPixmap, QWheelEvent, QPainter, QBrush, QColor
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QApplication, QMainWindow, QWidget, QVBoxLayout, QSplitter, QStackedWidget, QPushButton, QLabel, QToolButton
from PySide6.QtGui import QEnterEvent, QPixmap, QWheelEvent, QPainter, QBrush, QColor, QIcon, QImage, QTransform
from PySide6.QtCore import Qt, QSize, Signal, QEvent
import sys
from ui.widgets.placeholder_widget import PlaceholderWidget
from settings import ICONS_DIR
class ZoomableImageView(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None):
super().__init__(parent)
# Scena i element obrazu
self._scene = QGraphicsScene(self)
self.setScene(self._scene)
self._scene.setBackgroundBrush(QBrush(QColor(20, 20, 20))) # ciemne tło
# Scena i element obrazu
self._scene = QGraphicsScene(self)
self.setScene(self._scene)
self._scene.setBackgroundBrush(
QBrush(QColor(20, 20, 20))) # ciemne tło
self._pixmap_item = QGraphicsPixmapItem()
self._scene.addItem(self._pixmap_item)
self._pixmap_item = QGraphicsPixmapItem()
self._scene.addItem(self._pixmap_item)
# Ustawienia widoku
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # przesuwanie myszą
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
# Ustawienia widoku
# przesuwanie myszą
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
# Wyłączenie suwaków
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# Parametry zoomu
self._zoom_factor = 1.25
self._current_scale = 1.0
# Wyłączenie suwaków
self.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# Parametry zoomu
self._zoom_factor = 1.25
self._current_scale = 1.0
def set_image(self, pixmap: QPixmap):
# pixmap = QPixmap(image_path)
self._pixmap_item.setPixmap(pixmap)
self._scene.setSceneRect(pixmap.rect())
# self.reset_transform()
def set_image(self, pixmap: QPixmap):
if pixmap.isNull():
return
self._pixmap_item.setPixmap(pixmap)
self._scene.setSceneRect(pixmap.rect())
if self._current_scale == 1.0:
self.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
self._first_image = False
def reset_transform(self):
"""Resetuje skalowanie i ustawia 1:1"""
self._current_scale = 1.0
self.setTransform(self.transform().fromScale(1, 1))
def reset_transform(self):
"""Resetuje skalowanie i ustawia 1:1"""
self._current_scale = 1.0
self.setTransform(self.transform().fromScale(1, 1))
def wheelEvent(self, event: QWheelEvent):
"""Zoom kółkiem myszy"""
if event.modifiers() & Qt.KeyboardModifier.ControlModifier: # zoom tylko z CTRL
if event.angleDelta().y() > 0:
zoom = self._zoom_factor
else:
zoom = 1 / self._zoom_factor
def wheelEvent(self, event: QWheelEvent):
"""Zoom kółkiem myszy"""
if event.modifiers() & Qt.KeyboardModifier.ControlModifier: # zoom tylko z CTRL
if event.angleDelta().y() > 0:
zoom = self._zoom_factor
else:
zoom = 1 / self._zoom_factor
self._current_scale *= zoom
self.scale(zoom, zoom)
else:
return
super().wheelEvent(event) # normalne przewijanie
self._current_scale *= zoom
self.scale(zoom, zoom)
else:
return
super().wheelEvent(event) # normalne przewijanie
class CameraPlaceholder(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None):
super().__init__(parent)
self.setAutoFillBackground(True)
self.setStyleSheet("background-color: #141414;")
self.setAutoFillBackground(True)
self.setStyleSheet("background-color: #141414;")
layout = QVBoxLayout(self)
layout.setSpacing(20)
layout = QVBoxLayout(self)
layout.setSpacing(20)
self.camera_start_btn = QPushButton("Start Camera")
self.camera_start_btn.setFixedSize(200, 50)
style_sheet = """
QPushButton {
/* --- Styl podstawowy --- */
background-color: transparent;
border: 2px solid #CECECE; /* Grubość, styl i kolor obramowania */
border-radius: 25px; /* Kluczowa właściwość do zaokrąglenia rogów! */
color: #CECECE;
padding: 10px 20px; /* Wewnętrzny margines */
font-size: 16px;
}
self.camera_start_btn = QPushButton("Start Camera")
self.camera_start_btn.setFixedSize(200, 50)
style_sheet = """
QPushButton {
/* --- Styl podstawowy --- */
background-color: transparent;
border: 2px solid #CECECE; /* Grubość, styl i kolor obramowania */
border-radius: 25px; /* Kluczowa właściwość do zaokrąglenia rogów! */
color: #CECECE;
padding: 10px 20px; /* Wewnętrzny margines */
font-size: 16px;
}
QPushButton:hover {
/* --- Styl po najechaniu myszką --- */
color: #F0F0F0;
border: 2px solid #F0F0F0;
}
QPushButton:hover {
/* --- Styl po najechaniu myszką --- */
color: #F0F0F0;
border: 2px solid #F0F0F0;
}
QPushButton:pressed {
/* --- Styl po naciśnięciu --- */
background-color: #e0e0e0; /* Ciemniejsze tło w momencie kliknięcia */
border: 2px solid #e0e0e0; /* Zmiana koloru ramki dla sygnalizacji akcji */
}
"""
self.camera_start_btn.setStyleSheet(style_sheet)
QPushButton:pressed {
/* --- Styl po naciśnięciu --- */
background-color: #e0e0e0; /* Ciemniejsze tło w momencie kliknięcia */
border: 2px solid #e0e0e0; /* Zmiana koloru ramki dla sygnalizacji akcji */
}
"""
self.camera_start_btn.setStyleSheet(style_sheet)
self.info_label = QLabel("Kliknij, aby uruchomić kamerę")
self.info_label.setStyleSheet("background-color: transparent; color: #CECECE; font-size: 18px;")
self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.info_label = QLabel("Kliknij, aby uruchomić kamerę")
self.info_label.setStyleSheet(
"background-color: transparent; color: #CECECE; font-size: 18px;")
self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addStretch()
layout.addWidget(self.camera_start_btn, alignment=Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.info_label)
layout.addStretch()
self.setLayout(layout)
self.error_label = QLabel()
self.error_label.setStyleSheet(
"background-color: transparent; color: #CECECE; font-size: 18px; font-style: italic;")
self.error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
def set_info_text(self, text: str):
self.info_label.setText(text)
layout.addStretch()
layout.addWidget(self.camera_start_btn,
alignment=Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.info_label)
layout.addWidget(self.error_label)
layout.addStretch()
self.setLayout(layout)
def set_info_text(self, text: str):
self.info_label.setText(text)
def set_error_text(self, text: str):
self.error_label.setText(text)
def set_button_text(self, text:str):
self.camera_start_btn.setText(text)
class ViewWithOverlay(QWidget):
cameraConnection = Signal()
cameraSettings = Signal()
toggleOrientation = Signal()
swapViews = Signal()
rotateCW = Signal()
rotateCCW = Signal()
def __init__(self, live: bool = False):
super().__init__()
self.live = live
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.viewer = ZoomableImageView()
layout.addWidget(self.viewer)
self._create_top_right_buttons()
if self.live:
self._create_top_left_buttons()
self.resize(self.size())
# self.cw_btn.raise_()
# self.ccw_btn.raise_()
# self.flip_btn.raise_()
# self.orient_btn.raise_()
self.toggle_orientation(Qt.Orientation.Vertical)
def _create_tool_button(self, callback, icon_path: str | None):
icon_size = QSize(32, 32)
btn_size = (48, 48)
btn_style = """
background-color: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 2px solid #1f1f1f;
"""
btn = QToolButton(self)
if icon_path:
btn.setIcon(QIcon(icon_path))
btn.setIconSize(icon_size)
btn.setStyleSheet(btn_style)
btn.setFixedSize(*btn_size)
btn.clicked.connect(callback)
return btn
def _create_top_right_buttons(self):
self.cw_btn = self._create_tool_button(
icon_path=str(ICONS_DIR / "rotate-cw-svgrepo-com.svg"),
callback=self.rotateCW,
)
self.ccw_btn = self._create_tool_button(
icon_path=str(ICONS_DIR / "rotate-ccw-svgrepo-com.svg"),
callback=self.rotateCCW,
)
self.flip_btn = self._create_tool_button(
icon_path=None,
callback=self.swapViews,
)
self.orient_btn = self._create_tool_button(
icon_path=None,
callback=self.toggleOrientation,
)
def _create_top_left_buttons(self):
self.camera_btn = self._create_tool_button(
icon_path=str(ICONS_DIR / "settings-svgrepo-com.svg"),
callback=self.cameraConnection
)
self.settings_btn = self._create_tool_button(
icon_path=str(ICONS_DIR / "error-16-svgrepo-com.svg"),
callback=self.cameraSettings
)
def set_image(self, pixmap: QPixmap):
self.viewer.set_image(pixmap)
def resizeEvent(self, event):
super().resizeEvent(event)
# Aktualizacja pozycji przycisku przy zmianie rozmiaru
if self.live:
left_corner = 10
self.camera_btn.move(left_corner, 10)
left_corner += self.camera_btn.width() + 10
self.settings_btn.move(left_corner, 10)
right_corner = self.cw_btn.width() + 10
self.cw_btn.move(self.width() - right_corner, 10)
right_corner += self.ccw_btn.width() + 10
self.ccw_btn.move(self.width() - right_corner, 10)
right_corner += self.flip_btn.width() + 10
self.flip_btn.move(self.width() - right_corner, 10)
right_corner += self.orient_btn.width() + 10
self.orient_btn.move(self.width() - right_corner, 10)
def toggle_orientation(self, orientation):
if orientation == Qt.Orientation.Vertical:
self.flip_btn.setIcon(QIcon(str(ICONS_DIR / "flip-vertical-svgrepo-com.svg")))
self.orient_btn.setIcon(QIcon(str(ICONS_DIR / "horizontal-stacks-svgrepo-com.svg")))
else:
self.flip_btn.setIcon(QIcon(str(ICONS_DIR / "flip-horizontal-svgrepo-com.svg")))
self.orient_btn.setIcon(QIcon(str(ICONS_DIR / "vertical-stacks-svgrepo-com.svg")))
def enterEvent(self, event: QEnterEvent) -> None:
if self.live:
self.camera_btn.show()
self.settings_btn.show()
self.orient_btn.show()
self.flip_btn.show()
self.ccw_btn.show()
self.cw_btn.show()
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
if self.live:
self.camera_btn.hide()
self.settings_btn.hide()
self.orient_btn.hide()
self.flip_btn.hide()
self.ccw_btn.hide()
self.cw_btn.hide()
return super().leaveEvent(event)
class SplitView(QSplitter):
def __init__(self, parent=None):
super().__init__(parent)
print("Inicjalizacja SplitView2")
self.setOrientation(Qt.Orientation.Vertical)
def __init__(self, parent=None):
super().__init__(parent)
print("Inicjalizacja SplitView2")
self.setOrientation(Qt.Orientation.Vertical)
self.widget_start = CameraPlaceholder()
self.widget_live = ZoomableImageView()
# self.widget_live = PlaceholderWidget("Camera View", "#750466")
self.widget_ref = ZoomableImageView()
# self.widget_ref = PlaceholderWidget("Image View", "#007981")
self.widget_start = CameraPlaceholder()
self.widget_live = ViewWithOverlay(live=True)
self.widget_ref = ViewWithOverlay()
self.stack = QStackedWidget()
self.stack.addWidget(self.widget_start)
self.stack.addWidget(self.widget_live)
self.stack.setCurrentWidget(self.widget_start)
self.stack = QStackedWidget()
self.stack.addWidget(self.widget_start)
self.stack.addWidget(self.widget_live)
self.stack.setCurrentWidget(self.widget_start)
self.addWidget(self.stack)
self.addWidget(self.widget_ref)
self.addWidget(self.stack)
self.addWidget(self.widget_ref)
self.setSizes([self.height(), 0])
self.setSizes([self.height(), 0])
pixmap = QPixmap("media/empty_guitar_h.jpg")
# pixmap.fill(Qt.GlobalColor.lightGray)
self.widget_live.set_image(pixmap)
# pixmap = QPixmap("media/empty_guitar_h.jpg")
# pixmap.fill(Qt.GlobalColor.lightGray)
# self.widget_live.set_image(pixmap)
def toggle_orientation(self):
if self.orientation() == Qt.Orientation.Vertical:
self.setOrientation(Qt.Orientation.Horizontal)
self.setSizes([self.width()//2, self.width()//2])
else:
self.setOrientation(Qt.Orientation.Vertical)
self.setSizes([self.height()//2, self.height()//2])
# def set_live_image(self, path_image: str):
# """Ustawienie obrazu na żywo"""
# pixmap = QPixmap(path_image)
# self.widget_live.set_image(pixmap)
self.ref_image_rotate = 0
self.original_ref_pixmap = None
def set_live_image(self, pixmap: QPixmap):
"""Ustawienie obrazu na żywo"""
self.widget_live.set_image(pixmap)
if self.stack.currentWidget() != self.widget_live:
self.stack.setCurrentWidget(self.widget_live)
self.widget_live.toggleOrientation.connect(self.toggle_orientation)
self.widget_ref.toggleOrientation.connect(self.toggle_orientation)
self.widget_live.swapViews.connect(self.swap_views)
self.widget_ref.swapViews.connect(self.swap_views)
self.widget_ref.rotateCCW.connect(self.rotate_left)
self.widget_ref.rotateCW.connect(self.rotate_right)
def set_reference_image(self, path_image: str):
"""Ustawienie obrazu referencyjnego"""
pixmap = QPixmap(path_image)
self.widget_ref.set_image(pixmap)
def toggle_orientation(self):
if self.orientation() == Qt.Orientation.Vertical:
self.setOrientation(Qt.Orientation.Horizontal)
self.setSizes([self.width()//2, self.width()//2])
self.widget_live.toggle_orientation(Qt.Orientation.Horizontal)
self.widget_ref.toggle_orientation(Qt.Orientation.Horizontal)
else:
self.setOrientation(Qt.Orientation.Vertical)
self.setSizes([self.height()//2, self.height()//2])
self.widget_live.toggle_orientation(Qt.Orientation.Vertical)
self.widget_ref.toggle_orientation(Qt.Orientation.Vertical)
def toglle_live_view(self):
"""Przełączanie widoku na żywo"""
if self.stack.currentWidget() == self.widget_start:
self.stack.setCurrentWidget(self.widget_live)
else:
self.stack.setCurrentWidget(self.widget_start)
def swap_views(self):
"""Zamiana widoków miejscami"""
index_live = self.indexOf(self.stack)
index_ref = self.indexOf(self.widget_ref)
sizes = self.sizes()
self.insertWidget(index_live, self.widget_ref)
self.insertWidget(index_ref, self.stack)
self.setSizes(sizes)
def set_live_image(self, pixmap: QPixmap):
"""Ustawienie obrazu na żywo"""
self.widget_live.set_image(pixmap)
# if self.stack.currentWidget() != self.widget_live:
# self.stack.setCurrentWidget(self.widget_live)
def set_reference_image(self, path_image: str):
"""Ustawienie obrazu referencyjnego"""
self.original_ref_pixmap = QPixmap(path_image)
self.ref_image_rotate = 0
self.widget_ref.set_image(self.original_ref_pixmap)
def toggle_live_view(self):
"""Przełączanie widoku na żywo"""
if self.stack.currentWidget() == self.widget_start:
self.stack.setCurrentWidget(self.widget_live)
else:
self.stack.setCurrentWidget(self.widget_start)
def rotate_left(self):
if not self.original_ref_pixmap:
return
self.ref_image_rotate = (self.ref_image_rotate - 90) % 360
transform = QTransform().rotate(self.ref_image_rotate)
rotated_pixmap = self.original_ref_pixmap.transformed(transform)
self.widget_ref.set_image(rotated_pixmap)
def rotate_right(self):
if not self.original_ref_pixmap:
return
self.ref_image_rotate = (self.ref_image_rotate + 90) % 360
transform = QTransform().rotate(self.ref_image_rotate)
rotated_pixmap = self.original_ref_pixmap.transformed(transform)
self.widget_ref.set_image(rotated_pixmap)