← back to Wallco Ai

scripts/element_copy_heal.py

181 lines

#!/usr/bin/env python3
r"""
element_copy_heal (v2) — CRISP tile-seam heal that fixes ONLY the real physical
tile-WRAP edges, with NO averaging/blur band (the old heal_band_mid smudge that
Steve rejected 2026-06-12) and NO drawn discontinuity lines (the v1 half-roll
patch-copy sliced a visible line through the frog's face on root 2766).

DTD verdict C (2026-06-12, Claude+Codex unanimous): only the 4 physical wrap
boundaries (x=0<->W, y=0<->H) are true tile joins; x=W/2 / y=H/2 are arbitrary
interior sample lines that must NEVER be healed as seams (doing so cut the frog's
face in v1). Motif continuity ACROSS a wrap is provided by the neighbor repeat,
not by interior smoothing or synthetic cuts.

METHOD (low-color / engraving path — the common wallco case):
  1. Quantize the whole tile to its dominant palette -> a crisp label per pixel.
     We only WRITE a thin edge band; the interior keeps original RGB so stipple
     shading is preserved.
  2. top<->bottom wrap: for each COLUMN where the outermost top pixel and
     outermost bottom pixel carry DIFFERENT palette labels (a motif crosses the
     wrap), vote a single wrap label across both outer bands and snap the outer
     `band` px of BOTH edges on that column to that crisp palette color. Every
     written pixel is a crisp palette color (no averaged smudge); only the
     disagreeing columns are touched (no continuous drawn line); interior
     untouched.
  3. left<->right wrap: transpose.
  4. interior mids: skipped entirely.

Non-low-color designs fall back to a wrap-only crisp mirror on disagreeing
lines (still no interior, still no full-image blur).

Public entry point:
    heal_array(arr, boxes, *, two_tone=None, edge_band=12) -> healed uint8 array
`boxes` from seam-defect-boxes.py (used only to decide WHICH wrap edges need
work; interior boxes ignored).

NEVER imports make_seamless.py / force-edge-seamless.py (smear sources).
NEVER calls cv2.inpaint / diffusion (those blur). Pure crisp palette-reconcile.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
from collections import Counter

EDGE_BAND_DEFAULT = 12
LOWCOLOR_MAX = 6


def _rgb_to_lab(arr):
    rgb = arr.astype('float32') / 255.0
    def f(t):
        return np.where(t > 0.04045, ((t + 0.055) / 1.055) ** 2.4, t / 12.92)
    rl, gl, bl = f(rgb[..., 0]), f(rgb[..., 1]), f(rgb[..., 2])
    X = rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375
    Y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750
    Z = rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041
    def fl(t):
        d = 6 / 29
        return np.where(t > d ** 3, t ** (1 / 3), t / (3 * d * d) + 4 / 29)
    fx, fy, fz = fl(X / 0.95047), fl(Y / 1.0), fl(Z / 1.08883)
    return np.stack([116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)], axis=-1)


def dominant_palette(arr, k=8, min_pct=3.0):
    im = Image.fromarray(arr).convert('RGB')
    q = im.quantize(colors=k, method=Image.Quantize.MEDIANCUT,
                    dither=Image.Dither.NONE).convert('RGB')
    flat = np.asarray(q).reshape(-1, 3)
    cnt = Counter(map(tuple, flat))
    total = sum(cnt.values())
    return [c for c, n in cnt.most_common() if 100.0 * n / total >= min_pct]


def is_low_color(arr):
    return len(dominant_palette(arr)) <= LOWCOLOR_MAX


def _palette_labels(arr, palette):
    pal = np.array(palette, dtype='uint8')
    pal_lab = _rgb_to_lab(pal.reshape(1, -1, 3)).reshape(-1, 3)
    lab = _rgb_to_lab(arr).reshape(-1, 3)
    d = ((lab[:, None, :] - pal_lab[None, :, :]) ** 2).sum(-1)
    idx = d.argmin(1).reshape(arr.shape[:2])
    return idx, pal


def _reconcile_wrap(out, labels, pal, axis, band):
    """Make the two opposite wrap edges along axis carry the SAME crisp palette
    content where they currently disagree. axis='h': top<->bottom (write columns);
    axis='v': left<->right (write rows). Returns count of lines rewritten."""
    H, W = labels.shape
    n = 0
    if axis == 'h':
        disagree = labels[0, :] != labels[H - 1, :]
        top_band = labels[0:band, :]
        bot_band = labels[H - band:H, :]
        for x in np.where(disagree)[0]:
            votes = np.concatenate([top_band[:, x], bot_band[:, x]])
            wrap = int(np.bincount(votes).argmax())
            color = pal[wrap]
            out[0:band, x, :] = color
            out[H - band:H, x, :] = color
            n += 1
    else:
        disagree = labels[:, 0] != labels[:, W - 1]
        left_band = labels[:, 0:band]
        right_band = labels[:, W - band:W]
        for y in np.where(disagree)[0]:
            votes = np.concatenate([left_band[y, :], right_band[y, :]])
            wrap = int(np.bincount(votes).argmax())
            color = pal[wrap]
            out[y, 0:band, :] = color
            out[y, W - band:W, :] = color
            n += 1
    return n


def _wrap_mirror_disagree(out, arr, axis, band):
    H, W = arr.shape[:2]
    g = arr.astype('float32') @ np.array([0.299, 0.587, 0.114])
    n = 0
    if axis == 'h':
        disagree = np.abs(g[0, :] - g[H - 1, :]) > 24
        for x in np.where(disagree)[0]:
            src = arr[band, x, :]
            out[0:band, x, :] = src
            out[H - band:H, x, :] = src
            n += 1
    else:
        disagree = np.abs(g[:, 0] - g[:, W - 1]) > 24
        for y in np.where(disagree)[0]:
            src = arr[y, band, :]
            out[y, 0:band, :] = src
            out[y, W - band:W, :] = src
            n += 1
    return n


def heal_array(arr, boxes, *, two_tone=None, edge_band=EDGE_BAND_DEFAULT):
    """Heal ONLY the physical wrap edges flagged in boxes; interior mid boxes are
    ignored (motif edges, not seams). Returns a NEW uint8 array."""
    out = arr.copy()
    if two_tone is None:
        two_tone = is_low_color(arr)
    have_ledge = any(b['kind'] in ('left_edge', 'right_edge') for b in boxes)
    have_tedge = any(b['kind'] in ('top_edge', 'bottom_edge') for b in boxes)
    if two_tone:
        palette = dominant_palette(arr)
        labels, pal = _palette_labels(arr, palette)
        if have_tedge:
            _reconcile_wrap(out, labels, pal, 'h', edge_band)
        if have_ledge:
            _reconcile_wrap(out, labels, pal, 'v', edge_band)
    else:
        if have_tedge:
            _wrap_mirror_disagree(out, arr, 'h', edge_band)
        if have_ledge:
            _wrap_mirror_disagree(out, arr, 'v', edge_band)
    return out


if __name__ == '__main__':
    import argparse, json, sys
    from pathlib import Path
    sys.path.insert(0, str(Path(__file__).resolve().parent))
    seam = __import__('seam-defect-boxes')
    ap = argparse.ArgumentParser()
    ap.add_argument('--path', required=True)
    ap.add_argument('--out', required=True)
    ap.add_argument('--edge-band', type=int, default=EDGE_BAND_DEFAULT)
    a = ap.parse_args()
    src = np.asarray(Image.open(a.path).convert('RGB'))
    scan_b = seam.scan(src)
    healed = heal_array(src, scan_b['boxes'], edge_band=a.edge_band)
    Image.fromarray(healed).save(a.out)
    scan_a = seam.scan(healed)
    print(json.dumps({
        'before': scan_b['scores'], 'before_verdict': scan_b['verdict'],
        'after': scan_a['scores'], 'after_verdict': scan_a['verdict'],
        'two_tone': is_low_color(src), 'out': a.out,
    }))