fancycrop

Modified: February 14, 2026 7:58 PM Category: Coding Notes List: Projects Created: November 18, 2025 8:36 AM Master Type: Notes Hide: No Starred: No Status: Unassigned

#!/usr/bin/env python3
# FancyCrop.py — True minimal, background-agnostic, lossless cropper
# Fully PSD/TIFF-safe. Preserves EXIF/ICC/IPTC. Supports multicore.
#
# Logic:
#   - flatten (lossless)
#   - trim with fuzz sequence
#   - measure area reduction only
#   - choose the trial with MAX area removed
#   - classify confidence purely by area removed
#
# Confidence rules:
#   area_removed â‰Ĩ 5%   → high (overwrite original)
#   area_removed â‰Ĩ 1%   → mid  (overwrite original)
#   else                → low  (move to Cropped/)
#
# Now patched with:
#   ✔ secondary tighter trim
#   ✔ tunable extra-fuzz + extra-shave
#   ✔ museum_safe / full_crop toggle
#   ✔ boundary-protector (only for full_crop)
#   ✔ no other logic changes

import os
import sys
import subprocess
import tempfile
from datetime import datetime
import shutil
from multiprocessing import Pool, cpu_count
from PIL import Image

# ============================================================
#                    CONFIGURATION
# ============================================================

MODE = "full_crop"     # "museum_safe" or "full_crop"

FUZZ_SEQUENCE = ["20%", "30%", "35%", "40%"]

EXTENT_PCT = "120%"
SHAVE_PCT  = "12%"

HIGH_CONF_AREA = 0.05
MID_CONF_AREA  = 0.01

CROPPED_DIRNAME = "Cropped"
CACHE_DIR = os.path.join(tempfile.gettempdir(), "FancyCrop")
LOG_FILE = os.path.join(tempfile.gettempdir(), "FancyCrop.log")

VALID_EXTS = {".psd", ".tif", ".tiff"}

# Tighter trim pass
EXTRA_FUZZ  = "6%"
EXTRA_SHAVE = "2%"

# Full-crop boundary protector (raw pixel test)
BOUNDARY_PROTECT_MIN_LUMA = 20
BOUNDARY_PROTECT_SCAN_PX   = 40   # only checks first 40px in (L/R/T/B)

# ============================================================
# LOGGING
# ============================================================

def log(msg):
    stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{stamp}] {msg}"
    print(line)
    with open(LOG_FILE, "a") as f:
        f.write(line + "\n")

# ============================================================
# IM HELPERS
# ============================================================

def run(cmd):
    return subprocess.run(
        cmd, stdout=subprocess.PIPE,
        stderr=subprocess.PIPE, check=True
    ).stdout.decode("utf-8","replace")

