import cv2
import numpy as np
from PIL import Image, ImageDraw
from pathlib import Path
from multiprocessing import Pool, cpu_count
import time

INPUT_DIR  = Path(r"C:\allgifted\mathapi11v2\docs\past year questions\Old Images")
OUTPUT_DIR = Path(r"C:\allgifted\mathapi11v2\docs\past year questions\Only Images")

BORDER      = 3
PAD         = 36          # was 22 -- recover clipped axis labels on charts
D0ACAC      = (208, 172, 172)
MIN_AREA    = 600         # filters body text; keeps illustration shapes
HEADER_SKIP = 880         # only applied to tall full-page scans (see height gate)
HEADER_H_GATE = 1500      # below this height, skip no header (small per-question scans)
DILATE_K    = 22          # was 25 -- slightly tighter grouping, still holds charts together
MERGE_OVERLAP = 0.35      # min horizontal overlap to merge vertically-adjacent regions
MERGE_GAP   = 12          # max vertical gap to consider merging
INNER_FRAC  = 0.20        # margin trimmed from each side for density test (inner 60%)
MIN_DENSITY = 0.008       # drop crops whose inner region is <0.8% ink (hollow boxes)

def extract(image_path):
    pil = Image.open(image_path).convert("RGB")
    img = np.array(pil)
    h, w = img.shape[:2]

    gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
    _, binary = cv2.threshold(gray, 210, 255, cv2.THRESH_BINARY_INV)

    # Height-aware school-header blanking: only on tall full-page scans.
    skip = HEADER_SKIP if h > HEADER_H_GATE else 0
    if skip:
        binary[:skip, :] = 0

    # Blank yellow section-header bands
    R, G, B = img[:,:,0], img[:,:,1], img[:,:,2]
    yellow = (R.astype(int)>200) & (G.astype(int)>200) & (B.astype(int)<180)
    for y in np.where(yellow.mean(axis=1) > 0.2)[0]:
        binary[max(0,y-2):min(h,y+28), :] = 0

    # Keep large components; strip body text (small) and full-width thin rules
    # (answer blanks / separators). Rule strip is chart-safe: only near-full-width
    # hairlines are removed, not in-plot chart gridlines.
    n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(binary)
    illus = np.zeros_like(binary)
    rule_w = int(0.70 * w)
    for i in range(1, n_lbl):
        area = stats[i, cv2.CC_STAT_AREA]
        if area < MIN_AREA:
            continue
        cw = stats[i, cv2.CC_STAT_WIDTH]
        ch = stats[i, cv2.CC_STAT_HEIGHT]
        if ch <= 10 and cw >= rule_w:        # full-width thin rule / answer blank
            continue
        illus[labels == i] = 255

    kernel = np.ones((DILATE_K, DILATE_K), np.uint8)
    dilated = cv2.dilate(illus, kernel)

    contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    regions = []
    for c in contours:
        x, y, bw, bh = cv2.boundingRect(c)
        if bh < 40 or bw < 80 or bw * bh < 8000:
            continue
        if bh < 45 and bw > 400:             # thin horizontal separator band
            continue
        roi = illus[y:y+bh, x:x+bw]
        ys, xs = np.where(roi > 0)
        if len(xs) == 0:
            continue
        x1 = max(0, x + int(xs.min()) - PAD)
        y1 = max(0, y + int(ys.min()) - PAD)
        x2 = min(w,  x + int(xs.max()) + PAD)
        y2 = min(h,  y + int(ys.max()) + PAD)
        regions.append([x1, y1, x2, y2])

    regions.sort(key=lambda b: b[1])

    # Overlap-aware vertical merge: only merge stacked regions that actually
    # share horizontal extent (a split figure), NOT a figure + an offset
    # answer/working block sitting below it.
    def hoverlap(a, b):
        inter = max(0, min(a[2], b[2]) - max(a[0], b[0]))
        minw = max(1, min(a[2]-a[0], b[2]-b[0]))
        return inter / minw

    merged = []
    for b in regions:
        if (merged and b[1] <= merged[-1][3] + MERGE_GAP
                and hoverlap(merged[-1], b) >= MERGE_OVERLAP):
            merged[-1][0] = min(merged[-1][0], b[0])
            merged[-1][1] = min(merged[-1][1], b[1])
            merged[-1][2] = max(merged[-1][2], b[2])
            merged[-1][3] = max(merged[-1][3], b[3])
        else:
            merged.append(b[:])

    # Re-tighten + density filter (drop hollow boxes / near-empty frames)
    final = []
    for b in merged:
        roi = illus[b[1]:b[3], b[0]:b[2]]
        ys, xs = np.where(roi > 0)
        if len(xs) == 0:
            continue
        x1 = max(0, b[0] + int(xs.min()) - PAD)
        y1 = max(0, b[1] + int(ys.min()) - PAD)
        x2 = min(w,  b[0] + int(xs.max()) + PAD)
        y2 = min(h,  b[1] + int(ys.max()) + PAD)
        if (y2-y1) < 40 or (x2-x1) < 60:
            continue
        # interior ink-density test on inner 60%
        mx = int((x2-x1) * INNER_FRAC); my = int((y2-y1) * INNER_FRAC)
        inner = binary[y1+my:y2-my, x1+mx:x2-mx]
        if inner.size == 0 or (inner > 0).mean() < MIN_DENSITY:
            continue
        final.append((x1, y1, x2, y2))

    return pil, final

