feat(crop-borders): detect and crop uniform image borders
This commit is contained in:
Executable
+157
@@ -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 <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()
|
||||
Reference in New Issue
Block a user