1

Corrige l'alias Franklin Webb et régénère les frises

This commit is contained in:
Richard Dern 2025-12-02 22:05:26 +01:00
parent a3fe842d94
commit bce8b37bb7
7 changed files with 332 additions and 1 deletions

View File

@ -341,3 +341,13 @@ Le script lit `data/intermediate/sets_enriched.csv`, `data/intermediate/minifigs
- `figures/rebrickable/{set_id}/{known_character}/head.jpg` : visuel de la tête correspondante. - `figures/rebrickable/{set_id}/{known_character}/head.jpg` : visuel de la tête correspondante.
Les requêtes API sont dédoublonnées, espacées (fair-use) et mises en cache dans `data/intermediate/part_img_cache.csv` pour reprise en cas dinterruption. Les images déjà téléchargées sont réutilisées localement pour éviter les requêtes répétées. Les requêtes API sont dédoublonnées, espacées (fair-use) et mises en cache dans `data/intermediate/part_img_cache.csv` pour reprise en cas dinterruption. Les images déjà téléchargées sont réutilisées localement pour éviter les requêtes répétées.
### Étape 32 : frises chronologiques des minifigs par personnage
1. `source .venv/bin/activate`
2. `python -m scripts.plot_minifig_character_collages`
Le script lit `data/intermediate/minifigs_by_set.csv`, `data/intermediate/sets_enriched.csv` et les ressources téléchargées dans `figures/rebrickable`. Il regroupe chaque personnage connu (hors figurants) avec les sets associés et leur année de commercialisation, pour produire :
- `data/intermediate/minifig_character_sets.csv` : apparitions des personnages avec set, identifiant de set, année et fig_num.
- `figures/step32/minifig_characters/{personnage}.png` : frise horizontale par personnage, composée des visuels de minifigs dans lordre chronologique, annotés avec lannée et le numéro de set. Les minifigs dont limage nest pas disponible sont remplacées par un rectangle neutre pour matérialiser le manque.

View File

@ -29,4 +29,5 @@ Guard with Scarf,Figurant
Park Worker,Figurant Park Worker,Figurant
Park Guest in Dark Pink Vest Jacket,Figurant Park Guest in Dark Pink Vest Jacket,Figurant
Wildlife Guard,Figurant Wildlife Guard,Figurant
Kid,Figurant Kid,Figurant
Franklin Web,Franklin Webb
1 alias canonical
29 Park Worker Figurant
30 Park Guest in Dark Pink Vest Jacket Figurant
31 Wildlife Guard Figurant
32 Kid Figurant
33 Franklin Web Franklin Webb

View File

@ -0,0 +1,115 @@
"""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_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 / "minifig.jpg"
label = f"{row['year']} - {row['set_num']}"
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_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_height=image_height,
label_height=label_height,
spacing=spacing,
)
)
return generated

View File

@ -0,0 +1,95 @@
"""Liste les sets contenant chaque personnage connu."""
import csv
from pathlib import Path
from typing import Dict, Iterable, List, Sequence
from lib.filesystem import ensure_parent_dir
from lib.rebrickable.stats import read_rows
def load_minifigs_by_set(path: Path) -> List[dict]:
"""Charge le CSV minifigs_by_set."""
return read_rows(path)
def load_sets(path: Path) -> Dict[str, dict]:
"""Indexe les sets enrichis par set_num."""
lookup: Dict[str, dict] = {}
for row in read_rows(path):
lookup[row["set_num"]] = row
return lookup
def build_character_sets(
minifigs_rows: Iterable[dict],
sets_lookup: Dict[str, dict],
excluded_characters: Sequence[str] | None = None,
) -> List[dict]:
"""Associe chaque personnage connu aux sets où il apparaît (hors figurants)."""
excluded = set(excluded_characters or [])
seen: set[tuple[str, str, str]] = set()
character_sets: List[dict] = []
for row in minifigs_rows:
character = row["known_character"].strip()
fig_num = row["fig_num"].strip()
if character == "" or fig_num == "":
continue
if character in excluded:
continue
set_row = sets_lookup[row["set_num"]]
key = (character, row["set_num"], fig_num)
if key in seen:
continue
character_sets.append(
{
"known_character": character,
"set_num": row["set_num"],
"set_id": set_row["set_id"],
"year": set_row["year"],
"fig_num": fig_num,
}
)
seen.add(key)
character_sets.sort(key=lambda row: (row["known_character"], int(row["year"]), row["set_num"], row["fig_num"]))
return character_sets
def write_character_sets(destination_path: Path, rows: Sequence[dict]) -> None:
"""Écrit le CSV listant les sets par personnage."""
ensure_parent_dir(destination_path)
fieldnames = ["known_character", "set_num", "set_id", "year", "fig_num"]
with destination_path.open("w", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow(row)
def group_by_character(rows: Iterable[dict]) -> Dict[str, List[dict]]:
"""Regroupe les lignes par personnage pour la génération des frises."""
grouped: Dict[str, List[dict]] = {}
for row in rows:
character = row["known_character"]
entries = grouped.get(character)
if entries is None:
entries = []
grouped[character] = entries
entries.append(row)
for character, entries in grouped.items():
entries.sort(key=lambda row: (int(row["year"]), row["set_num"], row["fig_num"]))
return grouped
def load_missing_minifigs(path: Path) -> set[str]:
"""Charge les ressources minifigs introuvables consignées dans le log de téléchargement."""
missing: set[str] = set()
with path.open() as csv_file:
reader = csv.DictReader(csv_file)
for row in reader:
if row["status"] != "missing":
continue
if not row["path"].endswith("minifig.jpg"):
continue
missing.add(row["path"])
return missing

View File

@ -1,4 +1,5 @@
matplotlib matplotlib
Pillow
python-dotenv python-dotenv
pytest pytest
requests requests

View File

@ -0,0 +1,37 @@
"""Construit les frises chronologiques de minifigs par personnage."""
from pathlib import Path
from lib.plots.minifig_character_collages import build_character_collages
from lib.rebrickable.minifig_character_sets import (
build_character_sets,
group_by_character,
load_minifigs_by_set,
load_missing_minifigs,
load_sets,
write_character_sets,
)
MINIFIGS_BY_SET_PATH = Path("data/intermediate/minifigs_by_set.csv")
SETS_ENRICHED_PATH = Path("data/intermediate/sets_enriched.csv")
RESOURCES_DIR = Path("figures/rebrickable")
DESTINATION_DIR = Path("figures/step32/minifig_characters")
CHARACTER_SETS_PATH = Path("data/intermediate/minifig_character_sets.csv")
DOWNLOAD_LOG_PATH = Path("data/intermediate/resources_download_log.csv")
EXCLUDED_CHARACTERS = ["Figurant"]
def main() -> None:
"""Assemble les apparitions des personnages et génère les frises associées."""
minifigs_by_set = load_minifigs_by_set(MINIFIGS_BY_SET_PATH)
sets_lookup = load_sets(SETS_ENRICHED_PATH)
character_sets = build_character_sets(minifigs_by_set, sets_lookup, excluded_characters=EXCLUDED_CHARACTERS)
write_character_sets(CHARACTER_SETS_PATH, character_sets)
missing_minifigs = load_missing_minifigs(DOWNLOAD_LOG_PATH)
grouped = group_by_character(character_sets)
build_character_collages(grouped, RESOURCES_DIR, DESTINATION_DIR, missing_paths=missing_minifigs)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,72 @@
"""Tests des frises chronologiques de minifigs par personnage."""
from pathlib import Path
from PIL import Image
from lib.plots.minifig_character_collages import build_character_collages
from lib.rebrickable.minifig_character_sets import build_character_sets, group_by_character
from lib.rebrickable.resources import sanitize_name
def create_image(path: Path, color: tuple[int, int, int], size: tuple[int, int]) -> None:
"""Crée une image unie pour les besoins de tests."""
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", size, color).save(path)
def test_build_character_sets_and_collages(tmp_path: Path) -> None:
"""Construit les apparitions par personnage puis les frises associées."""
minifigs_rows = [
{"set_num": "1000-1", "known_character": "Alice", "fig_num": "fig-1"},
{"set_num": "1000-1", "known_character": "Alice", "fig_num": "fig-1"},
{"set_num": "1001-1", "known_character": "Bob", "fig_num": "fig-2"},
{"set_num": "1002-1", "known_character": "Bob", "fig_num": "fig-3"},
{"set_num": "1004-1", "known_character": "Claire Dearing", "fig_num": "fig-5"},
{"set_num": "1003-1", "known_character": "Figurant", "fig_num": "fig-4"},
]
sets_lookup = {
"1000-1": {"set_id": "1000", "year": "2020"},
"1001-1": {"set_id": "1001", "year": "2021"},
"1002-1": {"set_id": "1002", "year": "2022"},
"1003-1": {"set_id": "1003", "year": "2023"},
"1004-1": {"set_id": "1004", "year": "2024"},
}
character_sets = build_character_sets(minifigs_rows, sets_lookup, excluded_characters=["Figurant"])
assert character_sets == [
{"known_character": "Alice", "set_num": "1000-1", "set_id": "1000", "year": "2020", "fig_num": "fig-1"},
{"known_character": "Bob", "set_num": "1001-1", "set_id": "1001", "year": "2021", "fig_num": "fig-2"},
{"known_character": "Bob", "set_num": "1002-1", "set_id": "1002", "year": "2022", "fig_num": "fig-3"},
{"known_character": "Claire Dearing", "set_num": "1004-1", "set_id": "1004", "year": "2024", "fig_num": "fig-5"},
]
resources_dir = tmp_path / "resources"
for entry in character_sets:
image_path = resources_dir / entry["set_id"] / sanitize_name(entry["known_character"]) / "minifig.jpg"
if "Claire" in entry["known_character"]:
continue
create_image(image_path, (120, 80, 60), (10, 10))
destination_dir = tmp_path / "output"
missing_minifigs = {
str(resources_dir / "1004" / sanitize_name("Claire Dearing") / "minifig.jpg"),
}
generated = build_character_collages(
group_by_character(character_sets),
resources_dir,
destination_dir,
missing_paths=missing_minifigs,
image_height=20,
label_height=10,
spacing=2,
)
assert len(generated) == 3
alice = Image.open(destination_dir / f"{sanitize_name('Alice')}.png")
bob = Image.open(destination_dir / f"{sanitize_name('Bob')}.png")
claire = Image.open(destination_dir / f"{sanitize_name('Claire Dearing')}.png")
assert alice.size == (20, 30)
assert bob.size == (42, 30)
assert claire.size == (20, 30)
assert claire.getpixel((5, 10)) == (220, 220, 220)