def process_one(img_path_str):
    cv2.setNumThreads(1)
    img_path = Path(img_path_str)
    try:
        pil, regions = extract(img_path)
    except Exception as e:
        return (img_path.name, -1, 0, str(e))
    stem = img_path.stem
    saved = 0
    for i, (x1, y1, x2, y2) in enumerate(regions, 1):
        crop = pil.crop((x1, y1, x2, y2))
        bw, bh = crop.size
        bordered = Image.new("RGB", (bw + BORDER*2, bh + BORDER*2), (255,255,255))
        draw = ImageDraw.Draw(bordered)
        draw.rectangle([0, 0, bw+BORDER*2-1, bh+BORDER*2-1], outline=D0ACAC, width=BORDER)
        bordered.paste(crop, (BORDER, BORDER))
        bordered.save(OUTPUT_DIR / f"{stem}_illus_{i:02d}.png")
        saved += 1
    return (img_path.name, len(regions), saved, None)

if __name__ == "__main__":
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    exts = {".png", ".jpg", ".jpeg", ".bmp"}
    files = sorted(str(f) for f in INPUT_DIR.iterdir() if f.suffix.lower() in exts)
    workers = max(1, min(cpu_count() - 2, len(files)))
    print(f"Found {len(files)} paper images. Using {workers} parallel workers.", flush=True)

    t0 = time.time()
    results = []
    with Pool(workers) as pool:
        for res in pool.imap_unordered(process_one, files):
            results.append(res)
            if res[3]:
                print(f"  ERROR {res[0]}: {res[3]}", flush=True)
    elapsed = time.time() - t0

    results.sort(key=lambda r: r[0])
    log_lines, total_saved, errors, zero = [], 0, 0, 0
    for name, nreg, saved, err in results:
        if err:
            log_lines.append(f"{name} | ERROR | {err}"); errors += 1
        else:
            log_lines.append(f"{name} | {nreg} illustrations | {saved} saved")
            total_saved += saved
            if saved == 0: zero += 1
    (OUTPUT_DIR / "_extraction_log.txt").write_text("\n".join(log_lines), encoding="utf-8")

    print(f"\nDONE in {elapsed:.1f}s", flush=True)
    print(f"  images processed : {len(results)}", flush=True)
    print(f"  crops saved      : {total_saved}", flush=True)
    print(f"  images w/ 0 crops: {zero}", flush=True)
    print(f"  errors           : {errors}", flush=True)
