|
5 | 5 | - PNG optimization with pngquant |
6 | 6 | - Branded og:image generation for social media |
7 | 7 | - Collage generation for spec overview pages |
| 8 | +- Before/after comparison images for update reviews |
8 | 9 |
|
9 | 10 | Usage as CLI: |
10 | 11 | python -m core.images thumbnail input.png output.png 400 |
11 | 12 | python -m core.images process input.png output.png thumb.png |
12 | 13 | python -m core.images brand input.png output.png "scatter-basic" "matplotlib" |
13 | 14 | python -m core.images collage output.png img1.png img2.png img3.png img4.png |
| 15 | + python -m core.images compare before.png after.png output.png [spec_id] [library] |
14 | 16 | """ |
15 | 17 |
|
16 | 18 | import logging |
@@ -150,6 +152,125 @@ def process_plot_image( |
150 | 152 | return result |
151 | 153 |
|
152 | 154 |
|
| 155 | +# ============================================================================= |
| 156 | +# Before/After Comparison Images |
| 157 | +# ============================================================================= |
| 158 | + |
| 159 | +# Comparison image dimensions |
| 160 | +COMPARE_WIDTH = 2400 |
| 161 | +COMPARE_HEIGHT = 800 |
| 162 | +COMPARE_MARGIN = 30 |
| 163 | +COMPARE_GAP = 30 |
| 164 | +COMPARE_HEADER_HEIGHT = 50 |
| 165 | +COMPARE_LABEL_HEIGHT = 40 |
| 166 | + |
| 167 | + |
| 168 | +def create_comparison_image( |
| 169 | + before_path: str | Path | None, |
| 170 | + after_path: str | Path, |
| 171 | + output_path: str | Path, |
| 172 | + spec_id: str = "", |
| 173 | + library: str = "", |
| 174 | +) -> None: |
| 175 | + """Create a side-by-side before/after comparison image. |
| 176 | +
|
| 177 | + Layout: [margin] [BEFORE image] [gap] [AFTER image] [margin] |
| 178 | + with a header bar and labels above each image. |
| 179 | +
|
| 180 | + Args: |
| 181 | + before_path: Path to the "before" image, or None for new implementations. |
| 182 | + after_path: Path to the "after" image. |
| 183 | + output_path: Path where the comparison image will be saved. |
| 184 | + spec_id: Spec ID for the header label. |
| 185 | + library: Library name for the header label. |
| 186 | + """ |
| 187 | + # Canvas |
| 188 | + canvas = Image.new("RGB", (COMPARE_WIDTH, COMPARE_HEIGHT), PYPLOTS_BG) |
| 189 | + draw = ImageDraw.Draw(canvas) |
| 190 | + |
| 191 | + # Available space for each image panel |
| 192 | + panel_width = (COMPARE_WIDTH - 2 * COMPARE_MARGIN - COMPARE_GAP) // 2 |
| 193 | + panel_top = COMPARE_HEADER_HEIGHT + COMPARE_LABEL_HEIGHT |
| 194 | + panel_height = COMPARE_HEIGHT - panel_top - COMPARE_MARGIN |
| 195 | + |
| 196 | + # Header bar |
| 197 | + header_text = f"{library} · {spec_id}" if library and spec_id else library or spec_id |
| 198 | + if header_text: |
| 199 | + header_font = _get_font(28, weight=700) |
| 200 | + bbox = draw.textbbox((0, 0), header_text, font=header_font) |
| 201 | + text_w = bbox[2] - bbox[0] |
| 202 | + text_h = bbox[3] - bbox[1] |
| 203 | + draw.text( |
| 204 | + ((COMPARE_WIDTH - text_w) // 2, (COMPARE_HEADER_HEIGHT - text_h) // 2), |
| 205 | + header_text, |
| 206 | + fill=PYPLOTS_DARK, |
| 207 | + font=header_font, |
| 208 | + ) |
| 209 | + |
| 210 | + # Labels |
| 211 | + label_font = _get_font(22, weight=400) |
| 212 | + before_label = "BEFORE (current)" |
| 213 | + after_label = "AFTER (updated)" |
| 214 | + |
| 215 | + before_bbox = draw.textbbox((0, 0), before_label, font=label_font) |
| 216 | + before_label_w = before_bbox[2] - before_bbox[0] |
| 217 | + draw.text( |
| 218 | + (COMPARE_MARGIN + (panel_width - before_label_w) // 2, COMPARE_HEADER_HEIGHT + 8), |
| 219 | + before_label, |
| 220 | + fill="#6b7280", |
| 221 | + font=label_font, |
| 222 | + ) |
| 223 | + |
| 224 | + after_bbox = draw.textbbox((0, 0), after_label, font=label_font) |
| 225 | + after_label_w = after_bbox[2] - after_bbox[0] |
| 226 | + after_panel_x = COMPARE_MARGIN + panel_width + COMPARE_GAP |
| 227 | + draw.text( |
| 228 | + (after_panel_x + (panel_width - after_label_w) // 2, COMPARE_HEADER_HEIGHT + 8), |
| 229 | + after_label, |
| 230 | + fill="#6b7280", |
| 231 | + font=label_font, |
| 232 | + ) |
| 233 | + |
| 234 | + # Load and place BEFORE image (or placeholder) |
| 235 | + if before_path and Path(before_path).exists(): |
| 236 | + before_img = Image.open(before_path) |
| 237 | + if before_img.mode in ("RGBA", "P"): |
| 238 | + before_img = before_img.convert("RGB") |
| 239 | + before_img = _fit_image(before_img, panel_width, panel_height) |
| 240 | + bx = COMPARE_MARGIN + (panel_width - before_img.width) // 2 |
| 241 | + by = panel_top + (panel_height - before_img.height) // 2 |
| 242 | + canvas.paste(before_img, (bx, by)) |
| 243 | + else: |
| 244 | + # Gray placeholder |
| 245 | + placeholder_font = _get_font(20, weight=400) |
| 246 | + placeholder_text = "No previous version" |
| 247 | + pb = draw.textbbox((0, 0), placeholder_text, font=placeholder_font) |
| 248 | + pw = pb[2] - pb[0] |
| 249 | + ph = pb[3] - pb[1] |
| 250 | + px = COMPARE_MARGIN + (panel_width - pw) // 2 |
| 251 | + py = panel_top + (panel_height - ph) // 2 |
| 252 | + draw.text((px, py), placeholder_text, fill="#9ca3af", font=placeholder_font) |
| 253 | + |
| 254 | + # Load and place AFTER image |
| 255 | + after_img = Image.open(after_path) |
| 256 | + if after_img.mode in ("RGBA", "P"): |
| 257 | + after_img = after_img.convert("RGB") |
| 258 | + after_img = _fit_image(after_img, panel_width, panel_height) |
| 259 | + ax = after_panel_x + (panel_width - after_img.width) // 2 |
| 260 | + ay = panel_top + (panel_height - after_img.height) // 2 |
| 261 | + canvas.paste(after_img, (ax, ay)) |
| 262 | + |
| 263 | + canvas.save(output_path, "PNG", optimize=True) |
| 264 | + |
| 265 | + |
| 266 | +def _fit_image(img: Image.Image, max_width: int, max_height: int) -> Image.Image: |
| 267 | + """Scale an image to fit within max_width x max_height, preserving aspect ratio.""" |
| 268 | + scale = min(max_width / img.width, max_height / img.height, 1.0) |
| 269 | + if scale < 1.0: |
| 270 | + return img.resize((int(img.width * scale), int(img.height * scale)), Image.Resampling.LANCZOS) |
| 271 | + return img |
| 272 | + |
| 273 | + |
153 | 274 | # ============================================================================= |
154 | 275 | # OG Image Branding Functions |
155 | 276 | # ============================================================================= |
@@ -597,12 +718,14 @@ def print_usage() -> None: |
597 | 718 | print(" python -m core.images process <input> <output> [thumb]") |
598 | 719 | print(" python -m core.images brand <input> <output> [spec_id] [library]") |
599 | 720 | print(" python -m core.images collage <output> <img1> [img2] [img3] [img4]") |
| 721 | + print(" python -m core.images compare <before> <after> <output> [spec_id] [library]") |
600 | 722 | print("") |
601 | 723 | print("Examples:") |
602 | 724 | print(" python -m core.images thumbnail plot.png thumb.png 400") |
603 | 725 | print(" python -m core.images process plot.png out.png thumb.png") |
604 | 726 | print(" python -m core.images brand plot.png og.png scatter-basic matplotlib") |
605 | 727 | print(" python -m core.images collage og.png img1.png img2.png img3.png img4.png") |
| 728 | + print(" python -m core.images compare before.png after.png comparison.png area-basic matplotlib") |
606 | 729 | sys.exit(1) |
607 | 730 |
|
608 | 731 | if len(sys.argv) < 2: |
@@ -643,6 +766,18 @@ def print_usage() -> None: |
643 | 766 | create_og_collage(input_files, output_file) |
644 | 767 | print(f"Collage: {output_file} ({OG_WIDTH}x{OG_HEIGHT}px, {len(input_files)} images)") |
645 | 768 |
|
| 769 | + elif command == "compare": |
| 770 | + if len(sys.argv) < 5: |
| 771 | + print_usage() |
| 772 | + before_file = sys.argv[2] |
| 773 | + after_file = sys.argv[3] |
| 774 | + output_file = sys.argv[4] |
| 775 | + spec_id = sys.argv[5] if len(sys.argv) > 5 else "" |
| 776 | + library = sys.argv[6] if len(sys.argv) > 6 else "" |
| 777 | + before = None if before_file == "none" else before_file |
| 778 | + create_comparison_image(before, after_file, output_file, spec_id, library) |
| 779 | + print(f"Comparison: {output_file} ({COMPARE_WIDTH}x{COMPARE_HEIGHT}px)") |
| 780 | + |
646 | 781 | else: |
647 | 782 | print(f"Unknown command: {command}") |
648 | 783 | print_usage() |
0 commit comments