From a6e89bf6ef8b30273a3ec7fee43c8777b2612d7f Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Wed, 3 Dec 2025 17:37:01 +0100 Subject: [PATCH] =?UTF-8?q?Collage=20des=20pi=C3=A8ces=20imprim=C3=A9es=20?= =?UTF-8?q?exclusives=20et=20variante=20sans=20impressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/plots/part_rarity.py | 71 ++++++++++++++++++++++- lib/rebrickable/part_rarity.py | 5 +- scripts/plot_part_rarity.py | 5 +- tests/test_part_rarity.py | 8 ++- tests/test_part_rarity_printed_collage.py | 33 +++++++++++ 5 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/test_part_rarity_printed_collage.py diff --git a/lib/plots/part_rarity.py b/lib/plots/part_rarity.py index 81a110b..bf7e522 100644 --- a/lib/plots/part_rarity.py +++ b/lib/plots/part_rarity.py @@ -6,7 +6,7 @@ from typing import List import matplotlib.pyplot as plt from matplotlib.offsetbox import AnnotationBbox, OffsetImage -from PIL import Image +from PIL import Image, ImageDraw, ImageFont from lib.filesystem import ensure_parent_dir @@ -21,6 +21,22 @@ def load_part_rarity(path: Path) -> List[dict]: return rows +def select_printed_exclusive(rows: List[dict], resources_dir: Path) -> List[dict]: + """Filtre les pièces imprimées exclusives aux sets filtrés disposant d'une image locale.""" + filtered: List[dict] = [] + for row in rows: + if row.get("other_sets_quantity", "0") != "0": + continue + if "print" not in row["part_name"].lower(): + continue + image_path = resources_dir / row.get("sample_set_id", "") / "rare_parts" / f"{row['part_num']}.jpg" + if not image_path.exists(): + continue + filtered.append(row) + filtered.sort(key=lambda r: (r["part_name"], r["part_num"])) + return filtered + + def format_label(row: dict) -> str: """Formate l’étiquette de l’axe vertical.""" return f"{row['part_num']} — {row['part_name']}" @@ -84,3 +100,56 @@ def plot_part_rarity( ensure_parent_dir(destination_path) fig.savefig(destination_path, dpi=150) plt.close(fig) + + +def plot_printed_exclusive_parts( + path: Path, + destination_path: Path, + resources_dir: Path = Path("figures/rebrickable"), + columns: int = 5, +) -> None: + """Assemble les images des pièces imprimées exclusives aux sets filtrés.""" + rows = load_part_rarity(path) + selected = select_printed_exclusive(rows, resources_dir) + selected.sort(key=lambda r: (int(r.get("sample_set_year", "9999") or 9999), r["sample_set_num"], r["part_num"])) + if not selected: + return + images: List[Image.Image] = [] + labels: List[str] = [] + for row in selected: + image_path = resources_dir / row["sample_set_id"] / "rare_parts" / f"{row['part_num']}.jpg" + img = Image.open(image_path).convert("RGBA") + max_side = 180 + ratio = min(max_side / img.width, max_side / img.height, 1.0) + if ratio < 1.0: + img = img.resize((int(img.width * ratio), int(img.height * ratio))) + images.append(img) + labels.append(f"{row.get('sample_set_year', '')} • {row['sample_set_num']}") + + columns = max(1, columns) + rows_count = (len(images) + columns - 1) // columns + cell_width = 220 + font = ImageFont.load_default() + draw_temp = ImageDraw.Draw(Image.new("RGB", (10, 10))) + def measure(text: str) -> tuple[int, int]: + bbox = draw_temp.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0], bbox[3] - bbox[1] + text_height = max(measure(label)[1] for label in labels) + cell_height = 190 + text_height + 14 + width = columns * cell_width + height = rows_count * cell_height + canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255)) + draw = ImageDraw.Draw(canvas) + for index, (img, label) in enumerate(zip(images, labels)): + col = index % columns + row_idx = index // columns + x = col * cell_width + (cell_width - img.width) // 2 + y = row_idx * cell_height + 8 + canvas.paste(img, (x, y), img) + text_width, _ = measure(label) + text_x = col * cell_width + (cell_width - text_width) // 2 + text_y = y + img.height + 6 + draw.text((text_x, text_y), label, fill="#111111", font=font) + + ensure_parent_dir(destination_path) + canvas.convert("RGB").save(destination_path, "PNG") diff --git a/lib/rebrickable/part_rarity.py b/lib/rebrickable/part_rarity.py index 0ddd729..6044f41 100644 --- a/lib/rebrickable/part_rarity.py +++ b/lib/rebrickable/part_rarity.py @@ -126,7 +126,8 @@ def build_part_rarity( other_quantity = other_usage.get(part_num, 0) total_quantity = entry["quantity"] + other_quantity sample_set_num = sorted(entry["set_numbers"])[0] - sample_set_id = filtered_sets[sample_set_num]["set_id"] + sample_set_row = filtered_sets[sample_set_num] + sample_set_id = sample_set_row["set_id"] rows.append( { "part_num": part_num, @@ -135,6 +136,7 @@ def build_part_rarity( "part_category": categories[part["part_cat_id"]], "sample_set_num": sample_set_num, "sample_set_id": sample_set_id, + "sample_set_year": sample_set_row["year"], "filtered_quantity": str(entry["quantity"]), "filtered_set_count": str(len(entry["set_numbers"])), "other_sets_quantity": str(other_quantity), @@ -156,6 +158,7 @@ def write_part_rarity(destination_path: Path, rows: Sequence[dict]) -> None: "part_category", "sample_set_num", "sample_set_id", + "sample_set_year", "filtered_quantity", "filtered_set_count", "other_sets_quantity", diff --git a/scripts/plot_part_rarity.py b/scripts/plot_part_rarity.py index e5d4a01..60283cf 100644 --- a/scripts/plot_part_rarity.py +++ b/scripts/plot_part_rarity.py @@ -2,7 +2,7 @@ from pathlib import Path -from lib.plots.part_rarity import plot_part_rarity +from lib.plots.part_rarity import plot_part_rarity, plot_printed_exclusive_parts PART_RARITY_TOP_PATH = Path("data/intermediate/part_rarity_exclusive.csv") @@ -10,12 +10,15 @@ DESTINATION_PATH = Path("figures/step34/part_rarity.png") RESOURCES_DIR = Path("figures/rebrickable") PART_RARITY_NO_PRINT_PATH = Path("data/intermediate/part_rarity_exclusive_no_print.csv") DESTINATION_NO_PRINT = Path("figures/step34/part_rarity_no_print.png") +PART_RARITY_FULL_PATH = Path("data/intermediate/part_rarity.csv") +DESTINATION_PRINTED_COLLAGE = Path("figures/step34/printed_exclusive_parts.png") def main() -> None: """Charge le top des pièces rares et produit le graphique illustré.""" plot_part_rarity(PART_RARITY_TOP_PATH, DESTINATION_PATH, resources_dir=RESOURCES_DIR) plot_part_rarity(PART_RARITY_NO_PRINT_PATH, DESTINATION_NO_PRINT, resources_dir=RESOURCES_DIR) + plot_printed_exclusive_parts(PART_RARITY_FULL_PATH, DESTINATION_PRINTED_COLLAGE, resources_dir=RESOURCES_DIR) if __name__ == "__main__": diff --git a/tests/test_part_rarity.py b/tests/test_part_rarity.py index 976c30e..c257851 100644 --- a/tests/test_part_rarity.py +++ b/tests/test_part_rarity.py @@ -117,6 +117,7 @@ def test_build_part_rarity_counts_spares_and_ignores_categories(tmp_path: Path) "part_category": "Bricks", "sample_set_num": "2000-1", "sample_set_id": "2000", + "sample_set_year": "2021", "filtered_quantity": "1", "filtered_set_count": "1", "other_sets_quantity": "0", @@ -130,6 +131,7 @@ def test_build_part_rarity_counts_spares_and_ignores_categories(tmp_path: Path) "part_category": "Bricks", "sample_set_num": "1000-1", "sample_set_id": "1000", + "sample_set_year": "2020", "filtered_quantity": "3", "filtered_set_count": "2", "other_sets_quantity": "3", @@ -143,6 +145,7 @@ def test_build_part_rarity_counts_spares_and_ignores_categories(tmp_path: Path) "part_category": "Large Buildable Figures", "sample_set_num": "2000-1", "sample_set_id": "2000", + "sample_set_year": "2021", "filtered_quantity": "2", "filtered_set_count": "1", "other_sets_quantity": "4", @@ -175,6 +178,7 @@ def test_write_part_rarity_outputs_csv(tmp_path: Path) -> None: "part_category": "Bricks", "sample_set_num": "123-1", "sample_set_id": "123", + "sample_set_year": "2020", "filtered_quantity": "3", "filtered_set_count": "2", "other_sets_quantity": "3", @@ -188,7 +192,7 @@ def test_write_part_rarity_outputs_csv(tmp_path: Path) -> None: assert destination.exists() content = destination.read_text().strip().splitlines() assert content[0] == ( - "part_num,part_name,part_cat_id,part_category,sample_set_num,sample_set_id,filtered_quantity,filtered_set_count," + "part_num,part_name,part_cat_id,part_category,sample_set_num,sample_set_id,sample_set_year,filtered_quantity,filtered_set_count," "other_sets_quantity,catalog_total_quantity,filtered_share" ) - assert content[1] == "p1,Brick 1x1,1,Bricks,123-1,123,3,2,3,6,0.5000" + assert content[1] == "p1,Brick 1x1,1,Bricks,123-1,123,2020,3,2,3,6,0.5000" diff --git a/tests/test_part_rarity_printed_collage.py b/tests/test_part_rarity_printed_collage.py new file mode 100644 index 0000000..4262a59 --- /dev/null +++ b/tests/test_part_rarity_printed_collage.py @@ -0,0 +1,33 @@ +"""Tests du collage des pièces imprimées exclusives.""" + +import matplotlib +from pathlib import Path +from PIL import Image + +from lib.plots.part_rarity import plot_printed_exclusive_parts + + +matplotlib.use("Agg") + + +def test_plot_printed_exclusive_parts(tmp_path: Path) -> None: + """Génère un collage des pièces imprimées exclusives avec images locales.""" + data_path = tmp_path / "part_rarity.csv" + resources_dir = tmp_path / "figures" / "rebrickable" + resources_dir.mkdir(parents=True) + (resources_dir / "1000" / "rare_parts").mkdir(parents=True) + (resources_dir / "2000" / "rare_parts").mkdir(parents=True) + Image.new("RGB", (60, 40), color=(255, 0, 0)).save(resources_dir / "1000" / "rare_parts" / "p1.jpg") + Image.new("RGB", (60, 40), color=(0, 255, 0)).save(resources_dir / "2000" / "rare_parts" / "p2.jpg") + data_path.write_text( + "part_num,part_name,part_cat_id,part_category,sample_set_num,sample_set_id,sample_set_year,filtered_quantity,filtered_set_count,other_sets_quantity,catalog_total_quantity,filtered_share\n" + "p1,Slope print,1,Bricks,1000-1,1000,2020,3,2,0,3,1.0000\n" + "p2,Tile print,1,Bricks,2000-1,2000,2021,2,1,0,2,1.0000\n" + "p3,Tile plain,1,Bricks,2000-1,2000,2021,2,1,0,2,1.0000\n" + ) + destination = tmp_path / "figures" / "step34" / "printed_exclusive_parts.png" + + plot_printed_exclusive_parts(data_path, destination, resources_dir=resources_dir, columns=2) + + assert destination.exists() + assert destination.stat().st_size > 0