1

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

This commit is contained in:
2025-12-02 22:05:26 +01:00
parent ace0c5ea35
commit 6dbd552efc
58 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.
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 Guest in Dark Pink Vest Jacket,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

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 346 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

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
Pillow
python-dotenv
pytest
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)