Files
playground/crop_borders.py

158 lines
4.6 KiB
Python
Executable File

#!/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 <stem>.borderless.<ext> 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()