From ff2fa1819a4d855729da4338daa9946c39cf8169 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Wed, 3 Dec 2025 15:28:53 +0100 Subject: [PATCH] Ajouter la frise des top couleurs annuelles --- README.md | 1 + lib/plots/colors_timeline.py | 76 +++++++++++++++++++++++++++++- scripts/plot_colors_timeline.py | 3 ++ tests/test_colors_timeline_plot.py | 6 ++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 356d314..0548da4 100644 --- a/README.md +++ b/README.md @@ -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). +Une frise `figures/step15/colors_top5_swatches.png` montre, pour chaque année, les 5 couleurs les plus utilisées (pastilles verticales par année). 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 diff --git a/lib/plots/colors_timeline.py b/lib/plots/colors_timeline.py index cf6e95b..97eed24 100644 --- a/lib/plots/colors_timeline.py +++ b/lib/plots/colors_timeline.py @@ -1,7 +1,7 @@ """Visualisations temporelles des palettes de couleurs.""" from pathlib import Path -from typing import Dict, Iterable, List, Tuple +from typing import Dict, Iterable, List, Sequence, Tuple import matplotlib.pyplot as plt import numpy as np @@ -115,6 +115,80 @@ def build_heatmap_data(rows: Iterable[dict]) -> Tuple[List[int], List[str], np.n return years, labels, matrix, swatches +def build_top_colors_by_year(rows: Iterable[dict], limit: int = 5) -> List[dict]: + """Extrait les principales couleurs par année avec leur part relative.""" + totals_by_year: Dict[int, int] = {} + grouped: Dict[int, List[dict]] = {} + for row in rows: + year = int(row["year"]) + quantity = int(row["quantity_total"]) + totals_by_year[year] = totals_by_year.get(year, 0) + quantity + grouped.setdefault(year, []).append(row) + top_rows: List[dict] = [] + for year in sorted(grouped.keys()): + entries = grouped[year] + entries.sort(key=lambda r: (-int(r["quantity_total"]), r["color_name"], r["color_rgb"])) + total = totals_by_year[year] + for rank, entry in enumerate(entries[:limit]): + quantity = int(entry["quantity_total"]) + share = quantity / total if total > 0 else 0.0 + top_rows.append( + { + "year": year, + "rank": rank, + "color_name": entry["color_name"], + "color_rgb": entry["color_rgb"], + "is_translucent": entry["is_translucent"], + "quantity_total": quantity, + "share": share, + } + ) + return top_rows + + +def plot_top_colors_swatches( + matrix_path: Path, + destination_path: Path, + limit: int = 5, +) -> None: + """Affiche une frise des top couleurs par année (5 pastilles empilées par année).""" + rows = load_rows(matrix_path) + if not rows: + return + top_rows = build_top_colors_by_year(rows, limit=limit) + years = sorted({row["year"] for row in top_rows}) + year_positions = {year: idx for idx, year in enumerate(years)} + x_values = [year_positions[row["year"]] for row in top_rows] + y_values = [limit - row["rank"] - 1 for row in top_rows] + ordered_hex = sort_hex_colors_lab({row["color_rgb"] for row in top_rows}) + hex_rank = {hex_value: idx for idx, hex_value in enumerate(ordered_hex)} + sorted_rows = sorted( + top_rows, + key=lambda r: (r["year"], hex_rank[r["color_rgb"]], r["is_translucent"], r["color_name"]), + ) + x_values = [year_positions[row["year"]] for row in sorted_rows] + y_values = [sorted_rows[i]["rank"] * 0.6 for i in range(len(sorted_rows))] + colors = [f"#{row['color_rgb']}" for row in sorted_rows] + sizes = [620 for _ in sorted_rows] + edges = ["#f2f2f2" if row["is_translucent"] == "true" else "#111111" for row in sorted_rows] + + fig, ax = plt.subplots(figsize=(0.75 * len(years) + 3.5, 0.75 * limit + 0.9)) + ax.scatter(x_values, y_values, s=sizes, c=colors, edgecolors=edges, linewidths=1.05) + ax.set_xticks(list(year_positions.values())) + ax.set_xticklabels(years, rotation=45) + ax.set_yticks([]) + ax.set_ylabel("") + ax.set_xlim(-0.6, len(years) - 0.4) + ax.set_ylim(-0.6, limit - 0.5) + ax.set_title(f"Top {limit} couleurs par année") + ax.grid(axis="x", linestyle="--", alpha=0.25) + + ensure_parent_dir(destination_path) + fig.tight_layout() + fig.savefig(destination_path, dpi=160) + plt.close(fig) + + def plot_colors_heatmap( matrix_path: Path, destination_path: Path, diff --git a/scripts/plot_colors_timeline.py b/scripts/plot_colors_timeline.py index 5e2df81..1c228e6 100644 --- a/scripts/plot_colors_timeline.py +++ b/scripts/plot_colors_timeline.py @@ -3,6 +3,7 @@ from pathlib import Path from lib.plots.colors_timeline import plot_colors_heatmap, plot_translucent_share +from lib.plots.colors_timeline import plot_top_colors_swatches TIMELINE_PATH = Path("data/intermediate/colors_timeline.csv") @@ -12,6 +13,7 @@ TRANSLUCENT_DESTINATION = Path("figures/step15/colors_translucent_share.png") HEATMAP_LINEAR_DESTINATION = Path("figures/step15/colors_heatmap_linear.png") HEATMAP_LOG_DESTINATION = Path("figures/step15/colors_heatmap_log.png") HEATMAP_SHARE_DESTINATION = Path("figures/step15/colors_heatmap_share.png") +TOP_COLORS_DESTINATION = Path("figures/step15/colors_top5_swatches.png") def main() -> None: @@ -20,6 +22,7 @@ def main() -> None: plot_colors_heatmap(MATRIX_PATH, HEATMAP_LINEAR_DESTINATION, use_log_scale=False) plot_colors_heatmap(MATRIX_PATH, HEATMAP_LOG_DESTINATION, use_log_scale=True) plot_colors_heatmap(MATRIX_PATH, HEATMAP_SHARE_DESTINATION, normalize_by_year=True) + plot_top_colors_swatches(MATRIX_PATH, TOP_COLORS_DESTINATION, limit=5) if __name__ == "__main__": diff --git a/tests/test_colors_timeline_plot.py b/tests/test_colors_timeline_plot.py index faec18a..d9d0f2c 100644 --- a/tests/test_colors_timeline_plot.py +++ b/tests/test_colors_timeline_plot.py @@ -3,7 +3,7 @@ import matplotlib from pathlib import Path -from lib.plots.colors_timeline import plot_colors_heatmap, plot_translucent_share +from lib.plots.colors_timeline import plot_colors_heatmap, plot_top_colors_swatches, plot_translucent_share matplotlib.use("Agg") @@ -33,6 +33,7 @@ def test_plot_colors_heatmap(tmp_path: Path) -> None: destination_linear = tmp_path / "figures" / "step15" / "colors_heatmap_linear.png" destination_log = tmp_path / "figures" / "step15" / "colors_heatmap_log.png" destination_share = tmp_path / "figures" / "step15" / "colors_heatmap_share.png" + destination_top = tmp_path / "figures" / "step15" / "colors_top5_swatches.png" matrix_path.write_text( "year,color_rgb,is_translucent,color_name,quantity_total\n" "2020,AAAAAA,false,Gray,5\n" @@ -44,6 +45,7 @@ def test_plot_colors_heatmap(tmp_path: Path) -> None: plot_colors_heatmap(matrix_path, destination_linear, use_log_scale=False) plot_colors_heatmap(matrix_path, destination_log, use_log_scale=True) plot_colors_heatmap(matrix_path, destination_share, normalize_by_year=True) + plot_top_colors_swatches(matrix_path, destination_top, limit=2) assert destination_linear.exists() assert destination_linear.stat().st_size > 0 @@ -51,3 +53,5 @@ def test_plot_colors_heatmap(tmp_path: Path) -> None: assert destination_log.stat().st_size > 0 assert destination_share.exists() assert destination_share.stat().st_size > 0 + assert destination_top.exists() + assert destination_top.stat().st_size > 0