← back to Majilite Onboard

scripts/crop_swatches.py

110 lines

#!/usr/bin/env python3
"""
Crop 160 Majilite product swatches out of 8 pre-rendered PDF pages.

Each page (page-02..page-09) is 1875x2487 px, near-black, holding a 4-col x 5-row
grid of 20 square-ish fabric swatches, each with a 2-line white text label centered
below it. We detect the swatch grid via brightness projection:

  - Column bands: sum brightness per column -> 4 wide high-mass bands (the columns),
    separated by black gutters.
  - Row bands: sum brightness per row -> thick bands (swatches, >200px tall) plus
    thin bands (label text, <90px). We keep only the thick bands = the 5 swatch rows.

Intersecting the 4 column bands x 5 swatch-row bands yields 20 boxes per page,
row-major (left->right, top->bottom). Page 2 -> DWMJ-000001..020, page 3 -> 021..040,
..., page 9 -> 141..160.

Output: public/images/DWMJ-000001.png .. DWMJ-000160.png
"""
import sys
import numpy as np
from PIL import Image
from pathlib import Path

PROJ = Path("/Users/macstudio3/Projects/majilite-onboard")
SRC = Path("/tmp/majilite")
OUT = PROJ / "public" / "images"

# --- tunable params (one set works for all 8 pages) ---
COL_THR_FRAC = 0.15   # column-projection threshold as fraction of max
ROW_THR_FRAC = 0.15   # row-projection threshold as fraction of max (0.15 keeps dark swatch rows)
MIN_COL_W    = 200    # a real column band is wide
MIN_ROW_H    = 200    # a real swatch row band is tall (labels are <90px)
TRIM         = 6      # px of black border to trim off each crop edge
EXPECT_COLS  = 4
EXPECT_ROWS  = 5


def bands(proj, thr):
    """Return list of (start,end) inclusive index ranges where proj>thr."""
    on = proj > thr
    out = []
    s = None
    for i, v in enumerate(on):
        if v and s is None:
            s = i
        elif not v and s is not None:
            out.append((s, i - 1))
            s = None
    if s is not None:
        out.append((s, len(on) - 1))
    return out


def detect_grid(gray):
    col = gray.sum(axis=0)
    row = gray.sum(axis=1)
    col_bands = [b for b in bands(col, col.max() * COL_THR_FRAC) if (b[1] - b[0]) >= MIN_COL_W]
    row_bands = [b for b in bands(row, row.max() * ROW_THR_FRAC) if (b[1] - b[0]) >= MIN_ROW_H]
    return col_bands, row_bands


def crop_page(page_num, first_sku):
    path = SRC / f"page-{page_num:02d}.png"
    img = Image.open(path).convert("RGB")
    gray = np.asarray(img.convert("L"), dtype=np.float64)
    col_bands, row_bands = detect_grid(gray)

    if len(col_bands) != EXPECT_COLS or len(row_bands) != EXPECT_ROWS:
        raise SystemExit(
            f"page {page_num}: expected {EXPECT_COLS}x{EXPECT_ROWS}, "
            f"got {len(col_bands)} cols / {len(row_bands)} rows\n"
            f"  cols={col_bands}\n  rows={row_bands}"
        )

    OUT.mkdir(parents=True, exist_ok=True)
    sku = first_sku
    saved = []
    for (r0, r1) in row_bands:            # top -> bottom
        for (c0, c1) in col_bands:        # left -> right (row-major)
            x0 = c0 + TRIM
            y0 = r0 + TRIM
            x1 = c1 - TRIM
            y1 = r1 - TRIM
            crop = img.crop((x0, y0, x1 + 1, y1 + 1))
            name = f"DWMJ-{sku:06d}.png"
            crop.save(OUT / name)
            saved.append((name, crop.size))
            sku += 1
    return saved


def main():
    pages = range(2, 10)  # page-02..page-09
    if len(sys.argv) > 1:
        pages = [int(p) for p in sys.argv[1:]]
    total = 0
    for page_num in pages:
        first_sku = (page_num - 2) * 20 + 1
        saved = crop_page(page_num, first_sku)
        total += len(saved)
        print(f"page {page_num:02d}: {len(saved)} swatches "
              f"-> DWMJ-{first_sku:06d}..DWMJ-{first_sku + len(saved) - 1:06d} "
              f"(size ~{saved[0][1]})")
    print(f"TOTAL: {total} images -> {OUT}")


if __name__ == "__main__":
    main()