#!/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()