From 640c6333f00f45d0aeb749b4de4ae077ea5cb900 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Mon, 1 Dec 2025 22:44:05 +0100 Subject: [PATCH] Ajoute les visualisations temporelles des palettes --- README.md | 7 +++ lib/plots/colors_timeline.py | 92 ++++++++++++++++++++++++++++++ scripts/plot_colors_timeline.py | 21 +++++++ tests/test_colors_timeline_plot.py | 43 ++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 lib/plots/colors_timeline.py create mode 100644 scripts/plot_colors_timeline.py create mode 100644 tests/test_colors_timeline_plot.py diff --git a/README.md b/README.md index c69b17d..98b2348 100644 --- a/README.md +++ b/README.md @@ -177,3 +177,10 @@ Le script agrège `data/intermediate/parts_filtered.csv` avec les libellés de c 2. `python -m scripts.compute_colors_timeline` Le script lit `data/intermediate/colors_by_set.csv` et produit deux agrégats : `data/intermediate/colors_timeline.csv` (statistiques annuelles : nombre de couleurs distinctes, nouvelles, perdues, part des translucides, top couleurs) et `data/intermediate/colors_year_color_matrix.csv` (quantités totales année × couleur) pour préparer heatmaps et analyses temporelles. + +### Étape 15 : visualiser l'évolution des palettes + +1. `source .venv/bin/activate` +2. `python -m scripts.plot_colors_timeline` + +Le script lit les deux agrégats de l'étape précédente et produit `figures/step14/colors_translucent_share.png` (part des pièces translucides par année et nombre de couleurs distinctes) ainsi que `figures/step14/colors_heatmap.png` (heatmap année × couleur basée sur les quantités totales en échelle log1p). diff --git a/lib/plots/colors_timeline.py b/lib/plots/colors_timeline.py new file mode 100644 index 0000000..6658f98 --- /dev/null +++ b/lib/plots/colors_timeline.py @@ -0,0 +1,92 @@ +"""Visualisations temporelles des palettes de couleurs.""" + +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + +import matplotlib.pyplot as plt +import numpy as np + +from lib.filesystem import ensure_parent_dir +from lib.rebrickable.stats import read_rows + + +def load_rows(path: Path) -> List[dict]: + """Charge un CSV simple en mémoire sous forme de dictionnaires.""" + return read_rows(path) + + +def plot_translucent_share(timeline_path: Path, destination_path: Path) -> None: + """Trace l'évolution de la part de pièces translucides et du nombre de couleurs.""" + rows = load_rows(timeline_path) + years = [int(row["year"]) for row in rows] + shares = [float(row["share_translucent"]) for row in rows] + distinct_counts = [int(row["colors_distinct"]) for row in rows] + + fig, ax = plt.subplots(figsize=(12, 5)) + ax.plot(years, shares, color="#1f77b4", marker="o", linewidth=2.2, label="Part des translucides") + ax.fill_between(years, shares, color="#1f77b4", alpha=0.15) + ax.set_ylabel("Part translucide") + ax.set_ylim(0, min(1.0, max(shares) * 1.1)) + ax.set_xlabel("Année") + ax.grid(True, linestyle="--", alpha=0.3) + ax2 = ax.twinx() + ax2.plot(years, distinct_counts, color="#ff7f0e", marker="s", linewidth=1.8, label="Couleurs distinctes") + ax2.set_ylabel("Nombre de couleurs distinctes") + handles = [ + plt.Line2D([0], [0], color="#1f77b4", marker="o", label="Part des translucides"), + plt.Line2D([0], [0], color="#ff7f0e", marker="s", label="Couleurs distinctes"), + ] + ax.legend(handles=handles, loc="upper left") + ax.set_title("Evolution des palettes : translucides vs. diversité des couleurs") + ax.set_xticks(years) + ax.tick_params(axis="x", labelrotation=45) + ensure_parent_dir(destination_path) + fig.tight_layout() + fig.savefig(destination_path, dpi=160) + plt.close(fig) + + +def build_heatmap_data(rows: Iterable[dict]) -> Tuple[List[int], List[str], np.ndarray]: + """Construit les vecteurs année, labels de couleur et matrice de quantités.""" + years = sorted({int(row["year"]) for row in rows}) + color_totals: Dict[Tuple[str, str, str], int] = {} + for row in rows: + key = (row["color_name"], row["color_rgb"], row["is_translucent"]) + color_totals[key] = color_totals.get(key, 0) + int(row["quantity_total"]) + sorted_colors = sorted( + color_totals.items(), + key=lambda item: (-item[1], item[0][0], item[0][1]), + ) + color_keys = [color for color, _ in sorted_colors] + labels = [f"{name} ({'trans' if is_trans == 'true' else 'opaque'})" for name, _, is_trans in color_keys] + matrix = np.zeros((len(color_keys), len(years)), dtype=float) + index_by_year = {year: idx for idx, year in enumerate(years)} + index_by_color = {color: idx for idx, color in enumerate(color_keys)} + for row in rows: + color_key = (row["color_name"], row["color_rgb"], row["is_translucent"]) + y_index = index_by_color[color_key] + x_index = index_by_year[int(row["year"])] + matrix[y_index, x_index] += int(row["quantity_total"]) + return years, labels, matrix + + +def plot_colors_heatmap(matrix_path: Path, destination_path: Path) -> None: + """Génère une heatmap année × couleur basée sur les quantités totales.""" + rows = load_rows(matrix_path) + years, labels, matrix = build_heatmap_data(rows) + values = np.log1p(matrix) + + fig, ax = plt.subplots(figsize=(14, max(6, len(labels) * 0.24))) + heatmap = ax.imshow(values, aspect="auto", cmap="magma", origin="lower") + ax.set_xticks(range(len(years))) + ax.set_xticklabels(years, rotation=45) + ax.set_yticks(range(len(labels))) + ax.set_yticklabels(labels) + ax.set_xlabel("Année") + ax.set_title("Intensité des couleurs par année (log1p des quantités)") + cbar = fig.colorbar(heatmap, ax=ax, shrink=0.82) + cbar.set_label("log1p(quantité totale)") + ensure_parent_dir(destination_path) + fig.tight_layout() + fig.savefig(destination_path, dpi=170) + plt.close(fig) diff --git a/scripts/plot_colors_timeline.py b/scripts/plot_colors_timeline.py new file mode 100644 index 0000000..dad829c --- /dev/null +++ b/scripts/plot_colors_timeline.py @@ -0,0 +1,21 @@ +"""Génère les visuels temporels des palettes de couleurs.""" + +from pathlib import Path + +from lib.plots.colors_timeline import plot_colors_heatmap, plot_translucent_share + + +TIMELINE_PATH = Path("data/intermediate/colors_timeline.csv") +MATRIX_PATH = Path("data/intermediate/colors_year_color_matrix.csv") +TRANSLUCENT_DESTINATION = Path("figures/step14/colors_translucent_share.png") +HEATMAP_DESTINATION = Path("figures/step14/colors_heatmap.png") + + +def main() -> None: + """Construit les visuels d'évolution annuelle des palettes.""" + plot_translucent_share(TIMELINE_PATH, TRANSLUCENT_DESTINATION) + plot_colors_heatmap(MATRIX_PATH, HEATMAP_DESTINATION) + + +if __name__ == "__main__": + main() diff --git a/tests/test_colors_timeline_plot.py b/tests/test_colors_timeline_plot.py new file mode 100644 index 0000000..39bcae8 --- /dev/null +++ b/tests/test_colors_timeline_plot.py @@ -0,0 +1,43 @@ +"""Tests des visualisations temporelles des couleurs.""" + +import matplotlib +from pathlib import Path + +from lib.plots.colors_timeline import plot_colors_heatmap, plot_translucent_share + + +matplotlib.use("Agg") + + +def test_plot_translucent_share(tmp_path: Path) -> None: + """Produit un graphique de part de translucides et diversité des couleurs.""" + timeline_path = tmp_path / "colors_timeline.csv" + destination = tmp_path / "figures" / "step14" / "colors_translucent_share.png" + timeline_path.write_text( + "year,colors_distinct,colors_new,colors_lost,share_translucent,total_quantity,top_colors\n" + "2020,2,2,0,0.25,100,Blue (60),Red (40)\n" + "2021,3,1,0,0.40,120,Blue (50),Trans-Black (48)\n" + ) + + plot_translucent_share(timeline_path, destination) + + assert destination.exists() + assert destination.stat().st_size > 0 + + +def test_plot_colors_heatmap(tmp_path: Path) -> None: + """Génère une heatmap année × couleur.""" + matrix_path = tmp_path / "colors_year_color_matrix.csv" + destination = tmp_path / "figures" / "step14" / "colors_heatmap.png" + matrix_path.write_text( + "year,color_rgb,is_translucent,color_name,quantity_total\n" + "2020,AAAAAA,false,Gray,5\n" + "2020,BBBBBB,true,Trans-Black,2\n" + "2021,AAAAAA,false,Gray,3\n" + "2021,CCCCCC,false,Blue,4\n" + ) + + plot_colors_heatmap(matrix_path, destination) + + assert destination.exists() + assert destination.stat().st_size > 0