Corrige l'alias Franklin Webb et régénère les frises
This commit is contained in:
parent
a3fe842d94
commit
bce8b37bb7
10
README.md
10
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.
|
- `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.
|
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.
|
||||||
|
|||||||
@ -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
|
||||||
|
115
lib/plots/minifig_character_collages.py
Normal file
115
lib/plots/minifig_character_collages.py
Normal 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
|
||||||
95
lib/rebrickable/minifig_character_sets.py
Normal file
95
lib/rebrickable/minifig_character_sets.py
Normal 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
|
||||||
@ -1,4 +1,5 @@
|
|||||||
matplotlib
|
matplotlib
|
||||||
|
Pillow
|
||||||
python-dotenv
|
python-dotenv
|
||||||
pytest
|
pytest
|
||||||
requests
|
requests
|
||||||
|
|||||||
37
scripts/plot_minifig_character_collages.py
Normal file
37
scripts/plot_minifig_character_collages.py
Normal 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()
|
||||||
72
tests/test_minifig_character_collages.py
Normal file
72
tests/test_minifig_character_collages.py
Normal 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)
|
||||||
Loading…
x
Reference in New Issue
Block a user