1
etude_lego_jurassic_world/lib/plots/minifig_character_collages.py

177 lines
6.9 KiB
Python

"""Génère des frises horizontales de minifigs par personnage."""
from pathlib import Path
from typing import Dict, Iterable, List, Sequence, Set
from PIL import Image, ImageDraw, ImageFont
from lib.filesystem import ensure_parent_dir
from lib.rebrickable.resources import sanitize_name
def resize_to_height(image: Image.Image, target_height: int) -> Image.Image:
"""Redimensionne une image en conservant le ratio selon une hauteur cible."""
width, height = image.size
ratio = target_height / height
new_width = int(width * ratio)
return image.resize((new_width, target_height), Image.Resampling.LANCZOS)
def render_label(width: int, height: int, text: str, font: ImageFont.ImageFont) -> Image.Image:
"""Construit l'étiquette textuelle centrée sous une minifig."""
label = Image.new("RGB", (width, height), (255, 255, 255))
drawer = ImageDraw.Draw(label)
bbox = drawer.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (width - text_width) // 2
y = (height - text_height) // 2
drawer.text((x, y), text, fill=(0, 0, 0), font=font)
return label
def build_placeholder(image_height: int) -> Image.Image:
"""Crée un rectangle neutre pour signaler une image manquante."""
placeholder = Image.new("RGBA", (image_height, image_height), (220, 220, 220, 255))
drawer = ImageDraw.Draw(placeholder)
drawer.rectangle([(0, 0), (image_height - 1, image_height - 1)], outline=(150, 150, 150), width=2)
drawer.line([(0, 0), (image_height - 1, image_height - 1)], fill=(200, 80, 80), width=3)
drawer.line([(image_height - 1, 0), (0, image_height - 1)], fill=(200, 80, 80), width=3)
return placeholder
def build_cell(image: Image.Image, label: str, image_height: int, label_height: int, font: ImageFont.ImageFont) -> Image.Image:
"""Assemble une minifig redimensionnée et son étiquette associée."""
resized = resize_to_height(image, image_height)
label_img = render_label(resized.width, label_height, label, font).convert("RGBA")
cell = Image.new("RGBA", (resized.width, resized.height + label_height), (255, 255, 255, 255))
cell.paste(resized, (0, 0))
cell.paste(label_img, (0, resized.height))
return cell
def build_character_collage(
character: str,
entries: Sequence[dict],
resources_dir: Path,
destination_dir: Path,
font: ImageFont.ImageFont,
missing_paths: Set[str] | None = None,
image_filename: str = "minifig.jpg",
label_field: str = "fig_num",
image_height: int = 260,
label_height: int = 44,
spacing: int = 28,
) -> Path:
"""Construit la frise d'un personnage et la sauvegarde dans le répertoire cible."""
sanitized = sanitize_name(character)
missing = missing_paths or set()
cells: List[Image.Image] = []
for row in entries:
image_path = resources_dir / row["set_id"] / sanitized / image_filename
owned = "*" if row.get("in_collection", "").lower() == "true" else ""
label_value = row[label_field]
label = f"{row['year']} - {row['set_num']}{owned} ({label_value})"
if str(image_path) in missing:
image = build_placeholder(image_height)
else:
image = Image.open(image_path).convert("RGBA")
cells.append(build_cell(image, label, image_height, label_height, font))
total_width = sum(cell.width for cell in cells) + spacing * (len(cells) - 1)
total_height = max(cell.height for cell in cells)
canvas = Image.new("RGB", (total_width, total_height), (255, 255, 255))
x = 0
for cell in cells:
canvas.paste(cell.convert("RGB"), (x, 0))
x += cell.width + spacing
destination_path = destination_dir / f"{sanitized}.png"
ensure_parent_dir(destination_path)
canvas.save(destination_path)
return destination_path
def build_character_collages(
grouped_entries: Dict[str, Sequence[dict]],
resources_dir: Path,
destination_dir: Path,
missing_paths: Set[str] | None = None,
image_filename: str = "minifig.jpg",
label_field: str = "fig_num",
image_height: int = 260,
label_height: int = 44,
spacing: int = 28,
) -> List[Path]:
"""Construit les frises pour chaque personnage."""
font = ImageFont.load_default()
generated: List[Path] = []
for character, entries in grouped_entries.items():
generated.append(
build_character_collage(
character,
entries,
resources_dir,
destination_dir,
font,
missing_paths=missing_paths,
image_filename=image_filename,
label_field=label_field,
image_height=image_height,
label_height=label_height,
spacing=spacing,
)
)
return generated
def filter_characters_with_variations(
grouped_entries: Dict[str, Sequence[dict]],
min_variations: int = 2,
) -> Dict[str, Sequence[dict]]:
"""Conserve uniquement les personnages ayant un nombre minimal de variations (fig_num distincts)."""
filtered: Dict[str, Sequence[dict]] = {}
for character, entries in grouped_entries.items():
variations = {entry["fig_num"] for entry in entries}
if len(variations) >= min_variations:
filtered[character] = entries
return filtered
def stack_collages(
source_dir: Path,
destination_path: Path,
spacing: int = 18,
allowed_stems: Set[str] | None = None,
name_panel_width: int = 0,
labels_by_stem: Dict[str, str] | None = None,
) -> None:
"""Superpose verticalement toutes les frises présentes dans un dossier."""
images: List[Image.Image] = []
stems: List[str] = []
for path in sorted(source_dir.glob("*.png")):
if allowed_stems is not None and path.stem not in allowed_stems:
continue
images.append(Image.open(path).convert("RGB"))
stems.append(path.stem)
if not images:
return
font = ImageFont.load_default()
if name_panel_width > 0:
with_names: List[Image.Image] = []
for image, stem in zip(images, stems):
label = labels_by_stem.get(stem, stem) if labels_by_stem else stem
name_img = render_label(name_panel_width, image.height, label, font).convert("RGB")
combined = Image.new("RGB", (name_panel_width + image.width, image.height), (255, 255, 255))
combined.paste(name_img, (0, 0))
combined.paste(image, (name_panel_width, 0))
with_names.append(combined)
images = with_names
max_width = max(image.width for image in images)
total_height = sum(image.height for image in images) + spacing * (len(images) - 1)
canvas = Image.new("RGB", (max_width, total_height), (255, 255, 255))
y = 0
for image in images:
canvas.paste(image, (0, y))
y += image.height + spacing
ensure_parent_dir(destination_path)
canvas.save(destination_path)