diff --git a/README.md b/README.md index 7005cf4..cd1e18d 100644 --- a/README.md +++ b/README.md @@ -346,9 +346,11 @@ 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` 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. +- `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. - Les étiquettes affichent aussi l’identifiant de la minifig (`fig-*`) et un astérisque à côté du set (`set_num*`) lorsqu’il est présent dans la collection. diff --git a/lib/plots/minifig_character_collages.py b/lib/plots/minifig_character_collages.py index 46132da..46a1b4d 100644 --- a/lib/plots/minifig_character_collages.py +++ b/lib/plots/minifig_character_collages.py @@ -57,6 +57,7 @@ def build_character_collage( destination_dir: Path, font: ImageFont.ImageFont, missing_paths: Set[str] | None = None, + image_filename: str = "minifig.jpg", image_height: int = 260, label_height: int = 44, spacing: int = 28, @@ -66,7 +67,7 @@ def build_character_collage( missing = missing_paths or set() cells: List[Image.Image] = [] for row in entries: - image_path = resources_dir / row["set_id"] / sanitized / "minifig.jpg" + image_path = resources_dir / row["set_id"] / sanitized / image_filename owned = "*" if row.get("in_collection", "").lower() == "true" else "" label = f"{row['year']} - {row['set_num']}{owned} ({row['fig_num']})" if str(image_path) in missing: @@ -92,6 +93,7 @@ def build_character_collages( resources_dir: Path, destination_dir: Path, missing_paths: Set[str] | None = None, + image_filename: str = "minifig.jpg", image_height: int = 260, label_height: int = 44, spacing: int = 28, @@ -108,6 +110,7 @@ def build_character_collages( destination_dir, font, missing_paths=missing_paths, + image_filename=image_filename, image_height=image_height, label_height=label_height, spacing=spacing, diff --git a/lib/rebrickable/minifig_character_sets.py b/lib/rebrickable/minifig_character_sets.py index e203930..9f5d821 100644 --- a/lib/rebrickable/minifig_character_sets.py +++ b/lib/rebrickable/minifig_character_sets.py @@ -86,13 +86,18 @@ def group_by_character(rows: Iterable[dict]) -> Dict[str, List[dict]]: def load_missing_minifigs(path: Path) -> set[str]: """Charge les ressources minifigs introuvables consignées dans le log de téléchargement.""" + return load_missing_resources(path, "minifig.jpg") + + +def load_missing_resources(path: Path, filename: str) -> set[str]: + """Charge les ressources introuvables d'un type donné 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"): + if not row["path"].endswith(filename): continue missing.add(row["path"]) return missing diff --git a/scripts/plot_minifig_head_collages.py b/scripts/plot_minifig_head_collages.py new file mode 100644 index 0000000..e175142 --- /dev/null +++ b/scripts/plot_minifig_head_collages.py @@ -0,0 +1,43 @@ +"""Construit les frises chronologiques des têtes 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_resources, + 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_heads") +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 de têtes 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_heads = load_missing_resources(DOWNLOAD_LOG_PATH, "head.jpg") + grouped = group_by_character(character_sets) + build_character_collages( + grouped, + RESOURCES_DIR, + DESTINATION_DIR, + missing_paths=missing_heads, + image_filename="head.jpg", + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_minifig_character_collages.py b/tests/test_minifig_character_collages.py index e3472be..6be4d4d 100644 --- a/tests/test_minifig_character_collages.py +++ b/tests/test_minifig_character_collages.py @@ -85,6 +85,7 @@ def test_build_character_sets_and_collages(tmp_path: Path) -> None: resources_dir, destination_dir, missing_paths=missing_minifigs, + image_filename="minifig.jpg", image_height=20, label_height=10, spacing=2, @@ -98,3 +99,28 @@ def test_build_character_sets_and_collages(tmp_path: Path) -> None: assert bob.size == (42, 30) assert claire.size == (20, 30) assert claire.getpixel((5, 10)) == (220, 220, 220) + + # Variante sur les têtes : utilise un autre fichier et un autre manque. + heads_destination = tmp_path / "output_heads" + head_missing = { + str(resources_dir / "1001" / sanitize_name("Bob") / "head.jpg"), + str(resources_dir / "1002" / sanitize_name("Bob") / "head.jpg"), + str(resources_dir / "1004" / sanitize_name("Claire Dearing") / "head.jpg"), + } + 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), + resources_dir, + heads_destination, + missing_paths=head_missing, + image_filename="head.jpg", + image_height=16, + label_height=8, + spacing=2, + ) + assert len(head_collages) == 3 + alice_head = Image.open(heads_destination / f"{sanitize_name('Alice')}.png") + 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)