def have(cmd):
    return subprocess.call([ "/usr/bin/which", cmd ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL ) == 0

def dims(path):
    try:
        out = run(["identify","-format","%[fx:w] %[fx:h]",f"{path}[0]"])
        w,h = out.strip().split()
        return int(float(w)), int(float(h))
    except:
        return 0,0

# ============================================================
# FLATTEN + TRIM
# ============================================================

def flatten_to_single_layer(src, dst):
    log(f"đŸĒ„ Flattening {src}")
    run([
        "magick", src,
        "-auto-orient",
        "-flatten",
        "-alpha","off",
        "-define","tiff:preserve-tags=exif,icc,iptc",
        dst
    ])

def trim_once(src, dst, fuzz):
    log(f"âœ‚ī¸  fuzz={fuzz}")
    run([
        "magick", src,
        "-gravity","center","-extent",EXTENT_PCT,
        "-shave",SHAVE_PCT,
        "-fuzz",fuzz,
        "-trim","+repage",
        "-define","tiff:preserve-tags=exif,icc,iptc",
        dst
    ])

# ============================================================
# BOUNDARY PROTECTOR — ONLY FOR full_crop
# ============================================================

def has_edge_information(path):
    """
    Detects *non-white / non-empty* pixels in the first ~40px
    of each side. This protects acetate text, notch codes,
    edge numbering, etc.
    """
    try:
        im = Image.open(path).convert("RGB")
        w,h = im.size
        px = im.load()

        # luminance helper
        def L(p): return 0.299*p[0] + 0.587*p[1] + 0.114*p[2]

        # scan strips along L/R/T/B
        for x in range(0, min(BOUNDARY_PROTECT_SCAN_PX, w)):
            for y in range(h):
                if L(px[x,y]) < BOUNDARY_PROTECT_MIN_LUMA:
                    return True

        for x in range(w - min(BOUNDARY_PROTECT_SCAN_PX,w), w):
            for y in range(h):
                if L(px[x,y]) < BOUNDARY_PROTECT_MIN_LUMA:
                    return True

        for y in range(0, min(BOUNDARY_PROTECT_SCAN_PX, h)):
            for x in range(w):
                if L(px[x,y]) < BOUNDARY_PROTECT_MIN_LUMA:
                    return True

        for y in range(h - min(BOUNDARY_PROTECT_SCAN_PX,h), h):
            for x in range(w):
                if L(px[x,y]) < BOUNDARY_PROTECT_MIN_LUMA:
                    return True

        return False
    except:
        return False

# ============================================================
# MAIN CROPPING LOGIC
# ============================================================

def process_file(path):
    ext = os.path.splitext(path)[1].lower()
    if ext not in VALID_EXTS: return

    log(f"📁 Processing: {path}")

    flat  = path + ".flat"  + ext
    trial = path + ".trim"  + ext

    try:
        # ---------------- FLATTEN ----------------
        flatten_to_single_layer(path, flat)
        w0,h0 = dims(flat)
        if not w0 or not h0:
            log("đŸšĢ unreadable dims")
            return

        orig_area = float(w0*h0)
        best_trial = None
        best_area_reduction = 0.0

        # ---------------- PRIMARY TRIM LOOP ----------------
        for fuzz in FUZZ_SEQUENCE:
            trim_once(flat, trial, fuzz)

            w1,h1 = dims(trial)
            if not w1 or not h1: continue

            ar = 1.0 - (float(w1*h1)/orig_area)
            log(f"    fuzz={fuzz} area_reduction={ar:.4f}")

            if ar > best_area_reduction:
                if best_trial and os.path.exists(best_trial):
                    os.remove(best_trial)
                best_area_reduction = ar
                best_trial = path + f".best_{fuzz.replace('%','p')}" + ext
                if os.path.exists(best_trial):
                    os.remove(best_trial)
                os.rename(trial, best_trial)
            else:
                if os.path.exists(trial):
                    os.remove(trial)

        if not best_trial:
            log("đŸšĢ No successful trim.")
            return

        # ---------------- CONFIDENCE ----------------
        ar = best_area_reduction
        log(f"  Final area reduction = {ar:.4f}")

        if ar >= HIGH_CONF_AREA:
            conf, action = "HIGH", "overwrite"
        elif ar >= MID_CONF_AREA:
            conf, action = "MID", "overwrite"
        else:
            conf, action = "LOW", "cropped_dir"

        log(f"  → final confidence = {conf}")

        # ---------------- SECONDARY TIGHT TRIM ----------------
        tighter = path + ".tighter" + ext
        if os.path.exists(tighter): os.remove(tighter)

        run([
            "magick", best_trial,
            "-fuzz",EXTRA_FUZZ,
            "-shave",EXTRA_SHAVE,
            "-trim","+repage",
            "-define","tiff:preserve-tags=exif,icc,iptc",
            tighter
        ])

        os.remove(best_trial)
        best_trial = tighter

        # ---------------- FULL_CROP MODE SAFETY ----------------
        if MODE == "full_crop":
            if has_edge_information(best_trial):
                log("âš ī¸ full_crop blocked: edge information detected → reverting to museum_safe")
                MODE = "museum_safe"

        # ---------------- APPLY FINAL DECISION ----------------
        if action == "overwrite":
            tmp = path + ".final" + ext
            if os.path.exists(tmp): os.remove(tmp)
            os.rename(best_trial, tmp)
            os.replace(tmp, path)
            log(f"💾 OVERWROTE original ({conf})")
        else:
            out_dir = os.path.join(os.path.dirname(path), CROPPED_DIRNAME)
            os.makedirs(out_dir, exist_ok=True)
            out_path = os.path.join(out_dir, os.path.basename(path))
            if os.path.exists(out_path): os.remove(out_path)
            os.rename(best_trial, out_path)
            log(f"âš ī¸ LOW CONF → moved to {out_path}")

    except Exception as e:
        log(f"❌ Error: {e}")

    finally:
        for p in (flat, trial):
            if os.path.exists(p):
                try: os.remove(p)
                except: pass

# ============================================================
# WALK + MULTICORE
# ============================================================

def walk_files(root):
    for dp, _, files in os.walk(root):
        for name in files:
            if os.path.splitext(name.lower())[1] in VALID_EXTS:
                yield os.path.join(dp, name)

def main():
    if not have("magick"):
        print("❌ ImageMagick not found", file=sys.stderr)
        sys.exit(1)

    if os.path.exists(CACHE_DIR):
        shutil.rmtree(CACHE_DIR)
    os.makedirs(CACHE_DIR, exist_ok=True)

    root = sys.argv[1] if len(sys.argv)>1 else "."
    files = list(walk_files(root))

    if not files:
        log("â„šī¸ No PSD/TIFF files found.")
        return

    workers = min(max(cpu_count()//2,1),4)
    log(f"â„šī¸ Using {workers} workers")

    try:
        if workers==1:
            for f in files: process_file(f)
        else:
            with Pool(workers) as pool:
                pool.map(process_file,files)
    finally:
        shutil.rmtree(CACHE_DIR, ignore_errors=True)

if __name__=="__main__":
    main()