From cdc39cdfa0629183fd094f45ab18c30f3da6a5f9 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Wed, 3 Dec 2025 23:30:31 +0100 Subject: [PATCH] Filtre les collages globaux sur les personnages multi-variations --- README.md | 3 ++ lib/plots/minifig_character_collages.py | 38 ++++++++++++++++++++++ scripts/plot_minifig_collage_grids.py | 41 ++++++++++++++++++++++++ tests/test_minifig_character_collages.py | 25 ++++++++++++--- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 scripts/plot_minifig_collage_grids.py diff --git a/README.md b/README.md index 34ed736..8d518af 100644 --- a/README.md +++ b/README.md @@ -361,12 +361,15 @@ Les requêtes API sont dédoublonnées, espacées (fair-use) et mises en cache d 1. `source .venv/bin/activate` 2. `python -m scripts.plot_minifig_character_collages` 3. `python -m scripts.plot_minifig_head_collages` +4. `python -m scripts.plot_minifig_collage_grids` 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, possession 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, le numéro de set (avec `*` si possédé) et l’identifiant de minifig. Les minifigs dont l’image n’est pas disponible sont remplacées par un rectangle neutre pour matérialiser le manque. - `figures/step32/minifig_heads/{personnage}.png` : même principe mais en utilisant les visuels de têtes (`head.jpg`) pour chaque apparition, annotés avec l’année, le set (avec `*` si possédé) et le `part_num` de la tête. +- `figures/step32/minifig_characters_all.png` : superposition verticale des frises minifigs pour les personnages ayant au moins deux variations. +- `figures/step32/minifig_heads_all.png` : superposition verticale des frises têtes pour les personnages ayant au moins deux variations. ### Étape 33 : réutilisation des têtes de minifigs dans le catalogue diff --git a/lib/plots/minifig_character_collages.py b/lib/plots/minifig_character_collages.py index e5029d8..c440cac 100644 --- a/lib/plots/minifig_character_collages.py +++ b/lib/plots/minifig_character_collages.py @@ -121,3 +121,41 @@ def build_character_collages( ) ) 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, +) -> None: + """Superpose verticalement toutes les frises présentes dans un dossier.""" + images: List[Image.Image] = [] + 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")) + if not images: + return + 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) diff --git a/scripts/plot_minifig_collage_grids.py b/scripts/plot_minifig_collage_grids.py new file mode 100644 index 0000000..398e4c2 --- /dev/null +++ b/scripts/plot_minifig_collage_grids.py @@ -0,0 +1,41 @@ +"""Assemble des grilles verticales filtrées sur les personnages multi-variations.""" + +from pathlib import Path + +from lib.plots.minifig_character_collages import filter_characters_with_variations, stack_collages +from lib.rebrickable.minifig_character_sets import ( + build_character_sets, + group_by_character, + load_minifigs_by_set, + load_sets, +) +from lib.rebrickable.resources import sanitize_name + + +MINIFIGS_BY_SET_PATH = Path("data/intermediate/minifigs_by_set.csv") +SETS_ENRICHED_PATH = Path("data/intermediate/sets_enriched.csv") +CHARACTERS_DIR = Path("figures/step32/minifig_characters") +HEADS_DIR = Path("figures/step32/minifig_heads") +CHARACTERS_DEST = Path("figures/step32/minifig_characters_all.png") +HEADS_DEST = Path("figures/step32/minifig_heads_all.png") +EXCLUDED_CHARACTERS = ["Figurant"] + + +def main() -> None: + """Superpose les frises de personnages ayant au moins deux variations.""" + minifigs = load_minifigs_by_set(MINIFIGS_BY_SET_PATH) + sets_lookup = load_sets(SETS_ENRICHED_PATH) + character_sets = build_character_sets(minifigs, sets_lookup, excluded_characters=EXCLUDED_CHARACTERS) + grouped = group_by_character(character_sets) + filtered = filter_characters_with_variations(grouped, min_variations=2) + keep = {sanitize_name(name) for name in filtered.keys()} + + for source_dir, destination in ( + (CHARACTERS_DIR, CHARACTERS_DEST), + (HEADS_DIR, HEADS_DEST), + ): + stack_collages(source_dir, destination, spacing=12, allowed_stems=keep) + + +if __name__ == "__main__": + main() diff --git a/tests/test_minifig_character_collages.py b/tests/test_minifig_character_collages.py index 5b08be0..6da8e57 100644 --- a/tests/test_minifig_character_collages.py +++ b/tests/test_minifig_character_collages.py @@ -4,7 +4,7 @@ from pathlib import Path from PIL import Image -from lib.plots.minifig_character_collages import build_character_collages +from lib.plots.minifig_character_collages import build_character_collages, stack_collages from lib.rebrickable.minifig_character_sets import build_character_sets, group_by_character from lib.rebrickable.resources import sanitize_name @@ -114,7 +114,7 @@ def test_build_character_sets_and_collages(tmp_path: Path) -> None: head_image = resources_dir / "1000" / sanitize_name("Alice") / "head.jpg" create_image(head_image, (10, 120, 60), (8, 12)) head_collages = build_character_collages( - group_by_character(character_sets), + {"Bob": group_by_character(character_sets)["Bob"]}, resources_dir, heads_destination, missing_paths=head_missing, @@ -123,8 +123,23 @@ def test_build_character_sets_and_collages(tmp_path: Path) -> None: label_height=8, spacing=2, ) - assert len(head_collages) == 3 - alice_head = Image.open(heads_destination / f"{sanitize_name('Alice')}.png") + assert len(head_collages) == 1 bob_head = Image.open(heads_destination / f"{sanitize_name('Bob')}.png") - assert alice_head.size[1] == 24 assert bob_head.getpixel((3, 8)) == (220, 220, 220) + + +def test_stack_collages_with_filter(tmp_path: Path) -> None: + """Assemble une grille verticale en filtrant les frises autorisées.""" + source_dir = tmp_path / "collages" + source_dir.mkdir() + img1 = source_dir / "Alice.png" + img2 = source_dir / "Bob.png" + Image.new("RGB", (10, 6), (100, 100, 100)).save(img1) + Image.new("RGB", (14, 8), (120, 120, 120)).save(img2) + + destination = tmp_path / "stacked.png" + stack_collages(source_dir, destination, spacing=2, allowed_stems={"Bob"}) + + result = Image.open(destination) + assert result.size == (14, 8) + assert result.getpixel((0, 0)) == (120, 120, 120)