diff --git a/README.md b/README.md index e240bea..e1ff2c1 100644 --- a/README.md +++ b/README.md @@ -164,14 +164,14 @@ Le script lit `data/intermediate/parts_filtered.csv` et `data/final/stats.csv` ( 2. `python -m scripts.plot_colors_grid` Le script lit `data/intermediate/parts_filtered.csv` et `data/raw/colors.csv`, puis génère deux visuels : `figures/step12/colors_grid.png` pour l'ensemble des pièces (rechanges incluses) et `figures/step12/colors_grid_minifigs.png` pour la seule palette des minifigs. Les couleurs sont triées perceptuellement et mises en scène sur une grille hexagonale. -Les codes couleurs 0033B2 et 05131D sont ignorés pour ne pas polluer les palettes. +Les codes couleurs 0033B2 et 05131D sont ignorés, et les pièces considérées comme techniques/structurelles (catégories Technic, roues, tubes, axes, etc.) sont filtrées afin de ne garder que les éléments « visibles » pour l’esthétique. ### Étape 13 : palette de couleurs par set 1. `source .venv/bin/activate` 2. `python -m scripts.build_colors_by_set` -Le script agrège `data/intermediate/parts_filtered.csv` avec les libellés de couleurs `data/raw/colors.csv` et produit `data/intermediate/colors_by_set.csv` contenant, pour chaque set et chaque couleur, les quantités totales, hors rechanges, issues des minifigs et hors minifigs. Ce fichier sert de base aux visualisations et matrices de palette. +Le script agrège `data/intermediate/parts_filtered.csv` avec les libellés de couleurs `data/raw/colors.csv` et produit `data/intermediate/colors_by_set.csv` contenant, pour chaque set et chaque couleur, les quantités totales, hors rechanges, issues des minifigs et hors minifigs. Les couleurs ignorées (0033B2, 05131D) et les pièces techniques/structurelles sont exclues. Ce fichier sert de base aux visualisations et matrices de palette. ### Étape 14 : évolution annuelle des palettes @@ -187,6 +187,7 @@ Le script lit `data/intermediate/colors_by_set.csv` et produit deux agrégats : Le script lit les agrégats de l'étape 14 et produit `figures/step15/colors_translucent_share.png` (part des pièces translucides par année et nombre de couleurs distinctes), `figures/step15/colors_heatmap_linear.png` (heatmap année × couleur en quantités brutes) et `figures/step15/colors_heatmap_log.png` (heatmap avec échelle log1p). Une troisième variante normalise les quantités par année : `figures/step15/colors_heatmap_share.png`. Dans cette vue, chaque colonne (année) est ramenée à une part relative (0–1) du total de pièces de l'année. Cela met en évidence la structure de palette indépendamment du volume : deux années restent comparables même si leur nombre total de pièces diffère fortement, mais l'information de volume absolu n'apparaît plus (à privilégier pour les comparaisons de proportions, pas pour mesurer la rareté volumique). +Toutes les vues héritent du filtrage des couleurs ignorées et des pièces techniques/structurelles appliqué en amont. ### Étape 16 : couleurs de peau des minifigs @@ -194,6 +195,7 @@ Une troisième variante normalise les quantités par année : `figures/step15/co 2. `python -m scripts.compute_minifig_heads` Le script identifie les têtes de minifigs via la catégorie Rebrickable dédiée (part_cat_id 59 dans `data/raw/parts.csv`), filtre les pièces de rechange, puis agrège leurs couleurs depuis `data/intermediate/parts_filtered.csv`. Les sorties sont `data/intermediate/minifig_heads_by_set.csv` (quantités de têtes par set, couleur et année) et `data/intermediate/minifig_heads_by_year.csv` (agrégées par année). Ces fichiers serviront de base pour analyser l'évolution des teintes de peau (ou assimilées) des minifigs. +Les couleurs ignorées (0033B2, 05131D) sont écartées lors de l’agrégation. ### Étape 17 : visualiser les couleurs de peau des minifigs @@ -277,3 +279,7 @@ Un second export `data/intermediate/minifigs_per_set_timeline.csv` est généré 2. `python -m scripts.plot_set_color_swatches` Le script lit `data/intermediate/colors_by_set.csv` (hors rechanges) et `data/intermediate/sets_enriched.csv`, sélectionne pour chaque set les 5 couleurs les plus présentes en excluant les pièces de minifigs (`quantity_non_minifig`), écrit `data/intermediate/set_color_swatches.csv`, puis trace `figures/step27/set_color_swatches.png` affichant chaque set avec ses 5 pastilles de couleurs dominantes. + +### Étape 28 : palettes perceptuelles par set (en préparation) + +Objectif : produire une palette de 5 couleurs « perceptuelles » par set, moins biaisée par le volume de pièces. L’étape s’appuiera sur les mêmes filtres (couleurs 0033B2/05131D exclues, pièces techniques/structurelles ignorées), pondérera les couleurs par parts relatives hors minifigs, appliquera un tri perceptuel et une sélection diversifiée pour refléter l’esthétique plutôt que le poids en pièces. La version volumique (`figures/step27/set_color_swatches.png`) reste disponible en attendant la finalisation de cette étape. diff --git a/lib/plots/colors_grid.py b/lib/plots/colors_grid.py index 919dceb..1bb4ade 100644 --- a/lib/plots/colors_grid.py +++ b/lib/plots/colors_grid.py @@ -9,7 +9,7 @@ from matplotlib.lines import Line2D from lib.filesystem import ensure_parent_dir from lib.color_sort import lab_sort_key, sort_hex_colors_lab -from lib.rebrickable.color_ignores import is_ignored_color_rgb +from lib.rebrickable.color_ignores import is_ignored_color_rgb, is_ignored_part_category from lib.rebrickable.parts_inventory import normalize_boolean from lib.rebrickable.stats import read_rows @@ -21,18 +21,38 @@ def sort_colors_perceptually(colors: Iterable[dict]) -> List[dict]: return sorted(colors, key=lambda color: index_map[color["color_rgb"]]) -def load_used_colors(parts_path: Path, colors_path: Path, minifig_only: bool = False) -> List[dict]: +def load_part_categories(parts_catalog_path: Path) -> Dict[str, str]: + """Indexe les catégories par part_num.""" + categories: Dict[str, str] = {} + with parts_catalog_path.open() as catalog_file: + import csv + + reader = csv.DictReader(catalog_file) + for row in reader: + categories[row["part_num"]] = row["part_cat_id"] + return categories + + +def load_used_colors( + parts_path: Path, + colors_path: Path, + parts_catalog_path: Path, + minifig_only: bool = False, +) -> List[dict]: """Charge les couleurs utilisées (hors rechanges) et leurs quantités totales. Si minifig_only est vrai, ne conserve que les pièces marquées is_minifig_part=true. Sinon, exclut les pièces de minifig. """ rows = read_rows(parts_path) + categories = load_part_categories(parts_catalog_path) colors_lookup = {(row["rgb"], normalize_boolean(row["is_trans"])): row["name"] for row in read_rows(colors_path)} totals: Dict[Tuple[str, str], int] = {} for row in rows: if is_ignored_color_rgb(row["color_rgb"]): continue + if is_ignored_part_category(categories[row["part_num"]]): + continue if minifig_only and row.get("is_minifig_part") != "true": continue if not minifig_only and row.get("is_minifig_part") == "true": @@ -85,11 +105,12 @@ def build_background(width: float, height: float, resolution: int = 600) -> np.n def plot_colors_grid( parts_path: Path, colors_path: Path, + parts_catalog_path: Path, destination_path: Path, minifig_only: bool = False, ) -> None: """Dessine une grille artistique des couleurs utilisées.""" - colors = load_used_colors(parts_path, colors_path, minifig_only=minifig_only) + colors = load_used_colors(parts_path, colors_path, parts_catalog_path, minifig_only=minifig_only) positions = build_hex_positions(len(colors)) x_values = [x for x, _ in positions] y_values = [y for _, y in positions] diff --git a/lib/rebrickable/color_ignores.py b/lib/rebrickable/color_ignores.py index 8cdbf26..96fe85c 100644 --- a/lib/rebrickable/color_ignores.py +++ b/lib/rebrickable/color_ignores.py @@ -1,8 +1,54 @@ -"""Couleurs à exclure des palettes.""" +"""Couleurs et catégories de pièces à exclure des palettes.""" + +import csv +from pathlib import Path +from typing import Set + IGNORED_COLOR_RGB = {"0033b2", "05131d"} +IGNORED_PART_CATEGORY_IDS = { + "1", # Baseplates + "8", # Technic Bricks + "12", # Technic Connectors + "17", # Gear Parts + "18", # Hinges, Arms and Turntables + "22", # Pneumatics + "23", # Panels + "25", # Technic Steering, Suspension and Engine + "26", # Technic Special + "29", # Wheels and Tyres + "30", # Tubes and Hoses + "31", # String, Bands and Reels + "34", # Supports, Girders and Cranes + "40", # Technic Panels + "43", # Znap + "44", # Mechanical + "45", # Electronics + "46", # Technic Axles + "51", # Technic Beams + "52", # Technic Gears + "53", # Technic Pins + "54", # Technic Bushes + "55", # Technic Beams Special +} def is_ignored_color_rgb(rgb: str) -> bool: """Retourne vrai si le code couleur doit être ignoré.""" return rgb.strip().lower() in IGNORED_COLOR_RGB + + +def is_ignored_part_category(part_cat_id: str) -> bool: + """Retourne vrai si la catégorie de pièce est exclue.""" + return part_cat_id.strip() in IGNORED_PART_CATEGORY_IDS + + +def load_ignored_part_numbers(parts_catalog_path: Path) -> Set[str]: + """Charge les références de pièces dont la catégorie est exclue.""" + ignored: Set[str] = set() + with parts_catalog_path.open() as parts_file: + reader = csv.DictReader(parts_file) + for row in reader: + if is_ignored_part_category(row["part_cat_id"]): + ignored.add(row["part_num"]) + return ignored diff --git a/lib/rebrickable/colors_by_set.py b/lib/rebrickable/colors_by_set.py index 6b97139..b88f7a9 100644 --- a/lib/rebrickable/colors_by_set.py +++ b/lib/rebrickable/colors_by_set.py @@ -25,10 +25,17 @@ def build_colors_lookup(colors_path: Path) -> Dict[Tuple[str, str], str]: return colors -def aggregate_colors_by_set(parts: Iterable[dict], colors_lookup: Dict[Tuple[str, str], str]) -> List[dict]: +def aggregate_colors_by_set( + parts: Iterable[dict], + colors_lookup: Dict[Tuple[str, str], str], + ignored_parts: set[str] | None = None, +) -> List[dict]: """Agrège les quantités par set et par couleur.""" + ignored = ignored_parts or set() totals: Dict[Tuple[str, str, str, str, str], dict] = {} for row in parts: + if row["part_num"] in ignored: + continue if is_ignored_color_rgb(row["color_rgb"]): continue key = (row["set_num"], row["set_id"], row["year"], row["color_rgb"], row["is_translucent"]) diff --git a/scripts/build_colors_by_set.py b/scripts/build_colors_by_set.py index afabe37..80a1690 100644 --- a/scripts/build_colors_by_set.py +++ b/scripts/build_colors_by_set.py @@ -8,10 +8,12 @@ from lib.rebrickable.colors_by_set import ( load_parts, write_colors_by_set, ) +from lib.rebrickable.color_ignores import load_ignored_part_numbers PARTS_PATH = Path("data/intermediate/parts_filtered.csv") COLORS_PATH = Path("data/raw/colors.csv") +PARTS_CATALOG_PATH = Path("data/raw/parts.csv") DESTINATION_PATH = Path("data/intermediate/colors_by_set.csv") @@ -19,7 +21,8 @@ def main() -> None: """Génère colors_by_set.csv depuis parts_filtered.csv.""" parts = load_parts(PARTS_PATH) colors_lookup = build_colors_lookup(COLORS_PATH) - aggregated = aggregate_colors_by_set(parts, colors_lookup) + ignored_parts = load_ignored_part_numbers(PARTS_CATALOG_PATH) + aggregated = aggregate_colors_by_set(parts, colors_lookup, ignored_parts=ignored_parts) write_colors_by_set(DESTINATION_PATH, aggregated) diff --git a/scripts/plot_colors_grid.py b/scripts/plot_colors_grid.py index 343f2bb..1f0342e 100644 --- a/scripts/plot_colors_grid.py +++ b/scripts/plot_colors_grid.py @@ -7,14 +7,15 @@ from lib.plots.colors_grid import plot_colors_grid PARTS_PATH = Path("data/intermediate/parts_filtered.csv") COLORS_PATH = Path("data/raw/colors.csv") +PARTS_CATALOG_PATH = Path("data/raw/parts.csv") DESTINATION_PATH = Path("figures/step12/colors_grid.png") MINIFIG_DESTINATION_PATH = Path("figures/step12/colors_grid_minifigs.png") def main() -> None: """Construit les visuels des palettes de couleurs utilisées.""" - plot_colors_grid(PARTS_PATH, COLORS_PATH, DESTINATION_PATH, minifig_only=False) - plot_colors_grid(PARTS_PATH, COLORS_PATH, MINIFIG_DESTINATION_PATH, minifig_only=True) + plot_colors_grid(PARTS_PATH, COLORS_PATH, PARTS_CATALOG_PATH, DESTINATION_PATH, minifig_only=False) + plot_colors_grid(PARTS_PATH, COLORS_PATH, PARTS_CATALOG_PATH, MINIFIG_DESTINATION_PATH, minifig_only=True) if __name__ == "__main__": diff --git a/tests/test_colors_grid_plot.py b/tests/test_colors_grid_plot.py index 6da1bd6..2b8c299 100644 --- a/tests/test_colors_grid_plot.py +++ b/tests/test_colors_grid_plot.py @@ -33,6 +33,7 @@ def test_plot_colors_grid(tmp_path: Path) -> None: """Produit un fichier image avec les couleurs utilisées.""" parts_path = tmp_path / "parts_filtered.csv" colors_path = tmp_path / "colors.csv" + parts_catalog_path = tmp_path / "parts.csv" destination_path = tmp_path / "colors_grid.png" write_csv( @@ -45,6 +46,16 @@ def test_plot_colors_grid(tmp_path: Path) -> None: ["3004", "0033B2", "false", "1000", "10", "false"], ], ) + write_csv( + parts_catalog_path, + ["part_num", "name", "part_cat_id"], + [ + ["3001", "Brick", "11"], + ["3002", "Pane", "23"], + ["3003", "Slope", "3"], + ["3004", "Technic", "8"], + ], + ) write_csv( colors_path, ["id", "name", "rgb", "is_trans", "num_parts", "num_sets", "y1", "y2"], @@ -52,13 +63,14 @@ def test_plot_colors_grid(tmp_path: Path) -> None: ["1", "White", "FFFFFF", "False", "0", "0", "0", "0"], ["2", "Black", "000000", "True", "0", "0", "0", "0"], ["3", "Red", "FF0000", "False", "0", "0", "0", "0"], + ["4", "Ignored Blue", "0033B2", "False", "0", "0", "0", "0"], ], ) - colors = load_used_colors(parts_path, colors_path) - assert len(colors) == 3 + colors = load_used_colors(parts_path, colors_path, parts_catalog_path) + assert len(colors) == 2 - plot_colors_grid(parts_path, colors_path, destination_path) + plot_colors_grid(parts_path, colors_path, parts_catalog_path, destination_path) assert destination_path.exists() assert destination_path.stat().st_size > 0