diff --git a/README.md b/README.md index 9be3988..e240bea 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ 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. ### Étape 13 : palette de couleurs par set diff --git a/lib/plots/colors_grid.py b/lib/plots/colors_grid.py index 2f1d43e..919dceb 100644 --- a/lib/plots/colors_grid.py +++ b/lib/plots/colors_grid.py @@ -9,6 +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.parts_inventory import normalize_boolean from lib.rebrickable.stats import read_rows @@ -30,6 +31,8 @@ def load_used_colors(parts_path: Path, colors_path: Path, minifig_only: bool = F 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 minifig_only and row.get("is_minifig_part") != "true": continue if not minifig_only and row.get("is_minifig_part") == "true": diff --git a/lib/rebrickable/color_ignores.py b/lib/rebrickable/color_ignores.py new file mode 100644 index 0000000..8cdbf26 --- /dev/null +++ b/lib/rebrickable/color_ignores.py @@ -0,0 +1,8 @@ +"""Couleurs à exclure des palettes.""" + +IGNORED_COLOR_RGB = {"0033b2", "05131d"} + + +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 diff --git a/lib/rebrickable/colors_by_set.py b/lib/rebrickable/colors_by_set.py index 28adbca..6b97139 100644 --- a/lib/rebrickable/colors_by_set.py +++ b/lib/rebrickable/colors_by_set.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Dict, Iterable, List, Tuple from lib.filesystem import ensure_parent_dir +from lib.rebrickable.color_ignores import is_ignored_color_rgb def load_parts(parts_path: Path) -> List[dict]: @@ -28,6 +29,8 @@ def aggregate_colors_by_set(parts: Iterable[dict], colors_lookup: Dict[Tuple[str """Agrège les quantités par set et par couleur.""" totals: Dict[Tuple[str, str, str, str, str], dict] = {} for row in parts: + if is_ignored_color_rgb(row["color_rgb"]): + continue key = (row["set_num"], row["set_id"], row["year"], row["color_rgb"], row["is_translucent"]) existing = totals.get(key) if existing is None: diff --git a/lib/rebrickable/minifig_heads.py b/lib/rebrickable/minifig_heads.py index 8e79957..f3af0f3 100644 --- a/lib/rebrickable/minifig_heads.py +++ b/lib/rebrickable/minifig_heads.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Dict, Iterable, List, Set, Tuple from lib.rebrickable.colors_by_set import build_colors_lookup +from lib.rebrickable.color_ignores import is_ignored_color_rgb from lib.rebrickable.stats import read_rows @@ -39,6 +40,8 @@ def aggregate_head_colors_by_set( continue if row["is_spare"] == "true": continue + if is_ignored_color_rgb(row["color_rgb"]): + continue key = (row["set_num"], row["set_id"], row["year"], row["color_rgb"]) existing = aggregates.get(key) if existing is None: diff --git a/lib/rebrickable/set_color_swatches.py b/lib/rebrickable/set_color_swatches.py index 6baf982..3ebbf6d 100644 --- a/lib/rebrickable/set_color_swatches.py +++ b/lib/rebrickable/set_color_swatches.py @@ -5,7 +5,10 @@ from collections import defaultdict from pathlib import Path from typing import Dict, Iterable, List, Sequence +import colorsys + from lib.filesystem import ensure_parent_dir +from lib.rebrickable.color_ignores import is_ignored_color_rgb from lib.rebrickable.stats import read_rows @@ -24,6 +27,39 @@ def load_sets_enriched(path: Path) -> Dict[str, dict]: return lookup +def parse_rgb_hex(value: str) -> tuple[float, float, float]: + """Parse un code hexadécimal (RRGGBB) en composantes 0-1.""" + normalized = value.strip() + if len(normalized) != 6: + return (0.0, 0.0, 0.0) + r = int(normalized[0:2], 16) / 255.0 + g = int(normalized[2:4], 16) / 255.0 + b = int(normalized[4:6], 16) / 255.0 + return (r, g, b) + + +def hue_bucket(degrees: float) -> int: + """Regroupe les teintes en grandes familles pour l'affichage.""" + if degrees >= 330 or degrees < 30: + return 0 # rouge + if degrees < 90: + return 1 # jaune / orangé + if degrees < 150: + return 2 # vert + if degrees < 270: + return 3 # bleu + return 4 # violet + + +def color_display_key(row: dict) -> tuple[float, int, float, str]: + """Clé de tri visuelle : teinte regroupée d'abord, puis luminosité.""" + r, g, b = parse_rgb_hex(row["color_rgb"]) + h, _s, v = colorsys.rgb_to_hsv(r, g, b) + degrees = h * 360.0 + bucket = hue_bucket(degrees) + return (bucket, degrees, v, row["color_name"]) + + def build_top_colors_by_set(rows: Iterable[dict], sets_lookup: Dict[str, dict], top_n: int = 5) -> List[dict]: """Sélectionne les top couleurs hors minifigs pour chaque set.""" colors_by_set: Dict[str, List[dict]] = defaultdict(list) @@ -31,6 +67,8 @@ def build_top_colors_by_set(rows: Iterable[dict], sets_lookup: Dict[str, dict], quantity = int(row["quantity_non_minifig"]) if quantity <= 0: continue + if is_ignored_color_rgb(row["color_rgb"]): + continue set_num = row["set_num"] set_meta = sets_lookup.get(set_num) if set_meta is None: @@ -49,7 +87,9 @@ def build_top_colors_by_set(rows: Iterable[dict], sets_lookup: Dict[str, dict], results: List[dict] = [] for set_num, color_rows in colors_by_set.items(): sorted_rows = sorted(color_rows, key=lambda r: (-r["quantity"], r["color_name"])) - for rank, color_row in enumerate(sorted_rows[:top_n], start=1): + selected = sorted_rows[:top_n] + ordered = sorted(selected, key=color_display_key) + for rank, color_row in enumerate(ordered, start=1): results.append( { "set_num": color_row["set_num"], @@ -62,7 +102,7 @@ def build_top_colors_by_set(rows: Iterable[dict], sets_lookup: Dict[str, dict], "quantity_non_minifig": str(color_row["quantity"]), } ) - results.sort(key=lambda r: (int(r["year"]), r["name"], r["set_num"], int(r["rank"]))) + results.sort(key=lambda r: (int(r["year"]), r["set_num"], r["name"], int(r["rank"]))) return results diff --git a/tests/test_colors_by_set.py b/tests/test_colors_by_set.py index 9311d9d..3858a60 100644 --- a/tests/test_colors_by_set.py +++ b/tests/test_colors_by_set.py @@ -42,6 +42,7 @@ def test_aggregate_colors_by_set(tmp_path: Path) -> None: ["3002", "FFFFFF", "false", "1000-1", "1000", "2020", "1", "true", "false"], ["3003", "000000", "true", "1000-1", "1000", "2020", "4", "false", "true"], ["4001", "FFFFFF", "false", "2000-1", "2000", "2021", "3", "false", "true"], + ["4002", "0033B2", "false", "2000-1", "2000", "2021", "5", "false", "false"], ], ) write_csv( @@ -50,6 +51,7 @@ def test_aggregate_colors_by_set(tmp_path: Path) -> None: [ ["1", "White", "FFFFFF", "False", "0", "0", "0", "0"], ["2", "Trans-Black", "000000", "True", "0", "0", "0", "0"], + ["3", "Ignored Blue", "0033B2", "False", "0", "0", "0", "0"], ], ) diff --git a/tests/test_colors_grid_plot.py b/tests/test_colors_grid_plot.py index f813968..6da1bd6 100644 --- a/tests/test_colors_grid_plot.py +++ b/tests/test_colors_grid_plot.py @@ -42,6 +42,7 @@ def test_plot_colors_grid(tmp_path: Path) -> None: ["3001", "FFFFFF", "false", "1000", "2", "false"], ["3002", "000000", "true", "1000", "5", "false"], ["3003", "FF0000", "false", "1000", "1", "true"], + ["3004", "0033B2", "false", "1000", "10", "false"], ], ) write_csv( diff --git a/tests/test_minifig_heads.py b/tests/test_minifig_heads.py index 15ac236..0c8bc45 100644 --- a/tests/test_minifig_heads.py +++ b/tests/test_minifig_heads.py @@ -49,6 +49,7 @@ def test_extract_minifig_heads_and_colors(tmp_path: Path) -> None: ["3001", "FFFFFF", "false", "1000-1", "1000", "2020", "2", "false", "false"], ["3626b", "FFE1BD", "false", "2000-1", "2000", "2021", "2", "false", "true"], ["3626b", "FFE1BD", "false", "2000-1", "2000", "2021", "1", "true", "true"], + ["3626b", "0033B2", "false", "2000-1", "2000", "2021", "1", "false", "true"], ], ) write_csv( @@ -67,6 +68,7 @@ def test_extract_minifig_heads_and_colors(tmp_path: Path) -> None: ["1", "Light Flesh", "FFE1BD", "False", "0", "0", "0", "0"], ["2", "Medium Dark Flesh", "E7B68F", "False", "0", "0", "0", "0"], ["3", "White", "FFFFFF", "False", "0", "0", "0", "0"], + ["4", "Ignored Blue", "0033B2", "False", "0", "0", "0", "0"], ], ) diff --git a/tests/test_set_color_swatches.py b/tests/test_set_color_swatches.py index 2ae1ce9..d5fabe6 100644 --- a/tests/test_set_color_swatches.py +++ b/tests/test_set_color_swatches.py @@ -22,6 +22,7 @@ def test_build_top_colors_by_set_selects_top5_non_minifig(tmp_path: Path) -> Non "123-1,123,2020,444444,false,Green,2,2,0,2\n" "123-1,123,2020,555555,false,Yellow,1,1,0,1\n" "123-1,123,2020,666666,false,Pink,1,1,0,1\n" + "123-1,123,2020,0033B2,false,Ignored Blue,50,50,0,50\n" "124-1,124,2021,aaaaaa,false,Gray,4,4,4,0\n", ) sets_path = tmp_path / "sets_enriched.csv"