diff --git a/crop_borders.py b/crop_borders.py new file mode 100755 index 0000000..1365171 --- /dev/null +++ b/crop_borders.py @@ -0,0 +1,157 @@ +#!/usr/bin/env -S uv run --script +# /// script +# dependencies = ["Pillow"] +# /// +import argparse +from functools import cache +import logging +from pathlib import Path +import sys +from typing import Tuple + +from PIL import Image, ImageChops, ImageStat + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + p.add_argument("paths", nargs="+", type=Path, help="Image paths to crop") + p.add_argument( + "--outdir", + "-o", + type=Path, + default=None, + help="Output directory. If omitted, saves as .borderless. next to original.", + ) + p.add_argument("--tolerance", "-t", type=int, default=10, help="Color tolerance (0-255) for border detection") + p.add_argument("--min-border", type=int, default=1, help="Minimum border thickness to consider cropping") + return p.parse_args() + + +def is_near_color(pixel, color, tol: int) -> bool: + return all(abs(int(pixel[i]) - int(color[i])) <= tol for i in range(len(color))) + + +def dominant_edge_color(im: Image.Image) -> Tuple[int, int, int]: + # sample edges and return median color (RGB) + w, h = im.size + samples = [] + # take 1-pixel wide strips from each edge + left = im.crop((0, 0, 1, h)) + right = im.crop((w - 1, 0, w, h)) + top = im.crop((0, 0, w, 1)) + bottom = im.crop((0, h - 1, w, h)) + for region in (left, right, top, bottom): + stat = ImageStat.Stat(region) + # stat.mean may have 1 (L) or 3 (RGB) channels + mean = stat.mean + if len(mean) == 1: + samples.append((int(mean[0]), int(mean[0]), int(mean[0]))) + else: + samples.append(tuple(int(x) for x in mean[:3])) + # return median of samples per channel + channels = list(zip(*samples)) + med = tuple(int(sorted(ch)[len(ch) // 2]) for ch in channels) + return med + + +def find_crop_box(im: Image.Image, tol: int, min_border: int) -> Tuple[int, int, int, int]: + # Convert to RGB + rgb = im.convert("RGB") + w, h = rgb.size + edge_color = dominant_edge_color(rgb) + + def col_at(x, y): + return rgb.getpixel((x, y)) + + left = 0 + for x in range(w): + # check column x: all pixels near edge_color? + col_pixels = [col_at(x, y) for y in range(h)] + if all(is_near_color(px, edge_color, tol) for px in col_pixels): + left = x + 1 + continue + break + + right = w + for x in range(w - 1, -1, -1): + col_pixels = [col_at(x, y) for y in range(h)] + if all(is_near_color(px, edge_color, tol) for px in col_pixels): + right = x + continue + break + + top = 0 + for y in range(h): + row_pixels = [col_at(x, y) for x in range(w)] + if all(is_near_color(px, edge_color, tol) for px in row_pixels): + top = y + 1 + continue + break + + bottom = h + for y in range(h - 1, -1, -1): + row_pixels = [col_at(x, y) for x in range(w)] + if all(is_near_color(px, edge_color, tol) for px in row_pixels): + bottom = y + continue + break + + # enforce minimum border threshold + if left < min_border: + left = 0 + if (w - right) < min_border: + right = w + if top < min_border: + top = 0 + if (h - bottom) < min_border: + bottom = h + + # ensure valid box + if left >= right or top >= bottom: + return 0, 0, w, h + return left, top, right, bottom + + +def crop_image(path: Path, target: Path, tolerance: int, min_border: int) -> Path: + im = Image.open(path) + box = find_crop_box(im, tol=tolerance, min_border=min_border) + if box == (0, 0, im.width, im.height): + logging.info(f"no border detected: {path}") + return path + cropped = im.crop(box) + cropped.save(target) + logging.info(f"wrote: {target} (cropped box={box})") + return target + + +@cache +def make_dir(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def main() -> None: + args = parse_args() + + for p in args.paths: + p: Path + if not p.exists(): + logging.error(f"not found: {p}") + continue + try: + # resolve target path here (simple outdir logic) + if args.outdir is None: + target = p.with_stem(f"{p.stem}.borderless") + else: + make_dir(args.outdir) + target = args.outdir / p.name + + crop_image(path=p, target=target, tolerance=args.tolerance, min_border=args.min_border) + except Exception as e: + logging.exception(f"failed to process {p}: {e}") + + +if __name__ == "__main__": + main()