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