From 6598e4073f714997731301019c0a99f385cb7721 Mon Sep 17 00:00:00 2001 From: Jules Date: Sat, 21 Mar 2026 18:47:56 +0100 Subject: [PATCH] Init projet Photostation - borne impression photo tactile PyQt6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structure : 4 écrans (accueil, import, éditeur, impression), navigation QStackedWidget, base éditeur QGraphicsScene. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 12 +++ memoire.md | 51 +++++++++++ requirements.txt | 4 + src/__init__.py | 0 src/main.py | 27 ++++++ src/ui/__init__.py | 0 src/ui/main_window.py | 47 +++++++++++ src/ui/screens/__init__.py | 0 src/ui/screens/editor_screen.py | 144 ++++++++++++++++++++++++++++++++ src/ui/screens/home_screen.py | 77 +++++++++++++++++ src/ui/screens/import_screen.py | 87 +++++++++++++++++++ src/ui/screens/print_screen.py | 90 ++++++++++++++++++++ 12 files changed, 539 insertions(+) create mode 100644 .gitignore create mode 100644 memoire.md create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/main.py create mode 100644 src/ui/__init__.py create mode 100644 src/ui/main_window.py create mode 100644 src/ui/screens/__init__.py create mode 100644 src/ui/screens/editor_screen.py create mode 100644 src/ui/screens/home_screen.py create mode 100644 src/ui/screens/import_screen.py create mode 100644 src/ui/screens/print_screen.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e32412 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +.env +*.swp +*.swo +*~ diff --git a/memoire.md b/memoire.md new file mode 100644 index 0000000..a083bdd --- /dev/null +++ b/memoire.md @@ -0,0 +1,51 @@ +# Photostation + +## Description +Borne d'impression photo tactile. Les utilisateurs importent leurs photos (USB, WiFi, QR code, email), les éditent (recadrage, rotation, effets, goodies, multi-pose, texte) et les impriment sur imprimante sublimation. + +## Stack technique +- **Interface** : PyQt6 / QGraphicsScene (natif, léger, tactile) +- **Traitement image** : Pillow (effets, redimensionnement, export) +- **Impression** : CUPS via pycups +- **Hotspot WiFi** : hostapd + dnsmasq + Flask (mini serveur upload) +- **QR** : qrcode (Python) + +## Matériel +- **Prototype** : Raspberry Pi 3B+ (1GB RAM) — portable vers RPi 4/5 +- **Écran tactile** : à déterminer (en stock chez Jules) +- **Imprimante sublimation** : à acheter (DNP DS620A ou Mitsubishi CP-D90DW) +- **Pas de capture photo** — les utilisateurs apportent leurs propres photos + +## Architecture +``` +src/ + main.py — Point d'entrée + ui/ + main_window.py — Navigation QStackedWidget + screens/ + home_screen.py — Accueil, choix du mode d'import + import_screen.py — Galerie de photos importées + editor_screen.py — Éditeur (crop, rotation, effets, goodies, multi-pose, texte) + print_screen.py — Prévisualisation + impression +assets/ + goodies/ — Stickers PNG + frames/ — Cadres photo + fonts/ — Polices custom +templates/ — Templates multi-pose +config/ — Configuration (imprimante, WiFi, etc.) +``` + +## Dev +- **Repo Gitea** : https://git.copydev.fr/jules/photostation +- **Dev sur** : LXC Proxmox (x86) ou PC local, déploiement sur RPi +- **Lancement** : `python3 src/main.py` (fenêtré) ou `python3 src/main.py --kiosk` (plein écran) + +## Statut +- Projet initialisé le 2026-03-21 +- Structure de base créée (4 écrans, navigation) +- TODO : import USB, éditeur complet, impression CUPS, modes WiFi/QR/email + +## Formats d'impression +- 10x15 cm +- 13x18 cm +- 15x20 cm diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5594bb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PyQt6>=6.5 +Pillow>=10.0 +qrcode>=7.4 +pycups>=2.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..e6d4491 --- /dev/null +++ b/src/main.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Photostation - Borne d'impression photo tactile.""" + +import sys +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt +from ui.main_window import MainWindow + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("Photostation") + + window = MainWindow() + + # Mode kiosk si argument --kiosk + if "--kiosk" in sys.argv: + window.showFullScreen() + else: + window.resize(1024, 600) + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..cf1dcc3 --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,47 @@ +"""Fenêtre principale — navigation entre les écrans.""" + +from PyQt6.QtWidgets import QMainWindow, QStackedWidget +from PyQt6.QtCore import Qt +from ui.screens.home_screen import HomeScreen +from ui.screens.import_screen import ImportScreen +from ui.screens.editor_screen import EditorScreen +from ui.screens.print_screen import PrintScreen + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Photostation") + self.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, True) + + self.stack = QStackedWidget() + self.setCentralWidget(self.stack) + + # Écrans + self.home_screen = HomeScreen(self) + self.import_screen = ImportScreen(self) + self.editor_screen = EditorScreen(self) + self.print_screen = PrintScreen(self) + + self.stack.addWidget(self.home_screen) # 0 + self.stack.addWidget(self.import_screen) # 1 + self.stack.addWidget(self.editor_screen) # 2 + self.stack.addWidget(self.print_screen) # 3 + + self.show_home() + + def show_home(self): + self.stack.setCurrentIndex(0) + + def show_import(self): + self.stack.setCurrentIndex(1) + + def show_editor(self, image_path=None): + if image_path: + self.editor_screen.load_image(image_path) + self.stack.setCurrentIndex(2) + + def show_print(self, image_path=None): + if image_path: + self.print_screen.set_image(image_path) + self.stack.setCurrentIndex(3) diff --git a/src/ui/screens/__init__.py b/src/ui/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/screens/editor_screen.py b/src/ui/screens/editor_screen.py new file mode 100644 index 0000000..450afcb --- /dev/null +++ b/src/ui/screens/editor_screen.py @@ -0,0 +1,144 @@ +"""Écran éditeur — recadrage, goodies, effets, multi-pose.""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, + QToolBar +) +from PyQt6.QtCore import Qt, QRectF +from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter + + +class EditorScreen(QWidget): + def __init__(self, main_window): + super().__init__() + self.main_window = main_window + self.current_image_path = None + self.pixmap_item = None + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(10) + + # Header + header = QHBoxLayout() + back_btn = QPushButton("Retour") + back_btn.setFont(QFont("", 14)) + back_btn.clicked.connect(self.main_window.show_import) + header.addWidget(back_btn) + + title = QLabel("Éditeur") + title.setFont(QFont("", 24, QFont.Weight.Bold)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + header.addWidget(title, 1) + + print_btn = QPushButton("Imprimer") + print_btn.setFont(QFont("", 14)) + print_btn.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + color: white; + border-radius: 10px; + padding: 10px 20px; + } + QPushButton:pressed { background-color: #388E3C; } + """) + print_btn.clicked.connect(self._go_print) + header.addWidget(print_btn) + layout.addLayout(header) + + # Zone d'édition (QGraphicsScene) + self.scene = QGraphicsScene() + self.view = QGraphicsView(self.scene) + self.view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) + self.view.setStyleSheet("background-color: #333;") + layout.addWidget(self.view, 1) + + # Barre d'outils + tools = QHBoxLayout() + tools.setSpacing(10) + + tool_buttons = [ + ("Recadrer", self._crop), + ("Rotation", self._rotate), + ("N&B", self._filter_grayscale), + ("Sépia", self._filter_sepia), + ("Goodies", self._add_goodies), + ("Multi-pose", self._multipose), + ("Texte", self._add_text), + ] + + for label, callback in tool_buttons: + btn = QPushButton(label) + btn.setFont(QFont("", 13)) + btn.setMinimumHeight(50) + btn.setStyleSheet(""" + QPushButton { + background-color: #555; + color: white; + border-radius: 8px; + padding: 8px 15px; + } + QPushButton:pressed { background-color: #777; } + """) + btn.clicked.connect(callback) + tools.addWidget(btn) + + layout.addLayout(tools) + + def load_image(self, path): + self.current_image_path = path + self.scene.clear() + pixmap = QPixmap(path) + self.pixmap_item = self.scene.addPixmap(pixmap) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + def _go_print(self): + # TODO: exporter le canvas édité en image + self.main_window.show_print(self.current_image_path) + + def _crop(self): + # TODO: outil de recadrage interactif + pass + + def _rotate(self): + if self.pixmap_item: + self.pixmap_item.setRotation(self.pixmap_item.rotation() + 90) + + def _filter_grayscale(self): + if not self.current_image_path: + return + pixmap = QPixmap(self.current_image_path) + image = pixmap.toImage().convertToFormat(QImage.Format.Format_Grayscale8) + self.scene.clear() + self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(image)) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + def _filter_sepia(self): + if not self.current_image_path: + return + from PIL import Image, ImageOps + img = Image.open(self.current_image_path) + sepia = ImageOps.grayscale(img) + sepia = ImageOps.colorize(sepia, "#704214", "#C0A080") + # Convertir PIL -> QPixmap + data = sepia.convert("RGBA").tobytes("raw", "RGBA") + qimg = QImage(data, sepia.width, sepia.height, QImage.Format.Format_RGBA8888) + self.scene.clear() + self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(qimg)) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + def _add_goodies(self): + # TODO: panneau de sélection de stickers + pass + + def _multipose(self): + # TODO: sélection de templates multi-photos + pass + + def _add_text(self): + # TODO: ajout de texte éditable sur le canvas + pass diff --git a/src/ui/screens/home_screen.py b/src/ui/screens/home_screen.py new file mode 100644 index 0000000..f9ffc14 --- /dev/null +++ b/src/ui/screens/home_screen.py @@ -0,0 +1,77 @@ +"""Écran d'accueil — choix du mode d'import.""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + + +class HomeScreen(QWidget): + def __init__(self, main_window): + super().__init__() + self.main_window = main_window + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setSpacing(40) + + # Titre + title = QLabel("Photostation") + title.setFont(QFont("", 48, QFont.Weight.Bold)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title) + + subtitle = QLabel("Sélectionnez un mode d'import") + subtitle.setFont(QFont("", 20)) + subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(subtitle) + + # Boutons d'import + buttons_layout = QHBoxLayout() + buttons_layout.setSpacing(20) + + modes = [ + ("USB", "Clé USB", self._import_usb), + ("WiFi", "Depuis téléphone", self._import_wifi), + ("QR Code", "Scanner pour envoyer", self._import_qr), + ("Email", "Recevoir par mail", self._import_email), + ] + + for label, desc, callback in modes: + btn = self._create_mode_button(label, desc) + btn.clicked.connect(callback) + buttons_layout.addWidget(btn) + + layout.addLayout(buttons_layout) + + def _create_mode_button(self, label, description): + btn = QPushButton(f"{label}\n{description}") + btn.setFont(QFont("", 16)) + btn.setMinimumSize(200, 150) + btn.setStyleSheet(""" + QPushButton { + background-color: #2196F3; + color: white; + border-radius: 15px; + padding: 20px; + } + QPushButton:pressed { + background-color: #1976D2; + } + """) + return btn + + def _import_usb(self): + self.main_window.show_import() + + def _import_wifi(self): + self.main_window.show_import() + + def _import_qr(self): + self.main_window.show_import() + + def _import_email(self): + self.main_window.show_import() diff --git a/src/ui/screens/import_screen.py b/src/ui/screens/import_screen.py new file mode 100644 index 0000000..f2ffaee --- /dev/null +++ b/src/ui/screens/import_screen.py @@ -0,0 +1,87 @@ +"""Écran d'import — galerie de photos importées.""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QScrollArea, QGridLayout +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QPixmap + + +class ImportScreen(QWidget): + def __init__(self, main_window): + super().__init__() + self.main_window = main_window + self.photos = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Header + header = QHBoxLayout() + back_btn = QPushButton("Retour") + back_btn.setFont(QFont("", 14)) + back_btn.clicked.connect(self.main_window.show_home) + header.addWidget(back_btn) + + title = QLabel("Sélectionnez une photo") + title.setFont(QFont("", 24, QFont.Weight.Bold)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + header.addWidget(title, 1) + + header.addSpacing(80) # Balance le bouton retour + layout.addLayout(header) + + # Grille de photos (scrollable) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self.grid_widget = QWidget() + self.grid_layout = QGridLayout(self.grid_widget) + self.grid_layout.setSpacing(10) + scroll.setWidget(self.grid_widget) + + layout.addWidget(scroll) + + def set_photos(self, photo_paths): + """Affiche les photos dans la grille.""" + # Clear existing + while self.grid_layout.count(): + item = self.grid_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + self.photos = photo_paths + cols = 4 + for i, path in enumerate(photo_paths): + row, col = divmod(i, cols) + thumb = self._create_thumbnail(path) + self.grid_layout.addWidget(thumb, row, col) + + def _create_thumbnail(self, path): + btn = QPushButton() + btn.setMinimumSize(180, 180) + btn.setStyleSheet(""" + QPushButton { + border: 3px solid transparent; + border-radius: 10px; + } + QPushButton:pressed { + border-color: #2196F3; + } + """) + pixmap = QPixmap(path) + if not pixmap.isNull(): + scaled = pixmap.scaled( + 170, 170, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + btn.setIcon(scaled) + from PyQt6.QtCore import QSize + btn.setIconSize(QSize(170, 170)) + + btn.clicked.connect(lambda checked, p=path: self.main_window.show_editor(p)) + return btn diff --git a/src/ui/screens/print_screen.py b/src/ui/screens/print_screen.py new file mode 100644 index 0000000..c4d8d97 --- /dev/null +++ b/src/ui/screens/print_screen.py @@ -0,0 +1,90 @@ +"""Écran d'impression — prévisualisation et lancement impression.""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QComboBox +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QPixmap + + +class PrintScreen(QWidget): + FORMATS = { + "10x15 cm": (10, 15), + "13x18 cm": (13, 18), + "15x20 cm": (15, 20), + } + + def __init__(self, main_window): + super().__init__() + self.main_window = main_window + self.image_path = None + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setSpacing(30) + + # Header + header = QHBoxLayout() + back_btn = QPushButton("Retour éditeur") + back_btn.setFont(QFont("", 14)) + back_btn.clicked.connect(lambda: self.main_window.show_editor()) + header.addWidget(back_btn) + header.addStretch() + layout.addLayout(header) + + # Prévisualisation + self.preview = QLabel() + self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview.setMinimumSize(400, 300) + self.preview.setStyleSheet("background-color: #333; border-radius: 10px;") + layout.addWidget(self.preview) + + # Sélection format + format_layout = QHBoxLayout() + format_label = QLabel("Format :") + format_label.setFont(QFont("", 16)) + format_layout.addWidget(format_label) + + self.format_combo = QComboBox() + self.format_combo.setFont(QFont("", 16)) + self.format_combo.addItems(self.FORMATS.keys()) + self.format_combo.setMinimumHeight(50) + format_layout.addWidget(self.format_combo) + layout.addLayout(format_layout) + + # Bouton imprimer + print_btn = QPushButton("IMPRIMER") + print_btn.setFont(QFont("", 24, QFont.Weight.Bold)) + print_btn.setMinimumSize(300, 80) + print_btn.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + color: white; + border-radius: 15px; + } + QPushButton:pressed { background-color: #388E3C; } + """) + print_btn.clicked.connect(self._print) + layout.addWidget(print_btn, alignment=Qt.AlignmentFlag.AlignCenter) + + def set_image(self, path): + self.image_path = path + pixmap = QPixmap(path) + if not pixmap.isNull(): + scaled = pixmap.scaled( + 400, 300, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + self.preview.setPixmap(scaled) + + def _print(self): + if not self.image_path: + return + fmt = self.format_combo.currentText() + size = self.FORMATS[fmt] + # TODO: redimensionner selon le format et envoyer à CUPS + print(f"Impression {self.image_path} en {fmt} ({size[0]}x{size[1]} cm)")