From bce8b37bb7b71d574d8620e49d06d256ea184562 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Tue, 2 Dec 2025 22:05:26 +0100 Subject: [PATCH] =?UTF-8?q?Corrige=20l'alias=20Franklin=20Webb=20et=20r?= =?UTF-8?q?=C3=A9g=C3=A9n=C3=A8re=20les=20frises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++ config/known_character_aliases.csv | 3 +- lib/plots/minifig_character_collages.py | 115 +++++++++++++++++++++ lib/rebrickable/minifig_character_sets.py | 95 +++++++++++++++++ requirements.txt | 1 + scripts/plot_minifig_character_collages.py | 37 +++++++ tests/test_minifig_character_collages.py | 72 +++++++++++++ 7 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 lib/plots/minifig_character_collages.py create mode 100644 lib/rebrickable/minifig_character_sets.py create mode 100644 scripts/plot_minifig_character_collages.py create mode 100644 tests/test_minifig_character_collages.py diff --git a/README.md b/README.md index 953a1fe..cbac1fd 100644 --- a/README.md +++ b/README.md @@ -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 d’interruption. 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 l’ordre chronologique, annotés avec l’année et le numéro de set. Les minifigs dont l’image n’est pas disponible sont remplacées par un rectangle neutre pour matérialiser le manque. diff --git a/config/known_character_aliases.csv b/config/known_character_aliases.csv index a6458c4..9fca94a 100644 --- a/config/known_character_aliases.csv +++ b/config/known_character_aliases.csv @@ -29,4 +29,5 @@ Guard with Scarf,Figurant Park Worker,Figurant Park Guest in Dark Pink Vest Jacket,Figurant Wildlife Guard,Figurant -Kid,Figurant \ No newline at end of file +Kid,Figurant +Franklin Web,Franklin Webb \ No newline at end of file diff --git a/lib/plots/minifig_character_collages.py b/lib/plots/minifig_character_collages.py new file mode 100644 index 0000000..aef8165 --- /dev/null +++ b/lib/plots/minifig_character_collages.py @@ -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 diff --git a/lib/rebrickable/minifig_character_sets.py b/lib/rebrickable/minifig_character_sets.py new file mode 100644 index 0000000..923f3d3 --- /dev/null +++ b/lib/rebrickable/minifig_character_sets.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 171492d..89530d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ matplotlib +Pillow python-dotenv pytest requests diff --git a/scripts/plot_minifig_character_collages.py b/scripts/plot_minifig_character_collages.py new file mode 100644 index 0000000..b24fdc8 --- /dev/null +++ b/scripts/plot_minifig_character_collages.py @@ -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() diff --git a/tests/test_minifig_character_collages.py b/tests/test_minifig_character_collages.py new file mode 100644 index 0000000..923b716 --- /dev/null +++ b/tests/test_minifig_character_collages.py @@ -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)