Import USB + éditeur complet + widgets
- Service USB watcher (polling montages, scan photos récursif) - Écran accueil avec détection USB live (bouton vert + compteur) - Éditeur : crop interactif avec overlay/grille des tiers - Éditeur : filtres Pillow (N&B, sépia, vintage, contraste) - Éditeur : stickers/goodies draggable + cadres overlay - Éditeur : multi-pose (5 templates, remplissage auto) - Éditeur : texte éditable draggable - Éditeur : rotation, annulation, export scène PNG - Widget CropOverlay avec poignées tactiles - Widget GoodiesPanel (stickers + cadres depuis assets/) - Widget MultiposeDialog (prévisualisation templates) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
"""Écran éditeur — recadrage, goodies, effets, multi-pose."""
|
||||
"""Écran éditeur — recadrage, goodies, effets, multi-pose, texte."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||
QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem,
|
||||
QToolBar
|
||||
QGraphicsTextItem, QFileDialog, QInputDialog
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QRectF
|
||||
from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter
|
||||
from PyQt6.QtGui import (
|
||||
QFont, QPixmap, QImage, QPainter, QColor, QTransform
|
||||
)
|
||||
|
||||
from ui.widgets.crop_overlay import CropOverlay
|
||||
from ui.widgets.goodies_panel import GoodiesPanel
|
||||
from ui.widgets.multipose_dialog import MultiposeDialog, TEMPLATES
|
||||
|
||||
|
||||
class EditorScreen(QWidget):
|
||||
@@ -15,17 +23,22 @@ class EditorScreen(QWidget):
|
||||
self.main_window = main_window
|
||||
self.current_image_path = None
|
||||
self.pixmap_item = None
|
||||
self.crop_overlay = None
|
||||
self.is_cropping = False
|
||||
self.multipose_zones = None
|
||||
self.multipose_images = {}
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
layout.setSpacing(5)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
# Header
|
||||
header = QHBoxLayout()
|
||||
back_btn = QPushButton("Retour")
|
||||
back_btn.setFont(QFont("", 14))
|
||||
back_btn.clicked.connect(self.main_window.show_import)
|
||||
back_btn.clicked.connect(self._go_back)
|
||||
header.addWidget(back_btn)
|
||||
|
||||
title = QLabel("Éditeur")
|
||||
@@ -37,10 +50,8 @@ class EditorScreen(QWidget):
|
||||
print_btn.setFont(QFont("", 14))
|
||||
print_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
background-color: #4CAF50; color: white;
|
||||
border-radius: 10px; padding: 10px 20px;
|
||||
}
|
||||
QPushButton:pressed { background-color: #388E3C; }
|
||||
""")
|
||||
@@ -48,39 +59,53 @@ class EditorScreen(QWidget):
|
||||
header.addWidget(print_btn)
|
||||
layout.addLayout(header)
|
||||
|
||||
# Zone d'édition (QGraphicsScene)
|
||||
# Zone centrale : goodies panel + canvas
|
||||
center = QHBoxLayout()
|
||||
|
||||
# Panneau goodies (caché par défaut)
|
||||
self.goodies_panel = GoodiesPanel()
|
||||
self.goodies_panel.sticker_selected.connect(self._place_sticker)
|
||||
self.goodies_panel.frame_selected.connect(self._apply_frame)
|
||||
self.goodies_panel.hide()
|
||||
center.addWidget(self.goodies_panel)
|
||||
|
||||
# Canvas
|
||||
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)
|
||||
self.view.setStyleSheet("background-color: #333; border: none;")
|
||||
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
center.addWidget(self.view, 1)
|
||||
|
||||
layout.addLayout(center, 1)
|
||||
|
||||
# Barre d'outils
|
||||
tools = QHBoxLayout()
|
||||
tools.setSpacing(10)
|
||||
tools.setSpacing(8)
|
||||
|
||||
tool_buttons = [
|
||||
("Recadrer", self._crop),
|
||||
("Recadrer", self._toggle_crop),
|
||||
("Rotation", self._rotate),
|
||||
("N&B", self._filter_grayscale),
|
||||
("Sépia", self._filter_sepia),
|
||||
("Goodies", self._add_goodies),
|
||||
("Vintage", self._filter_vintage),
|
||||
("Contraste", self._filter_contrast),
|
||||
("Goodies", self._toggle_goodies),
|
||||
("Multi-pose", self._multipose),
|
||||
("Texte", self._add_text),
|
||||
("Annuler", self._reset_image),
|
||||
]
|
||||
|
||||
for label, callback in tool_buttons:
|
||||
btn = QPushButton(label)
|
||||
btn.setFont(QFont("", 13))
|
||||
btn.setFont(QFont("", 12))
|
||||
btn.setMinimumHeight(50)
|
||||
btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #555;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 8px 15px;
|
||||
background-color: #555; color: white;
|
||||
border-radius: 8px; padding: 8px 12px;
|
||||
}
|
||||
QPushButton:pressed { background-color: #777; }
|
||||
""")
|
||||
@@ -91,54 +116,295 @@ class EditorScreen(QWidget):
|
||||
|
||||
def load_image(self, path):
|
||||
self.current_image_path = path
|
||||
self.multipose_zones = None
|
||||
self.multipose_images = {}
|
||||
self._cancel_crop()
|
||||
self.scene.clear()
|
||||
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)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if self.pixmap_item:
|
||||
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
# --- Export ---
|
||||
def _export_scene(self):
|
||||
"""Exporte la scène complète en QImage haute résolution."""
|
||||
rect = self.scene.sceneRect()
|
||||
image = QImage(int(rect.width()), int(rect.height()), QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor(255, 255, 255))
|
||||
painter = QPainter(image)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
self.scene.render(painter)
|
||||
painter.end()
|
||||
return image
|
||||
|
||||
def _export_to_file(self):
|
||||
"""Exporte la scène dans un fichier temporaire et retourne le chemin."""
|
||||
image = self._export_scene()
|
||||
path = os.path.join(tempfile.gettempdir(), "photostation_export.png")
|
||||
image.save(path, "PNG")
|
||||
return path
|
||||
|
||||
# --- Navigation ---
|
||||
def _go_back(self):
|
||||
self._cancel_crop()
|
||||
self.main_window.show_import()
|
||||
|
||||
def _go_print(self):
|
||||
# TODO: exporter le canvas édité en image
|
||||
self.main_window.show_print(self.current_image_path)
|
||||
path = self._export_to_file()
|
||||
self.main_window.show_print(path)
|
||||
|
||||
def _crop(self):
|
||||
# TODO: outil de recadrage interactif
|
||||
pass
|
||||
# --- Recadrage ---
|
||||
def _toggle_crop(self):
|
||||
if self.is_cropping:
|
||||
self._apply_crop()
|
||||
else:
|
||||
self._start_crop()
|
||||
|
||||
def _start_crop(self):
|
||||
if not self.pixmap_item:
|
||||
return
|
||||
self.is_cropping = True
|
||||
self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
|
||||
pr = self.pixmap_item.boundingRect()
|
||||
# Crop initial = 80% centré
|
||||
margin_x = pr.width() * 0.1
|
||||
margin_y = pr.height() * 0.1
|
||||
crop_rect = QRectF(margin_x, margin_y,
|
||||
pr.width() - 2 * margin_x, pr.height() - 2 * margin_y)
|
||||
self.crop_overlay = CropOverlay(crop_rect)
|
||||
self.crop_overlay.set_bounds(pr)
|
||||
self.scene.addItem(self.crop_overlay)
|
||||
|
||||
def _apply_crop(self):
|
||||
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.removeItem(self.crop_overlay)
|
||||
self.crop_overlay = None
|
||||
self.is_cropping = False
|
||||
self.scene.clear()
|
||||
self.pixmap_item = self.scene.addPixmap(cropped)
|
||||
self.scene.setSceneRect(QRectF(cropped.rect()))
|
||||
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
def _cancel_crop(self):
|
||||
if self.crop_overlay:
|
||||
self.scene.removeItem(self.crop_overlay)
|
||||
self.crop_overlay = None
|
||||
self.is_cropping = False
|
||||
|
||||
# --- Rotation ---
|
||||
def _rotate(self):
|
||||
if self.pixmap_item:
|
||||
self.pixmap_item.setRotation(self.pixmap_item.rotation() + 90)
|
||||
if not self.pixmap_item:
|
||||
return
|
||||
pixmap = self.pixmap_item.pixmap()
|
||||
transform = QTransform().rotate(90)
|
||||
rotated = pixmap.transformed(transform, Qt.TransformationMode.SmoothTransformation)
|
||||
self.scene.clear()
|
||||
self.pixmap_item = self.scene.addPixmap(rotated)
|
||||
self.scene.setSceneRect(QRectF(rotated.rect()))
|
||||
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
# --- Filtres ---
|
||||
def _apply_pil_filter(self, pil_func):
|
||||
"""Applique un filtre Pillow sur l'image actuelle du canvas."""
|
||||
if not self.pixmap_item:
|
||||
return
|
||||
from PIL import Image
|
||||
# QPixmap -> PIL
|
||||
pixmap = self.pixmap_item.pixmap()
|
||||
qimg = pixmap.toImage().convertToFormat(QImage.Format.Format_RGBA8888)
|
||||
width, height = qimg.width(), qimg.height()
|
||||
ptr = qimg.bits()
|
||||
ptr.setsize(width * height * 4)
|
||||
pil_img = Image.frombytes("RGBA", (width, height), bytes(ptr))
|
||||
|
||||
# Appliquer le filtre
|
||||
result = pil_func(pil_img)
|
||||
|
||||
# PIL -> QPixmap
|
||||
data = result.convert("RGBA").tobytes("raw", "RGBA")
|
||||
new_qimg = QImage(data, result.width, result.height, QImage.Format.Format_RGBA8888)
|
||||
new_pixmap = QPixmap.fromImage(new_qimg)
|
||||
|
||||
self.scene.clear()
|
||||
self.pixmap_item = self.scene.addPixmap(new_pixmap)
|
||||
self.scene.setSceneRect(QRectF(new_pixmap.rect()))
|
||||
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
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)
|
||||
from PIL import ImageOps
|
||||
self._apply_pil_filter(lambda img: ImageOps.grayscale(img).convert("RGBA"))
|
||||
|
||||
def _filter_sepia(self):
|
||||
if not self.current_image_path:
|
||||
from PIL import ImageOps
|
||||
def sepia(img):
|
||||
gray = ImageOps.grayscale(img)
|
||||
return ImageOps.colorize(gray, "#704214", "#C0A080").convert("RGBA")
|
||||
self._apply_pil_filter(sepia)
|
||||
|
||||
def _filter_vintage(self):
|
||||
from PIL import ImageOps, ImageEnhance
|
||||
def vintage(img):
|
||||
rgb = img.convert("RGB")
|
||||
rgb = ImageEnhance.Color(rgb).enhance(0.6)
|
||||
rgb = ImageEnhance.Contrast(rgb).enhance(1.2)
|
||||
rgb = ImageEnhance.Brightness(rgb).enhance(0.9)
|
||||
return rgb.convert("RGBA")
|
||||
self._apply_pil_filter(vintage)
|
||||
|
||||
def _filter_contrast(self):
|
||||
from PIL import ImageEnhance
|
||||
def contrast(img):
|
||||
rgb = img.convert("RGB")
|
||||
rgb = ImageEnhance.Contrast(rgb).enhance(1.5)
|
||||
return rgb.convert("RGBA")
|
||||
self._apply_pil_filter(contrast)
|
||||
|
||||
# --- Goodies ---
|
||||
def _toggle_goodies(self):
|
||||
self.goodies_panel.setVisible(not self.goodies_panel.isVisible())
|
||||
|
||||
def _place_sticker(self, path):
|
||||
"""Ajoute un sticker draggable sur le canvas."""
|
||||
pixmap = QPixmap(path)
|
||||
if pixmap.isNull():
|
||||
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)
|
||||
# Taille sticker = 20% de la largeur de la scène
|
||||
scene_w = self.scene.sceneRect().width()
|
||||
if scene_w > 0:
|
||||
target_w = int(scene_w * 0.2)
|
||||
pixmap = pixmap.scaledToWidth(target_w, Qt.TransformationMode.SmoothTransformation)
|
||||
item = self.scene.addPixmap(pixmap)
|
||||
item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsMovable, True)
|
||||
item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsSelectable, True)
|
||||
# Centrer
|
||||
sr = self.scene.sceneRect()
|
||||
item.setPos(sr.center().x() - pixmap.width() / 2,
|
||||
sr.center().y() - pixmap.height() / 2)
|
||||
|
||||
def _add_goodies(self):
|
||||
# TODO: panneau de sélection de stickers
|
||||
pass
|
||||
def _apply_frame(self, path):
|
||||
"""Applique un cadre par-dessus l'image (en overlay)."""
|
||||
if not self.pixmap_item:
|
||||
return
|
||||
frame_pixmap = QPixmap(path)
|
||||
if frame_pixmap.isNull():
|
||||
return
|
||||
sr = self.scene.sceneRect()
|
||||
scaled = frame_pixmap.scaled(
|
||||
int(sr.width()), int(sr.height()),
|
||||
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
frame_item = self.scene.addPixmap(scaled)
|
||||
frame_item.setPos(sr.topLeft())
|
||||
frame_item.setZValue(100)
|
||||
|
||||
# --- Multi-pose ---
|
||||
def _multipose(self):
|
||||
# TODO: sélection de templates multi-photos
|
||||
pass
|
||||
name, zones = MultiposeDialog.get_template(self)
|
||||
if not zones:
|
||||
return
|
||||
|
||||
self.multipose_zones = zones
|
||||
self.multipose_images = {}
|
||||
|
||||
# Demander les photos pour chaque zone
|
||||
sr = self.scene.sceneRect()
|
||||
canvas_w, canvas_h = int(sr.width()) or 1800, int(sr.height()) or 1200
|
||||
|
||||
self.scene.clear()
|
||||
self.pixmap_item = None
|
||||
|
||||
# Canvas blanc
|
||||
white = QImage(canvas_w, canvas_h, QImage.Format.Format_ARGB32)
|
||||
white.fill(QColor(255, 255, 255))
|
||||
bg = self.scene.addPixmap(QPixmap.fromImage(white))
|
||||
self.scene.setSceneRect(QRectF(0, 0, canvas_w, canvas_h))
|
||||
|
||||
# Dessiner les zones avec numéros
|
||||
for i, (zx, zy, zw, zh) in enumerate(zones):
|
||||
rx = int(zx * canvas_w)
|
||||
ry = int(zy * canvas_h)
|
||||
rw = int(zw * canvas_w)
|
||||
rh = int(zh * canvas_h)
|
||||
|
||||
# Bordure de zone
|
||||
from PyQt6.QtWidgets import QGraphicsRectItem
|
||||
from PyQt6.QtGui import QPen, QBrush
|
||||
rect = self.scene.addRect(rx + 2, ry + 2, rw - 4, rh - 4,
|
||||
QPen(QColor(100, 100, 100), 2),
|
||||
QBrush(QColor(240, 240, 240)))
|
||||
|
||||
# Numéro
|
||||
text = self.scene.addText(str(i + 1), QFont("", 40))
|
||||
text.setDefaultTextColor(QColor(150, 150, 150))
|
||||
text.setPos(rx + rw / 2 - 20, ry + rh / 2 - 30)
|
||||
|
||||
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
# Charger les photos depuis la galerie d'import
|
||||
import_photos = self.main_window.import_screen.photos
|
||||
if import_photos:
|
||||
self._fill_multipose_from_list(import_photos, zones, canvas_w, canvas_h)
|
||||
|
||||
def _fill_multipose_from_list(self, photos, zones, canvas_w, canvas_h):
|
||||
"""Remplit les zones multi-pose avec les premières photos disponibles."""
|
||||
self.scene.clear()
|
||||
|
||||
# Canvas blanc
|
||||
white = QImage(canvas_w, canvas_h, QImage.Format.Format_ARGB32)
|
||||
white.fill(QColor(255, 255, 255))
|
||||
self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(white))
|
||||
self.scene.setSceneRect(QRectF(0, 0, canvas_w, canvas_h))
|
||||
|
||||
gap = 4 # espace entre les photos
|
||||
|
||||
for i, (zx, zy, zw, zh) in enumerate(zones):
|
||||
if i >= len(photos):
|
||||
break
|
||||
rx = int(zx * canvas_w) + gap
|
||||
ry = int(zy * canvas_h) + gap
|
||||
rw = int(zw * canvas_w) - gap * 2
|
||||
rh = int(zh * canvas_h) - gap * 2
|
||||
|
||||
pixmap = QPixmap(photos[i])
|
||||
if pixmap.isNull():
|
||||
continue
|
||||
scaled = pixmap.scaled(rw, rh,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
item = self.scene.addPixmap(scaled)
|
||||
# Centrer dans la zone
|
||||
offset_x = rx + (rw - scaled.width()) / 2
|
||||
offset_y = ry + (rh - scaled.height()) / 2
|
||||
item.setPos(offset_x, offset_y)
|
||||
|
||||
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
# --- Texte ---
|
||||
def _add_text(self):
|
||||
# TODO: ajout de texte éditable sur le canvas
|
||||
pass
|
||||
text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :")
|
||||
if not ok or not text:
|
||||
return
|
||||
item = QGraphicsTextItem(text)
|
||||
item.setFont(QFont("", 32))
|
||||
item.setDefaultTextColor(QColor(255, 255, 255))
|
||||
item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsMovable, True)
|
||||
item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True)
|
||||
item.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction)
|
||||
sr = self.scene.sceneRect()
|
||||
item.setPos(sr.center().x() - 100, sr.center().y() - 20)
|
||||
self.scene.addItem(item)
|
||||
|
||||
# --- Reset ---
|
||||
def _reset_image(self):
|
||||
if self.current_image_path:
|
||||
self.load_image(self.current_image_path)
|
||||
|
||||
Reference in New Issue
Block a user