Photo strips, photo d'identité, NFC tap-to-upload
- Photo strips : 2 templates (3 ou 4 photos), toujours 2 strips côte à côte sur un 10x15 - Photo d'identité : écran dédié, cadrage 35x45mm guidé (ovale + ligne yeux), planche 8 photos sur 10x15 - NFC : service nfcpy optionnel, écrit URL upload sur tag NDEF, configurable dans admin - Config : option NFC on/off ajoutée - Matériel cible : mini-PC (plus RPi), imprimante DNP DS-RX1HS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
memoire.md
51
memoire.md
@@ -1,7 +1,7 @@
|
|||||||
# Photostation
|
# Photostation
|
||||||
|
|
||||||
## Description
|
## 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.
|
Borne d'impression photo tactile. Les utilisateurs importent leurs photos (USB, WiFi, QR code, email, NFC), les éditent (recadrage, rotation, effets, goodies, multi-pose, photo strips, photo ID, texte) et les impriment sur imprimante sublimation.
|
||||||
|
|
||||||
## Concept
|
## Concept
|
||||||
Ce n'est PAS un photobooth (pas de capture photo). C'est une station de transfert/impression : les utilisateurs arrivent avec leurs propres photos et la borne permet de les éditer et imprimer.
|
Ce n'est PAS un photobooth (pas de capture photo). C'est une station de transfert/impression : les utilisateurs arrivent avec leurs propres photos et la borne permet de les éditer et imprimer.
|
||||||
@@ -13,12 +13,14 @@ Ce n'est PAS un photobooth (pas de capture photo). C'est une station de transfer
|
|||||||
- **Serveur upload** : Flask (thread daemon, page upload mobile-friendly)
|
- **Serveur upload** : Flask (thread daemon, page upload mobile-friendly)
|
||||||
- **Email** : IMAP polling (thread daemon)
|
- **Email** : IMAP polling (thread daemon)
|
||||||
- **QR** : qrcode (Python)
|
- **QR** : qrcode (Python)
|
||||||
|
- **NFC** : nfcpy (optionnel, nécessite lecteur USB type ACR122U)
|
||||||
|
|
||||||
## Matériel
|
## Matériel
|
||||||
- **Prototype** : Raspberry Pi 3B+ (1GB RAM) — en stock chez Jules
|
- **Prototype** : Raspberry Pi 3B+ (1GB RAM) — en stock chez Jules, pour tests initiaux
|
||||||
- **Cible** : Portable vers RPi 4 ou 5 sans aucun changement de code
|
- **Cible production** : Mini-PC (Intel N95/N100, 8GB RAM, 128GB SSD) — ~100-150€
|
||||||
- **Écran tactile** : en stock, 10" 4/3 visé (interface responsive)
|
- **Écran tactile** : en stock, 10-15" visé (interface responsive)
|
||||||
- **Imprimante sublimation** : à acheter (pistes : DNP DS620A, Mitsubishi CP-D90DW)
|
- **Imprimante sublimation** : DNP DS-RX1HS (~500-600€, consommable ~0.13€/tirage 10x15, 700 tirages/rouleau, driver CUPS Linux)
|
||||||
|
- **NFC** (optionnel) : lecteur USB ACR122U (~25-30€) pour tap-to-upload
|
||||||
- **Pas de capture photo** — les utilisateurs apportent leurs propres photos
|
- **Pas de capture photo** — les utilisateurs apportent leurs propres photos
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -29,28 +31,31 @@ src/
|
|||||||
usb_watcher.py — Détection USB par polling
|
usb_watcher.py — Détection USB par polling
|
||||||
upload_server.py — Serveur Flask d'upload (WiFi + QR)
|
upload_server.py — Serveur Flask d'upload (WiFi + QR)
|
||||||
imap_watcher.py — Polling IMAP pour réception email
|
imap_watcher.py — Polling IMAP pour réception email
|
||||||
|
nfc_service.py — NFC tap-to-upload (nfcpy, optionnel)
|
||||||
ui/
|
ui/
|
||||||
style.py — Styles responsive (tailles en % de la fenêtre)
|
style.py — Styles responsive (tailles en % de la fenêtre)
|
||||||
main_window.py — Navigation QStackedWidget (4 écrans)
|
main_window.py — Navigation QStackedWidget (5 écrans)
|
||||||
screens/
|
screens/
|
||||||
home_screen.py — Accueil, 4 modes, config admin (appui long 5s)
|
home_screen.py — Accueil, 4 modes, config admin (appui long 5s)
|
||||||
import_screen.py — Galerie photos (cartes, quantités, pastille ratio)
|
import_screen.py — Galerie photos (cartes, quantités, pastille ratio)
|
||||||
editor_screen.py — Éditeur (crop ratio, rotation, filtres, goodies, multi-pose, texte)
|
editor_screen.py — Éditeur (crop, rotation, filtres, goodies, multi-pose, strips, photo ID, texte)
|
||||||
print_screen.py — Choix format, recadrage ratio, impression
|
print_screen.py — Choix format, recadrage ratio, impression
|
||||||
|
id_photo_screen.py — Photo d'identité (cadrage 35x45mm, planche 8 photos)
|
||||||
widgets/
|
widgets/
|
||||||
crop_overlay.py — Rectangle de crop ratio verrouillé, bord à bord
|
crop_overlay.py — Rectangle de crop ratio verrouillé, bord à bord
|
||||||
goodies_panel.py — Panneau stickers/cadres
|
goodies_panel.py — Panneau stickers/cadres
|
||||||
multipose_dialog.py — Templates multi-pose
|
multipose_dialog.py — Templates multi-pose + photo strips
|
||||||
config_dialog.py — Config admin complète
|
config_dialog.py — Config admin complète
|
||||||
```
|
```
|
||||||
|
|
||||||
## Fonctionnalités implémentées (2026-03-21)
|
## Fonctionnalités implémentées (2026-03-22)
|
||||||
|
|
||||||
### Modes d'import
|
### Modes d'import
|
||||||
- **USB** : polling montages, scan récursif, bouton vert + compteur
|
- **USB** : polling montages, scan récursif, bouton vert + compteur
|
||||||
- **QR Code** : mini QR sur le bouton, serveur Flask upload, bouton vert quand photos reçues → galerie directe
|
- **QR Code** : mini QR sur le bouton, serveur Flask upload, bouton vert quand photos reçues → galerie directe
|
||||||
- **WiFi** : même serveur Flask, affiche nom AP + mot de passe
|
- **WiFi** : même serveur Flask, affiche nom AP + mot de passe
|
||||||
- **Email** : IMAP polling configurable, affiche adresse mail
|
- **Email** : IMAP polling configurable, affiche adresse mail
|
||||||
|
- **NFC** : tap-to-upload via lecteur USB (optionnel, configurable)
|
||||||
|
|
||||||
### Galerie
|
### Galerie
|
||||||
- Cartes à taille fixe, miniatures centrées (QPushButton + icon)
|
- Cartes à taille fixe, miniatures centrées (QPushButton + icon)
|
||||||
@@ -64,10 +69,20 @@ src/
|
|||||||
- Recadrage ratio verrouillé 3:2 (auto si ratio ≠ 3:2), bord à bord
|
- Recadrage ratio verrouillé 3:2 (auto si ratio ≠ 3:2), bord à bord
|
||||||
- Rotation 90°, filtres (N&B, sépia, vintage, contraste)
|
- Rotation 90°, filtres (N&B, sépia, vintage, contraste)
|
||||||
- Multi-pose (5 templates, même photo dupliquée, crop to fill)
|
- Multi-pose (5 templates, même photo dupliquée, crop to fill)
|
||||||
|
- **Photo Strips** (4 templates : strip 3/4 photos, 2 strips 3/4 photos sur 10x15)
|
||||||
|
- **Photo d'identité** (bouton Photo ID → écran dédié)
|
||||||
- Goodies (stickers draggable, cadres overlay)
|
- Goodies (stickers draggable, cadres overlay)
|
||||||
- Texte éditable draggable
|
- Texte éditable draggable
|
||||||
- Export scène → PNG haute résolution
|
- Export scène → PNG haute résolution
|
||||||
|
|
||||||
|
### Photo d'identité
|
||||||
|
- Écran dédié avec cadrage guidé au ratio 35x45mm
|
||||||
|
- Repères visuels : ovale tête, ligne des yeux
|
||||||
|
- Génération planche 8 photos (4x2) sur format 10x15
|
||||||
|
- Traits de coupe entre les photos
|
||||||
|
- Prévisualisation avant impression
|
||||||
|
- Option future : e-photo ANTS via API ephoto.io
|
||||||
|
|
||||||
### Impression
|
### Impression
|
||||||
- Formats activables par format dans la config (10x15, 13x18, 15x20)
|
- Formats activables par format dans la config (10x15, 13x18, 15x20)
|
||||||
- Imprimante CUPS assignable par format
|
- Imprimante CUPS assignable par format
|
||||||
@@ -77,6 +92,7 @@ src/
|
|||||||
- WiFi : nom AP, mot de passe
|
- WiFi : nom AP, mot de passe
|
||||||
- Serveur upload : IP d'écoute, port, URL publique (QR code)
|
- Serveur upload : IP d'écoute, port, URL publique (QR code)
|
||||||
- Email IMAP : activer/désactiver, serveur, port, user, password, dossier, intervalle
|
- Email IMAP : activer/désactiver, serveur, port, user, password, dossier, intervalle
|
||||||
|
- NFC : activer/désactiver
|
||||||
- Formats d'impression : checkbox par format + imprimante CUPS
|
- Formats d'impression : checkbox par format + imprimante CUPS
|
||||||
- Sauvegardé dans config/settings.json
|
- Sauvegardé dans config/settings.json
|
||||||
|
|
||||||
@@ -100,12 +116,19 @@ src/
|
|||||||
- **Git config** : user `Jules`, email `contact@copydev.fr`
|
- **Git config** : user `Jules`, email `contact@copydev.fr`
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
- [ ] Tarification : prix en € dans la galerie, tarif unitaire + paliers configurables
|
- [ ] e-photo ANTS : intégrer API ephoto.io (crédits à acheter, API REST, génère code 22 caractères)
|
||||||
- [ ] Écran récap après Valider : photos sélectionnées + quantités + prix total
|
- [ ] Hotspot WiFi captif (hostapd + dnsmasq)
|
||||||
- [ ] Impression CUPS réelle
|
|
||||||
- [ ] Hotspot WiFi captif (hostapd + dnsmasq — RPi spécifique)
|
|
||||||
- [ ] Peupler assets/goodies/ et assets/frames/
|
- [ ] Peupler assets/goodies/ et assets/frames/
|
||||||
- [ ] Tests sur RPi 3 réel
|
- [ ] Tests sur RPi 3B+ (prototype)
|
||||||
|
- [ ] Tests sur mini-PC cible
|
||||||
|
- [ ] Acheter DNP DS-RX1HS + premier kit consommable
|
||||||
|
|
||||||
|
## e-photo ANTS (future)
|
||||||
|
- **API** : ephoto.io — API REST agréée ANTS depuis 2020
|
||||||
|
- **Principe** : crédits prépayés (1 crédit = 1 code e-photo), pas d'abonnement
|
||||||
|
- **Intégration** : envoyer la photo ID via API, récupérer le code 22 caractères, imprimer sur la planche
|
||||||
|
- **Alternative** : EasyEPhoto (MB Tech) — accès navigateur, ~0.36€/photo (pack 1000)
|
||||||
|
- **Pas d'API ANTS publique** — il faut passer par un prestataire agréé
|
||||||
|
|
||||||
## Formats d'impression
|
## Formats d'impression
|
||||||
| Format | Ratio | Dimensions cm |
|
| Format | Ratio | Dimensions cm |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ui.main_window import MainWindow
|
|||||||
from ui.widgets.config_dialog import load_config
|
from ui.widgets.config_dialog import load_config
|
||||||
from services.upload_server import UploadServer
|
from services.upload_server import UploadServer
|
||||||
from services.imap_watcher import ImapWatcher
|
from services.imap_watcher import ImapWatcher
|
||||||
|
from services.nfc_service import NfcService
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -42,6 +43,11 @@ def main():
|
|||||||
interval=config.get("imap_interval", 30),
|
interval=config.get("imap_interval", 30),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# NFC tap-to-upload
|
||||||
|
if config.get("nfc_enabled"):
|
||||||
|
nfc_service = NfcService(upload_url=config.get("upload_url", ""))
|
||||||
|
nfc_service.start()
|
||||||
|
|
||||||
if "--kiosk" in sys.argv:
|
if "--kiosk" in sys.argv:
|
||||||
screen = app.primaryScreen().geometry()
|
screen = app.primaryScreen().geometry()
|
||||||
window.setGeometry(screen)
|
window.setGeometry(screen)
|
||||||
|
|||||||
116
src/services/nfc_service.py
Normal file
116
src/services/nfc_service.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Service NFC — écrit l'URL d'upload sur les tags NFC pour tap-to-upload.
|
||||||
|
|
||||||
|
Quand un téléphone avec NFC tape la borne, il ouvre automatiquement
|
||||||
|
la page d'upload Flask dans le navigateur.
|
||||||
|
|
||||||
|
Nécessite : module USB NFC (ex: ACR122U) + lib nfcpy (pip install nfcpy)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
|
class NfcSignals(QObject):
|
||||||
|
"""Signaux Qt pour le NFC."""
|
||||||
|
tag_detected = pyqtSignal() # Un téléphone a tapé
|
||||||
|
error = pyqtSignal(str)
|
||||||
|
status = pyqtSignal(str)
|
||||||
|
|
||||||
|
|
||||||
|
class NfcService:
|
||||||
|
"""Service NFC qui programme les tags NDEF avec l'URL d'upload."""
|
||||||
|
|
||||||
|
def __init__(self, upload_url=""):
|
||||||
|
self.upload_url = upload_url
|
||||||
|
self.signals = NfcSignals()
|
||||||
|
self._running = False
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def set_url(self, url):
|
||||||
|
"""Met à jour l'URL d'upload."""
|
||||||
|
self.upload_url = url
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Démarre le service NFC en background."""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import nfc
|
||||||
|
except ImportError:
|
||||||
|
self.signals.status.emit("NFC: module nfcpy non installé")
|
||||||
|
print("[NFC] nfcpy not installed — NFC disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
"""Boucle principale NFC."""
|
||||||
|
import nfc
|
||||||
|
import nfc.ndef
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
# Tenter de se connecter au lecteur NFC USB
|
||||||
|
clf = nfc.ContactlessFrontend()
|
||||||
|
if not clf.open('usb'):
|
||||||
|
self.signals.status.emit("NFC: aucun lecteur détecté")
|
||||||
|
print("[NFC] No reader found, retrying in 10s...")
|
||||||
|
time.sleep(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.signals.status.emit("NFC: lecteur prêt")
|
||||||
|
print("[NFC] Reader ready, waiting for tags...")
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
# Attendre un tag/téléphone
|
||||||
|
tag = clf.connect(
|
||||||
|
rdwr={
|
||||||
|
'on-connect': self._on_tag_connect,
|
||||||
|
'on-release': lambda tag: False,
|
||||||
|
},
|
||||||
|
terminate=lambda: not self._running,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tag and self._running:
|
||||||
|
time.sleep(1) # Éviter le spam
|
||||||
|
|
||||||
|
clf.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[NFC] Error: {e}")
|
||||||
|
self.signals.error.emit(str(e))
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
def _on_tag_connect(self, tag):
|
||||||
|
"""Appelé quand un tag NFC est détecté."""
|
||||||
|
import nfc.ndef
|
||||||
|
|
||||||
|
print(f"[NFC] Tag detected: {tag}")
|
||||||
|
|
||||||
|
if not self.upload_url:
|
||||||
|
print("[NFC] No upload URL configured")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Écrire un record NDEF URI sur le tag
|
||||||
|
# Quand un téléphone lit ce tag, il ouvre l'URL automatiquement
|
||||||
|
record = nfc.ndef.UriRecord(self.upload_url)
|
||||||
|
tag.ndef.records = [record]
|
||||||
|
print(f"[NFC] Wrote URL: {self.upload_url}")
|
||||||
|
self.signals.tag_detected.emit()
|
||||||
|
except AttributeError:
|
||||||
|
# Le tag n'est pas inscriptible (ex: téléphone en mode lecteur)
|
||||||
|
# On utilise le SNEP (Simple NDEF Exchange Protocol) pour push
|
||||||
|
print("[NFC] Tag not writable, attempting NDEF push...")
|
||||||
|
self.signals.tag_detected.emit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[NFC] Write error: {e}")
|
||||||
|
|
||||||
|
return True # Continue à écouter
|
||||||
@@ -6,6 +6,7 @@ from ui.screens.home_screen import HomeScreen
|
|||||||
from ui.screens.import_screen import ImportScreen
|
from ui.screens.import_screen import ImportScreen
|
||||||
from ui.screens.editor_screen import EditorScreen
|
from ui.screens.editor_screen import EditorScreen
|
||||||
from ui.screens.print_screen import PrintScreen
|
from ui.screens.print_screen import PrintScreen
|
||||||
|
from ui.screens.id_photo_screen import IdPhotoScreen
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -22,11 +23,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.import_screen = ImportScreen(self)
|
self.import_screen = ImportScreen(self)
|
||||||
self.editor_screen = EditorScreen(self)
|
self.editor_screen = EditorScreen(self)
|
||||||
self.print_screen = PrintScreen(self)
|
self.print_screen = PrintScreen(self)
|
||||||
|
self.id_photo_screen = IdPhotoScreen(self)
|
||||||
|
|
||||||
self.stack.addWidget(self.home_screen) # 0
|
self.stack.addWidget(self.home_screen) # 0
|
||||||
self.stack.addWidget(self.import_screen) # 1
|
self.stack.addWidget(self.import_screen) # 1
|
||||||
self.stack.addWidget(self.editor_screen) # 2
|
self.stack.addWidget(self.editor_screen) # 2
|
||||||
self.stack.addWidget(self.print_screen) # 3
|
self.stack.addWidget(self.print_screen) # 3
|
||||||
|
self.stack.addWidget(self.id_photo_screen) # 4
|
||||||
|
|
||||||
self.show_home()
|
self.show_home()
|
||||||
|
|
||||||
@@ -45,3 +48,8 @@ class MainWindow(QMainWindow):
|
|||||||
if image_path:
|
if image_path:
|
||||||
self.print_screen.set_image(image_path)
|
self.print_screen.set_image(image_path)
|
||||||
self.stack.setCurrentIndex(3)
|
self.stack.setCurrentIndex(3)
|
||||||
|
|
||||||
|
def show_id_photo(self, image_path=None):
|
||||||
|
if image_path:
|
||||||
|
self.id_photo_screen.load_image(image_path)
|
||||||
|
self.stack.setCurrentIndex(4)
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class EditorScreen(QWidget):
|
|||||||
("Contraste", self._filter_contrast),
|
("Contraste", self._filter_contrast),
|
||||||
("Goodies", self._toggle_goodies),
|
("Goodies", self._toggle_goodies),
|
||||||
("Multi-pose", self._multipose),
|
("Multi-pose", self._multipose),
|
||||||
|
("Photo ID", self._id_photo),
|
||||||
("Texte", self._add_text),
|
("Texte", self._add_text),
|
||||||
("Annuler", self._reset_image),
|
("Annuler", self._reset_image),
|
||||||
]
|
]
|
||||||
@@ -350,7 +351,7 @@ class EditorScreen(QWidget):
|
|||||||
|
|
||||||
# --- Multi-pose ---
|
# --- Multi-pose ---
|
||||||
def _multipose(self):
|
def _multipose(self):
|
||||||
name, zones = MultiposeDialog.get_template(self)
|
name, zones, canvas_size = MultiposeDialog.get_template(self)
|
||||||
if not zones:
|
if not zones:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -367,7 +368,10 @@ class EditorScreen(QWidget):
|
|||||||
self.multipose_zones = zones
|
self.multipose_zones = zones
|
||||||
self.multipose_source = source_pixmap
|
self.multipose_source = source_pixmap
|
||||||
|
|
||||||
# Taille canvas = format d'impression (ex: 1800x1200 pour 15x10)
|
# Taille canvas : strip custom ou format standard
|
||||||
|
if canvas_size:
|
||||||
|
canvas_w, canvas_h = canvas_size
|
||||||
|
else:
|
||||||
canvas_w = 1800
|
canvas_w = 1800
|
||||||
canvas_h = 1200
|
canvas_h = 1200
|
||||||
|
|
||||||
@@ -424,6 +428,13 @@ class EditorScreen(QWidget):
|
|||||||
|
|
||||||
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
||||||
|
|
||||||
|
# --- Photo ID ---
|
||||||
|
def _id_photo(self):
|
||||||
|
"""Ouvre l'écran photo d'identité avec l'image en cours."""
|
||||||
|
path = self.current_image_path
|
||||||
|
if path:
|
||||||
|
self.main_window.show_id_photo(path)
|
||||||
|
|
||||||
# --- Texte ---
|
# --- Texte ---
|
||||||
def _add_text(self):
|
def _add_text(self):
|
||||||
text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :")
|
text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :")
|
||||||
|
|||||||
317
src/ui/screens/id_photo_screen.py
Normal file
317
src/ui/screens/id_photo_screen.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Écran Photo d'identité — cadrage 35x45mm, planche, option e-photo ANTS."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||||
|
QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem,
|
||||||
|
QGraphicsEllipseItem, QGraphicsLineItem
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, QRectF
|
||||||
|
from PyQt6.QtGui import (
|
||||||
|
QFont, QPixmap, QImage, QPainter, QColor, QPen, QBrush
|
||||||
|
)
|
||||||
|
|
||||||
|
from ui.widgets.crop_overlay import CropOverlay
|
||||||
|
from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn, blue_btn
|
||||||
|
|
||||||
|
# Dimensions photo d'identité française (mm)
|
||||||
|
ID_WIDTH_MM = 35
|
||||||
|
ID_HEIGHT_MM = 45
|
||||||
|
ID_RATIO = ID_WIDTH_MM / ID_HEIGHT_MM # ~0.778
|
||||||
|
|
||||||
|
# Planche sur 10x15 : 4 colonnes x 2 lignes = 8 photos
|
||||||
|
SHEET_COLS = 4
|
||||||
|
SHEET_ROWS = 2
|
||||||
|
SHEET_COUNT = SHEET_COLS * SHEET_ROWS
|
||||||
|
|
||||||
|
# Dimensions planche en pixels à 300 DPI (10x15 cm)
|
||||||
|
SHEET_W_PX = 1772 # 15 cm
|
||||||
|
SHEET_H_PX = 1181 # 10 cm
|
||||||
|
|
||||||
|
# Dimension unitaire d'une photo ID en pixels à 300 DPI
|
||||||
|
ID_W_PX = int(ID_WIDTH_MM / 25.4 * 300) # ~413 px
|
||||||
|
ID_H_PX = int(ID_HEIGHT_MM / 25.4 * 300) # ~531 px
|
||||||
|
|
||||||
|
|
||||||
|
class IdPhotoScreen(QWidget):
|
||||||
|
"""Écran de création de photo d'identité avec cadrage guidé."""
|
||||||
|
|
||||||
|
def __init__(self, main_window):
|
||||||
|
super().__init__()
|
||||||
|
self.main_window = main_window
|
||||||
|
self.current_image_path = None
|
||||||
|
self.pixmap_item = None
|
||||||
|
self.crop_overlay = None
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(5)
|
||||||
|
layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QHBoxLayout()
|
||||||
|
|
||||||
|
self.back_btn = QPushButton("< Retour")
|
||||||
|
self.back_btn.clicked.connect(self._go_back)
|
||||||
|
header.addWidget(self.back_btn)
|
||||||
|
|
||||||
|
self.title = QLabel("Photo d'identité")
|
||||||
|
self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
header.addWidget(self.title, 1)
|
||||||
|
|
||||||
|
self.print_btn = QPushButton("Générer la planche")
|
||||||
|
self.print_btn.clicked.connect(self._generate_sheet)
|
||||||
|
header.addWidget(self.print_btn)
|
||||||
|
|
||||||
|
layout.addLayout(header)
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
self.instruction = QLabel(
|
||||||
|
"Positionnez le cadre sur le visage\n"
|
||||||
|
"Le haut du crâne doit toucher le haut du cadre"
|
||||||
|
)
|
||||||
|
self.instruction.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.instruction.setStyleSheet("color: #666;")
|
||||||
|
layout.addWidget(self.instruction)
|
||||||
|
|
||||||
|
# Canvas
|
||||||
|
self.scene = QGraphicsScene()
|
||||||
|
self.view = QGraphicsView(self.scene)
|
||||||
|
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||||
|
self.view.setStyleSheet("background-color: #333; border: none;")
|
||||||
|
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
layout.addWidget(self.view, 1)
|
||||||
|
|
||||||
|
# Barre d'outils
|
||||||
|
tools = QHBoxLayout()
|
||||||
|
tools.setSpacing(8)
|
||||||
|
|
||||||
|
self.reframe_btn = QPushButton("Recadrer")
|
||||||
|
self.reframe_btn.clicked.connect(self._apply_crop_and_reguide)
|
||||||
|
tools.addWidget(self.reframe_btn)
|
||||||
|
|
||||||
|
self.reset_btn = QPushButton("Annuler")
|
||||||
|
self.reset_btn.clicked.connect(self._reset)
|
||||||
|
tools.addWidget(self.reset_btn)
|
||||||
|
|
||||||
|
# Choix nombre de photos par planche
|
||||||
|
self.count_label = QLabel(f"Planche : {SHEET_COUNT} photos")
|
||||||
|
self.count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
tools.addWidget(self.count_label)
|
||||||
|
|
||||||
|
layout.addLayout(tools)
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
super().showEvent(event)
|
||||||
|
from PyQt6.QtCore import QTimer
|
||||||
|
QTimer.singleShot(0, self._apply_responsive)
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if self.pixmap_item:
|
||||||
|
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||||
|
|
||||||
|
def _apply_responsive(self):
|
||||||
|
self.back_btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT, bold=True))
|
||||||
|
self.back_btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT))
|
||||||
|
self.back_btn.setStyleSheet(back_btn_style(self))
|
||||||
|
|
||||||
|
self.title.setFont(scaled_font(self, Sizes.HEADING, bold=True))
|
||||||
|
|
||||||
|
self.print_btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT, bold=True))
|
||||||
|
self.print_btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT))
|
||||||
|
self.print_btn.setStyleSheet(green_btn(self))
|
||||||
|
|
||||||
|
self.instruction.setFont(scaled_font(self, Sizes.BODY))
|
||||||
|
|
||||||
|
for btn in (self.reframe_btn, self.reset_btn):
|
||||||
|
btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT))
|
||||||
|
btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT))
|
||||||
|
btn.setStyleSheet(blue_btn(self))
|
||||||
|
|
||||||
|
self.count_label.setFont(scaled_font(self, Sizes.BODY))
|
||||||
|
|
||||||
|
def load_image(self, path):
|
||||||
|
"""Charge une photo et affiche le cadre de recadrage ID."""
|
||||||
|
self.current_image_path = path
|
||||||
|
self.scene.clear()
|
||||||
|
self.crop_overlay = None
|
||||||
|
|
||||||
|
pixmap = QPixmap(path)
|
||||||
|
self.pixmap_item = self.scene.addPixmap(pixmap)
|
||||||
|
self.scene.setSceneRect(QRectF(pixmap.rect()))
|
||||||
|
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||||
|
|
||||||
|
self._show_id_crop()
|
||||||
|
|
||||||
|
def _show_id_crop(self):
|
||||||
|
"""Affiche le cadre de recadrage au ratio 35x45mm."""
|
||||||
|
if not self.pixmap_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
pr = self.pixmap_item.boundingRect()
|
||||||
|
img_w, img_h = pr.width(), pr.height()
|
||||||
|
|
||||||
|
ratio = ID_RATIO # 0.778
|
||||||
|
# Adapter au sens de l'image
|
||||||
|
if img_w < img_h:
|
||||||
|
# Portrait — le cadre est en portrait aussi
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Paysage — cadre portrait centré
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cadre le plus grand possible au ratio 35:45
|
||||||
|
if img_w / img_h > ratio:
|
||||||
|
crop_h = img_h * 0.85 # Laisser une petite marge
|
||||||
|
crop_w = crop_h * ratio
|
||||||
|
else:
|
||||||
|
crop_w = img_w * 0.85
|
||||||
|
crop_h = crop_w / ratio
|
||||||
|
|
||||||
|
# Centrer
|
||||||
|
x = (img_w - crop_w) / 2
|
||||||
|
y = (img_h - crop_h) / 2
|
||||||
|
|
||||||
|
crop_rect = QRectF(x, y, crop_w, crop_h)
|
||||||
|
self.crop_overlay = CropOverlay(crop_rect)
|
||||||
|
self.crop_overlay.set_bounds(pr)
|
||||||
|
self.crop_overlay.set_ratio(ratio)
|
||||||
|
self.scene.addItem(self.crop_overlay)
|
||||||
|
|
||||||
|
# Repères visuels sur le crop : ovale visage + ligne des yeux
|
||||||
|
self._add_face_guides(crop_rect)
|
||||||
|
|
||||||
|
def _add_face_guides(self, crop_rect):
|
||||||
|
"""Ajoute des repères visuels : ovale tête, ligne des yeux."""
|
||||||
|
x, y, w, h = crop_rect.x(), crop_rect.y(), crop_rect.width(), crop_rect.height()
|
||||||
|
|
||||||
|
# Ovale tête (norme : visage = 70-80% de la hauteur)
|
||||||
|
face_h = h * 0.75
|
||||||
|
face_w = w * 0.65
|
||||||
|
face_x = x + (w - face_w) / 2
|
||||||
|
face_y = y + h * 0.05 # Haut du crâne proche du haut
|
||||||
|
|
||||||
|
pen = QPen(QColor(100, 200, 255, 120), 2, Qt.PenStyle.DashLine)
|
||||||
|
ellipse = self.scene.addEllipse(
|
||||||
|
QRectF(face_x, face_y, face_w, face_h),
|
||||||
|
pen, QBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
)
|
||||||
|
ellipse.setZValue(50)
|
||||||
|
|
||||||
|
# Ligne des yeux (~35% depuis le haut de la photo ID)
|
||||||
|
eye_y = y + h * 0.35
|
||||||
|
eye_line = self.scene.addLine(
|
||||||
|
x + w * 0.15, eye_y, x + w * 0.85, eye_y,
|
||||||
|
QPen(QColor(100, 200, 255, 80), 1, Qt.PenStyle.DotLine)
|
||||||
|
)
|
||||||
|
eye_line.setZValue(50)
|
||||||
|
|
||||||
|
def _apply_crop_and_reguide(self):
|
||||||
|
"""Applique le crop actuel."""
|
||||||
|
if not self.crop_overlay or not self.pixmap_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
crop_rect = self.crop_overlay.get_crop_rect()
|
||||||
|
pixmap = self.pixmap_item.pixmap()
|
||||||
|
cropped = pixmap.copy(crop_rect.toRect())
|
||||||
|
|
||||||
|
self.scene.clear()
|
||||||
|
self.crop_overlay = None
|
||||||
|
self.pixmap_item = self.scene.addPixmap(cropped)
|
||||||
|
self.scene.setSceneRect(QRectF(cropped.rect()))
|
||||||
|
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||||
|
|
||||||
|
def _reset(self):
|
||||||
|
if self.current_image_path:
|
||||||
|
self.load_image(self.current_image_path)
|
||||||
|
|
||||||
|
def _generate_sheet(self):
|
||||||
|
"""Génère une planche de 8 photos d'identité sur format 10x15."""
|
||||||
|
if not self.pixmap_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Récupérer l'image actuelle (croppée ou non)
|
||||||
|
if self.crop_overlay:
|
||||||
|
crop_rect = self.crop_overlay.get_crop_rect().toRect()
|
||||||
|
source = self.pixmap_item.pixmap().copy(crop_rect)
|
||||||
|
else:
|
||||||
|
source = self.pixmap_item.pixmap()
|
||||||
|
|
||||||
|
# Redimensionner la photo ID aux dimensions exactes
|
||||||
|
id_photo = source.scaled(
|
||||||
|
ID_W_PX, ID_H_PX,
|
||||||
|
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation
|
||||||
|
)
|
||||||
|
|
||||||
|
# Créer la planche
|
||||||
|
sheet = QImage(SHEET_W_PX, SHEET_H_PX, QImage.Format.Format_ARGB32)
|
||||||
|
sheet.fill(QColor(255, 255, 255))
|
||||||
|
|
||||||
|
painter = QPainter(sheet)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||||
|
|
||||||
|
# Calculer l'espacement pour centrer les photos
|
||||||
|
total_w = SHEET_COLS * ID_W_PX
|
||||||
|
total_h = SHEET_ROWS * ID_H_PX
|
||||||
|
gap_x = (SHEET_W_PX - total_w) // (SHEET_COLS + 1)
|
||||||
|
gap_y = (SHEET_H_PX - total_h) // (SHEET_ROWS + 1)
|
||||||
|
|
||||||
|
for row in range(SHEET_ROWS):
|
||||||
|
for col in range(SHEET_COLS):
|
||||||
|
x = gap_x + col * (ID_W_PX + gap_x)
|
||||||
|
y = gap_y + row * (ID_H_PX + gap_y)
|
||||||
|
painter.drawPixmap(x, y, id_photo)
|
||||||
|
|
||||||
|
# Trait de coupe léger entre les photos
|
||||||
|
cut_pen = QPen(QColor(200, 200, 200), 1, Qt.PenStyle.DotLine)
|
||||||
|
painter.setPen(cut_pen)
|
||||||
|
for col in range(1, SHEET_COLS):
|
||||||
|
x = gap_x + col * (ID_W_PX + gap_x) - gap_x // 2
|
||||||
|
painter.drawLine(x, 0, x, SHEET_H_PX)
|
||||||
|
for row in range(1, SHEET_ROWS):
|
||||||
|
y = gap_y + row * (ID_H_PX + gap_y) - gap_y // 2
|
||||||
|
painter.drawLine(0, y, SHEET_W_PX, y)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
# Sauvegarder et envoyer à l'impression
|
||||||
|
path = os.path.join(tempfile.gettempdir(), "photostation_id_sheet.png")
|
||||||
|
sheet.save(path, "PNG")
|
||||||
|
|
||||||
|
# Afficher la planche dans le canvas pour validation
|
||||||
|
self.scene.clear()
|
||||||
|
self.crop_overlay = None
|
||||||
|
sheet_pixmap = QPixmap.fromImage(sheet)
|
||||||
|
self.pixmap_item = self.scene.addPixmap(sheet_pixmap)
|
||||||
|
self.scene.setSceneRect(QRectF(sheet_pixmap.rect()))
|
||||||
|
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||||
|
|
||||||
|
# Changer le bouton pour imprimer
|
||||||
|
self.print_btn.setText("Imprimer la planche")
|
||||||
|
self.print_btn.clicked.disconnect()
|
||||||
|
self.print_btn.clicked.connect(lambda: self._print_sheet(path))
|
||||||
|
|
||||||
|
self.instruction.setText(
|
||||||
|
f"Planche de {SHEET_COUNT} photos d'identité (35x45mm)\n"
|
||||||
|
"Vérifiez le résultat puis appuyez sur Imprimer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _print_sheet(self, path):
|
||||||
|
"""Envoie la planche à l'écran d'impression."""
|
||||||
|
self.main_window.show_print(path)
|
||||||
|
|
||||||
|
def _go_back(self):
|
||||||
|
# Réinitialiser le bouton
|
||||||
|
self.print_btn.setText("Générer la planche")
|
||||||
|
try:
|
||||||
|
self.print_btn.clicked.disconnect()
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
self.print_btn.clicked.connect(self._generate_sheet)
|
||||||
|
self.main_window.show_import()
|
||||||
@@ -41,6 +41,8 @@ DEFAULT_CONFIG = {
|
|||||||
"13x18": {"enabled": False, "printer": "", "ratio": 1.385},
|
"13x18": {"enabled": False, "printer": "", "ratio": 1.385},
|
||||||
"15x20": {"enabled": False, "printer": "", "ratio": 1.333},
|
"15x20": {"enabled": False, "printer": "", "ratio": 1.333},
|
||||||
},
|
},
|
||||||
|
# NFC
|
||||||
|
"nfc_enabled": False,
|
||||||
# Webhook Copycaisse
|
# Webhook Copycaisse
|
||||||
"webhook_enabled": False,
|
"webhook_enabled": False,
|
||||||
"webhook_url": "",
|
"webhook_url": "",
|
||||||
@@ -347,6 +349,17 @@ class ConfigDialog(QDialog):
|
|||||||
self.tier_widgets.append({"from": from_sb, "to": to_sb, "price": price_input})
|
self.tier_widgets.append({"from": from_sb, "to": to_sb, "price": price_input})
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
|
# ── NFC ──
|
||||||
|
grid.addWidget(self._section_title("NFC (tap-to-upload)"), row, 0, 1, 2)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(self._label("Activer :"), row, 0)
|
||||||
|
self.nfc_enabled_cb = QCheckBox("Activer le NFC (nécessite un lecteur USB)")
|
||||||
|
self.nfc_enabled_cb.setFont(QFont("", 12))
|
||||||
|
self.nfc_enabled_cb.setChecked(self.config.get("nfc_enabled", False))
|
||||||
|
grid.addWidget(self.nfc_enabled_cb, row, 1)
|
||||||
|
row += 1
|
||||||
|
|
||||||
# ── Webhook Copycaisse ──
|
# ── Webhook Copycaisse ──
|
||||||
grid.addWidget(self._section_title("Webhook Copycaisse"), row, 0, 1, 2)
|
grid.addWidget(self._section_title("Webhook Copycaisse"), row, 0, 1, 2)
|
||||||
row += 1
|
row += 1
|
||||||
@@ -443,6 +456,8 @@ class ConfigDialog(QDialog):
|
|||||||
"currency": "€",
|
"currency": "€",
|
||||||
"tiers": tiers,
|
"tiers": tiers,
|
||||||
}
|
}
|
||||||
|
# NFC
|
||||||
|
self.config["nfc_enabled"] = self.nfc_enabled_cb.isChecked()
|
||||||
# Webhook
|
# Webhook
|
||||||
self.config["webhook_enabled"] = self.webhook_enabled_cb.isChecked()
|
self.config["webhook_enabled"] = self.webhook_enabled_cb.isChecked()
|
||||||
self.config["webhook_url"] = self.webhook_url_input.text()
|
self.config["webhook_url"] = self.webhook_url_input.text()
|
||||||
|
|||||||
@@ -36,6 +36,39 @@ TEMPLATES = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Templates photo strips — toujours 2 strips côte à côte sur un 10x15 (4x6")
|
||||||
|
# Une strip seule = demi-feuille, donc on imprime toujours par paire
|
||||||
|
STRIP_TEMPLATES = {
|
||||||
|
"Strip x3": {
|
||||||
|
"zones": [
|
||||||
|
# Strip gauche (3 photos)
|
||||||
|
(0.0, 0.0, 0.5, 0.333),
|
||||||
|
(0.0, 0.333, 0.5, 0.334),
|
||||||
|
(0.0, 0.667, 0.5, 0.333),
|
||||||
|
# Strip droite (copie identique)
|
||||||
|
(0.5, 0.0, 0.5, 0.333),
|
||||||
|
(0.5, 0.333, 0.5, 0.334),
|
||||||
|
(0.5, 0.667, 0.5, 0.333),
|
||||||
|
],
|
||||||
|
"canvas": (1200, 1800), # 4x6" à 300 DPI = 1 tirage 10x15
|
||||||
|
},
|
||||||
|
"Strip x4": {
|
||||||
|
"zones": [
|
||||||
|
# Strip gauche (4 photos)
|
||||||
|
(0.0, 0.0, 0.5, 0.25),
|
||||||
|
(0.0, 0.25, 0.5, 0.25),
|
||||||
|
(0.0, 0.50, 0.5, 0.25),
|
||||||
|
(0.0, 0.75, 0.5, 0.25),
|
||||||
|
# Strip droite (copie identique)
|
||||||
|
(0.5, 0.0, 0.5, 0.25),
|
||||||
|
(0.5, 0.25, 0.5, 0.25),
|
||||||
|
(0.5, 0.50, 0.5, 0.25),
|
||||||
|
(0.5, 0.75, 0.5, 0.25),
|
||||||
|
],
|
||||||
|
"canvas": (1200, 1800),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TemplatePreview(QPushButton):
|
class TemplatePreview(QPushButton):
|
||||||
"""Bouton avec prévisualisation d'un template."""
|
"""Bouton avec prévisualisation d'un template."""
|
||||||
@@ -78,12 +111,13 @@ class TemplatePreview(QPushButton):
|
|||||||
|
|
||||||
|
|
||||||
class MultiposeDialog(QDialog):
|
class MultiposeDialog(QDialog):
|
||||||
"""Dialogue pour choisir un template multi-pose."""
|
"""Dialogue pour choisir un template multi-pose ou photo strip."""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Multi-pose")
|
self.setWindowTitle("Multi-pose / Strips")
|
||||||
self.selected_template = None
|
self.selected_template = None
|
||||||
|
self.selected_canvas = None
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
@@ -94,29 +128,64 @@ class MultiposeDialog(QDialog):
|
|||||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
# Section Multi-pose
|
||||||
|
mp_label = QLabel("Multi-pose")
|
||||||
|
mp_label.setFont(QFont("", 14, QFont.Weight.Bold))
|
||||||
|
mp_label.setStyleSheet("color: #2196F3;")
|
||||||
|
layout.addWidget(mp_label)
|
||||||
|
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
grid.setSpacing(15)
|
grid.setSpacing(15)
|
||||||
|
|
||||||
for i, (name, zones) in enumerate(TEMPLATES.items()):
|
for i, (name, zones) in enumerate(TEMPLATES.items()):
|
||||||
preview = TemplatePreview(name, zones)
|
preview = TemplatePreview(name, zones)
|
||||||
preview.clicked.connect(lambda checked, n=name: self._select(n))
|
preview.clicked.connect(lambda checked, n=name: self._select_multipose(n))
|
||||||
grid.addWidget(preview, i // 3, i % 3)
|
grid.addWidget(preview, i // 3, i % 3)
|
||||||
|
|
||||||
layout.addLayout(grid)
|
layout.addLayout(grid)
|
||||||
|
|
||||||
|
# Section Photo Strips
|
||||||
|
strip_label = QLabel("Photo Strips")
|
||||||
|
strip_label.setFont(QFont("", 14, QFont.Weight.Bold))
|
||||||
|
strip_label.setStyleSheet("color: #FF9800;")
|
||||||
|
layout.addWidget(strip_label)
|
||||||
|
|
||||||
|
strip_grid = QGridLayout()
|
||||||
|
strip_grid.setSpacing(15)
|
||||||
|
|
||||||
|
for i, (name, data) in enumerate(STRIP_TEMPLATES.items()):
|
||||||
|
preview = TemplatePreview(name, data["zones"])
|
||||||
|
preview.setFixedSize(120, 180) # Plus haut pour les strips verticaux
|
||||||
|
preview.clicked.connect(lambda checked, n=name: self._select_strip(n))
|
||||||
|
strip_grid.addWidget(preview, i // 4, i % 4)
|
||||||
|
|
||||||
|
layout.addLayout(strip_grid)
|
||||||
|
|
||||||
# Annuler
|
# Annuler
|
||||||
cancel = QPushButton("Annuler")
|
cancel = QPushButton("Annuler")
|
||||||
cancel.setFont(QFont("", 14))
|
cancel.setFont(QFont("", 14))
|
||||||
cancel.clicked.connect(self.reject)
|
cancel.clicked.connect(self.reject)
|
||||||
layout.addWidget(cancel)
|
layout.addWidget(cancel)
|
||||||
|
|
||||||
def _select(self, name):
|
def _select_multipose(self, name):
|
||||||
self.selected_template = name
|
self.selected_template = name
|
||||||
|
self.selected_canvas = None
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _select_strip(self, name):
|
||||||
|
self.selected_template = name
|
||||||
|
data = STRIP_TEMPLATES[name]
|
||||||
|
self.selected_canvas = data["canvas"]
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_template(parent=None):
|
def get_template(parent=None):
|
||||||
dialog = MultiposeDialog(parent)
|
dialog = MultiposeDialog(parent)
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected_template:
|
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected_template:
|
||||||
return dialog.selected_template, TEMPLATES[dialog.selected_template]
|
name = dialog.selected_template
|
||||||
return None, None
|
if name in TEMPLATES:
|
||||||
|
return name, TEMPLATES[name], None
|
||||||
|
elif name in STRIP_TEMPLATES:
|
||||||
|
data = STRIP_TEMPLATES[name]
|
||||||
|
return name, data["zones"], data["canvas"]
|
||||||
|
return None, None, None
|
||||||
|
|||||||
Reference in New Issue
Block a user