Init projet Photostation - borne impression photo tactile PyQt6
Structure : 4 écrans (accueil, import, éditeur, impression), navigation QStackedWidget, base éditeur QGraphicsScene. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
51
memoire.md
Normal file
51
memoire.md
Normal file
@@ -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
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
PyQt6>=6.5
|
||||
Pillow>=10.0
|
||||
qrcode>=7.4
|
||||
pycups>=2.0
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
27
src/main.py
Normal file
27
src/main.py
Normal file
@@ -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()
|
||||
0
src/ui/__init__.py
Normal file
0
src/ui/__init__.py
Normal file
47
src/ui/main_window.py
Normal file
47
src/ui/main_window.py
Normal file
@@ -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)
|
||||
0
src/ui/screens/__init__.py
Normal file
0
src/ui/screens/__init__.py
Normal file
144
src/ui/screens/editor_screen.py
Normal file
144
src/ui/screens/editor_screen.py
Normal file
@@ -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
|
||||
77
src/ui/screens/home_screen.py
Normal file
77
src/ui/screens/home_screen.py
Normal file
@@ -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()
|
||||
87
src/ui/screens/import_screen.py
Normal file
87
src/ui/screens/import_screen.py
Normal file
@@ -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
|
||||
90
src/ui/screens/print_screen.py
Normal file
90
src/ui/screens/print_screen.py
Normal file
@@ -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)")
|
||||
Reference in New Issue
Block a user