Galerie : - Petit badge orange ⚠ en haut à droite des photos pas au ratio 3:2 - Discret, tooltip "Photo à recadrer" - Tolérance 5% autour du ratio 1.5 Éditeur : - Si la photo n'est pas au ratio 3:2, le cadre de recadrage s'affiche automatiquement en suggestion à l'ouverture - L'utilisateur peut le déplacer/redimensionner ou le valider Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
443 lines
16 KiB
Python
443 lines
16 KiB
Python
"""Écran éditeur — recadrage, goodies, effets, multi-pose, texte."""
|
|
|
|
import os
|
|
import tempfile
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
|
QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem,
|
|
QGraphicsTextItem, QFileDialog, QInputDialog
|
|
)
|
|
from PyQt6.QtCore import Qt, QRectF
|
|
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):
|
|
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.is_cropping = False
|
|
self.multipose_zones = None
|
|
self.multipose_images = {}
|
|
self._setup_ui()
|
|
|
|
def _setup_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
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._go_back)
|
|
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 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.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(8)
|
|
|
|
tool_buttons = [
|
|
("Recadrer", self._toggle_crop),
|
|
("Rotation", self._rotate),
|
|
("N&B", self._filter_grayscale),
|
|
("Sépia", self._filter_sepia),
|
|
("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("", 12))
|
|
btn.setMinimumHeight(50)
|
|
btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #555; color: white;
|
|
border-radius: 8px; padding: 8px 12px;
|
|
}
|
|
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.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)
|
|
|
|
# Auto-afficher le cadre de recadrage si ratio ≠ 3:2
|
|
w, h = pixmap.width(), pixmap.height()
|
|
if w > 0 and h > 0:
|
|
ratio = max(w, h) / min(w, h)
|
|
if abs(ratio - 1.5) > 0.075:
|
|
self._show_ratio_crop()
|
|
|
|
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
|
|
|
|
# --- Cadre ratio suggéré ---
|
|
def _show_ratio_crop(self):
|
|
"""Affiche un cadre de recadrage au ratio 3:2 en suggestion."""
|
|
if not self.pixmap_item:
|
|
return
|
|
self.is_cropping = True
|
|
self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
|
|
pr = self.pixmap_item.boundingRect()
|
|
img_w, img_h = pr.width(), pr.height()
|
|
|
|
ratio = 1.5
|
|
# Adapter au sens de l'image
|
|
if img_w < img_h:
|
|
ratio = 1.0 / ratio
|
|
|
|
# Plus grande zone possible
|
|
if img_w / img_h > ratio:
|
|
crop_h = img_h * 0.95
|
|
crop_w = crop_h * ratio
|
|
else:
|
|
crop_w = img_w * 0.95
|
|
crop_h = crop_w / ratio
|
|
|
|
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.scene.addItem(self.crop_overlay)
|
|
|
|
# --- Navigation ---
|
|
def _go_back(self):
|
|
self._cancel_crop()
|
|
self.main_window.show_import()
|
|
|
|
def _go_print(self):
|
|
path = self._export_to_file()
|
|
self.main_window.show_print(path)
|
|
|
|
# --- 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 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):
|
|
from PIL import ImageOps
|
|
self._apply_pil_filter(lambda img: ImageOps.grayscale(img).convert("RGBA"))
|
|
|
|
def _filter_sepia(self):
|
|
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
|
|
# 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 _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):
|
|
name, zones = MultiposeDialog.get_template(self)
|
|
if not zones:
|
|
return
|
|
|
|
# Utilise la photo en cours (ou l'image éditée du canvas)
|
|
source_pixmap = None
|
|
if self.pixmap_item:
|
|
source_pixmap = self.pixmap_item.pixmap()
|
|
elif self.current_image_path:
|
|
source_pixmap = QPixmap(self.current_image_path)
|
|
|
|
if not source_pixmap or source_pixmap.isNull():
|
|
return
|
|
|
|
self.multipose_zones = zones
|
|
self.multipose_source = source_pixmap
|
|
|
|
# Taille canvas = format d'impression (ex: 1800x1200 pour 15x10)
|
|
canvas_w = 1800
|
|
canvas_h = 1200
|
|
|
|
self._render_multipose(source_pixmap, zones, canvas_w, canvas_h)
|
|
|
|
def _crop_to_fill(self, pixmap, target_w, target_h):
|
|
"""Crop centré pour remplir exactement target_w x target_h."""
|
|
src_w = pixmap.width()
|
|
src_h = pixmap.height()
|
|
src_ratio = src_w / src_h
|
|
target_ratio = target_w / target_h
|
|
|
|
if src_ratio > target_ratio:
|
|
# Image trop large → crop horizontal
|
|
new_w = int(src_h * target_ratio)
|
|
x_offset = (src_w - new_w) // 2
|
|
cropped = pixmap.copy(x_offset, 0, new_w, src_h)
|
|
else:
|
|
# Image trop haute → crop vertical
|
|
new_h = int(src_w / target_ratio)
|
|
y_offset = (src_h - new_h) // 2
|
|
cropped = pixmap.copy(0, y_offset, src_w, new_h)
|
|
|
|
return cropped.scaled(target_w, target_h,
|
|
Qt.AspectRatioMode.IgnoreAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation)
|
|
|
|
def _render_multipose(self, source_pixmap, zones, canvas_w, canvas_h):
|
|
"""Dessine le multi-pose : même image dupliquée, crop to fill."""
|
|
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))
|
|
self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(white))
|
|
self.scene.setSceneRect(QRectF(0, 0, canvas_w, canvas_h))
|
|
|
|
gap = 6 # espace entre les zones
|
|
|
|
for i, (zx, zy, zw, zh) in enumerate(zones):
|
|
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
|
|
|
|
if rw <= 0 or rh <= 0:
|
|
continue
|
|
|
|
# Crop to fill : l'image remplit toute la zone
|
|
filled = self._crop_to_fill(source_pixmap, rw, rh)
|
|
item = self.scene.addPixmap(filled)
|
|
item.setPos(rx, ry)
|
|
|
|
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
|
|
|
# --- Texte ---
|
|
def _add_text(self):
|
|
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)
|