"""マンガのコマの切り抜きを、吹き出しを途中で切らない位置に直す（2026-10-24）
fit(comic_rgb, crop) -> 直した crop
1. 切り抜きの外側にあるコマの枠（黒い線）を探し、そこまでを動かせる範囲にする
2. 白い所の連結成分のうち、中に文字の粒が並ぶものを吹き出しとみなす
3. 切り抜きの辺にかかる吹き出しは、半分以上が中にあれば広げて入れ、少しだけなら狭めて外す
"""
import numpy as np
from scipy import ndimage

def _panel(gray, crop):
    """切り抜きの辺の近く（±45px）で、いちばん長く黒い行・列をコマの枠とみなす。見つからなければ切り抜きの辺"""
    H, W = gray.shape
    x0, y0, x1, y1 = crop
    dark = gray < 90
    def best(axis, at, a, b, outward):
        rng = range(max(0, at - 45), min((H if axis == 0 else W), at + 45))
        sc = [(dark[v, a:b].mean() if axis == 0 else dark[a:b, v].mean(), v) for v in rng]
        hits = [v for c, v in sc if c > 0.4]
        if not hits: return (at, False)
        # 2本（となりのコマの枠と、このコマの枠）あるときは、コマの内側の1本を使う
        return ((max(hits) if outward < 0 else min(hits)), True)
    top = best(0, y0, x0 + 20, x1 - 20, -1); bot = best(0, y1, x0 + 20, x1 - 20, 1)
    lef = best(1, x0, y0 + 20, y1 - 20, -1); rig = best(1, x1, y0 + 20, y1 - 20, 1)
    _panel.found = (lef[1], top[1], rig[1], bot[1])
    return lef[0], top[0], rig[0], bot[0]

def _closed(gray):
    """ページの外の余白とコマの枠の外側を黒で囲み、枠からはみ出して余白に開いた吹き出しを閉じる"""
    g = gray.copy(); H, W = g.shape
    dark = g < 90
    cols = np.where(dark.mean(0) > 0.35)[0]; rows = np.where(dark.mean(1) > 0.35)[0]
    if len(cols) and cols.min() < 60: g[:, max(0, cols.min() - 1):cols.min() + 2] = 0       # ページの端に近い枠だけ
    if len(cols) and cols.max() > W - 60: g[:, cols.max() - 1:min(W, cols.max() + 2)] = 0
    if len(rows) and rows.min() < 60: g[max(0, rows.min() - 1):rows.min() + 2, :] = 0
    if len(rows) and rows.max() > H - 60: g[rows.max() - 1:min(H, rows.max() + 2), :] = 0
    return g

def bubbles(gray, box, want_mask=False):
    L, T, R, B = box
    sub = gray[T:B, L:R]
    white = sub > 200
    lab, n = ndimage.label(white)
    if n == 0: return []
    sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1))
    bg = 1 + int(np.argmax(sizes))
    out = []
    for i, sl in enumerate(ndimage.find_objects(lab), 1):
        if i == bg or sl is None: continue
        h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
        area = sizes[i - 1]
        if area < 2500 or h < 40 or w < 40: continue
        reg = ndimage.binary_fill_holes(lab[sl] == i)
        if reg.sum() / (h * w) < 0.35: continue
        inner = reg & ~(lab[sl] == i)                   # 中にある黒い粒（文字）
        il, k = ndimage.label(inner)
        if k < 4: continue
        if inner.sum() / reg.sum() > 0.30: continue    # 黒っぽすぎる所（髪・服の模様）は除く
        objs = ndimage.find_objects(il)
        small = [o for o in objs if (o[0].stop - o[0].start) < 70 and (o[1].stop - o[1].start) < 70]
        big = [o for o in objs if (o[0].stop - o[0].start) >= 70 or (o[1].stop - o[1].start) >= 70]
        if len(small) < 6 or big: continue             # 文字は小さな粒が並ぶ。長い線（服のしわ・聴診器）があれば吹き出しではない
        if h > 520 or w > 760 or (w > 480 and h > 300): continue   # コマ全体を拾わない
        hs = sorted((o[0].stop - o[0].start) for o in small)
        if hs[len(hs) // 2] < 6: continue              # 点や網かけだけ
        cy = np.array([(o[0].start + o[0].stop) / 2 for o in small]); cx = np.array([(o[1].start + o[1].stop) / 2 for o in small])
        def rows(c):                                   # 中心がそろった粒の数（文字の行・列）
            c = np.sort(c); best = 1; j = 0
            for k in range(len(c)):
                while c[k] - c[j] > 12: j += 1
                best = max(best, k - j + 1)
            return best
        if max(rows(cy), rows(cx)) < 4: continue
        if want_mask:
            m = np.pad(reg, 8)                          # 吹き出しの中（白と字）。ふちの線は重ねるときに描き直す
            out.append(((L + sl[1].start - 8, T + sl[0].start - 8, L + sl[1].stop + 8, T + sl[0].stop + 8), m))
        else:
            out.append((L + sl[1].start, T + sl[0].start, L + sl[1].stop, T + sl[0].stop))
    return out

def fit(img, crop, pad=10):
    gray = np.asarray(img.convert("L")).astype(np.int16)
    box = _panel(gray, crop)
    x0, y0, x1, y1 = crop
    L, T, R, B = box
    fL, fT, fR, fB = _panel.found
    # 枠の線が見つかった辺は、線の外側にそろえる（線は切り抜きに入れ、上に描く枠と重ねて1本にする）
    x0 = L - 3 if fL else max(x0, L + 2); y0 = T - 3 if fT else max(y0, T + 2)
    x1 = R + 4 if fR else min(x1, R - 2); y1 = B + 4 if fB else min(y1, B - 2)
    L, T, R, B = L - 5, T - 5, R + 6, B + 6
    for _ in range(3):
        changed = False
        for bx0, by0, bx1, by1 in bubbles(gray, (L + 3, T + 3, R - 3, B - 3)):
            ix0, iy0, ix1, iy1 = max(x0, bx0), max(y0, by0), min(x1, bx1), min(y1, by1)
            if ix0 >= ix1 or iy0 >= iy1: continue
            if bx0 >= x0 and by0 >= y0 and bx1 <= x1 and by1 <= y1: continue
            frac = (ix1 - ix0) * (iy1 - iy0) / ((bx1 - bx0) * (by1 - by0))
            if frac >= 0.35:                            # 広げて入れる
                nx0, ny0, nx1, ny1 = min(x0, bx0 - pad), min(y0, by0 - pad), max(x1, bx1 + pad), max(y1, by1 + pad)
                x0, y0, x1, y1 = max(nx0, L + 2), max(ny0, T + 2), min(nx1, R - 2), min(ny1, B - 2)
            else:                                       # 少しだけかかる → その辺を狭めて外す
                cand = []
                if bx0 > x0: cand.append(("r", x1 - (bx0 - pad)))
                if bx1 < x1: cand.append(("l", (bx1 + pad) - x0))
                if by0 > y0: cand.append(("b", y1 - (by0 - pad)))
                if by1 < y1: cand.append(("t", (by1 + pad) - y0))
                if not cand: continue
                side, amt = min(cand, key=lambda c: c[1])
                if amt > 0.15 * ((x1 - x0) if side in "lr" else (y1 - y0)):   # 大きく削るくらいなら広げて入れる
                    nx0, ny0, nx1, ny1 = min(x0, bx0 - pad), min(y0, by0 - pad), max(x1, bx1 + pad), max(y1, by1 + pad)
                    x0, y0, x1, y1 = max(nx0, L + 2), max(ny0, T + 2), min(nx1, R - 2), min(ny1, B - 2)
                    changed = True; continue
                if side == "r": x1 = bx0 - pad
                if side == "l": x0 = bx1 + pad
                if side == "b": y1 = by0 - pad
                if side == "t": y0 = by1 + pad
            changed = True
        if not changed: break
    return (int(x0), int(y0), int(x1), int(y1))

def all_bubbles(img):
    gray = _closed(np.asarray(img.convert("L")).astype(np.int16))
    H, W = gray.shape
    return bubbles(gray, (0, 0, W, H))

def _clean_bubble(rgb, box, m, out_w=1.4, in_w=1.3, k=4):
    """吹き出しをきれいに切り出す: 中（白と字）はそのまま、ふちの線は形をなめらかにして描き直す。
    4倍の細かさで形と線を作ってから縮め、ふちのギザギザ（ガサガサ）を出さない"""
    from PIL import Image
    from scipy.ndimage import gaussian_filter, distance_transform_edt
    piece = np.asarray(rgb.crop(box).convert("RGB")).astype(np.float32)
    h, w = piece.shape[:2]
    m = m[:h, :w].astype(np.float32)
    big = np.kron(m, np.ones((k, k), np.float32))
    inner = gaussian_filter(big, 2.6 * k) > 0.5                  # 中の形をなめらかに
    d_out = distance_transform_edt(~inner) / k                   # 元の大きさでの、外側への距離
    d_in = distance_transform_edt(inner) / k
    ink_hi = ((~inner) & (d_out <= out_w)) | (inner & (d_in <= in_w))   # 線: ふちの外 out_w・内 in_w
    in_hi = inner & (d_in > in_w)
    def down(a): return a.reshape(h, k, w, k).mean((1, 3))
    ink_a, in_a = down(ink_hi.astype(np.float32)), down(in_hi.astype(np.float32))
    inkc = np.array([13, 13, 13], np.float32)
    a = np.clip(ink_a + in_a, 0, 1)
    col = (piece * in_a[..., None] + inkc * ink_a[..., None]) / np.maximum(a, 1e-6)[..., None]
    out = np.dstack([np.clip(col, 0, 255), a * 255])
    return Image.fromarray(out.astype(np.uint8), "RGBA")

def framed(img, crop, extra=(), pad=10, skip=()):
    """吹き出しを切らないコマの画像を返す。(RGBA の画像, 直した crop)
    コマの中で動かせるだけ crop を動かし、それでもコマの枠の外へはみ出す吹き出しは、形のまま上に重ねる。
    extra: 拾えなかった吹き出しの中の点 [(x, y), …]（その白い所を吹き出しとして足す）"""
    from PIL import Image, ImageDraw
    gray = _closed(np.asarray(img.convert("L")).astype(np.int16))
    H, W = gray.shape
    c = fit(img, crop, pad)
    bl = bubbles(gray, (0, 0, W, H), want_mask=True)
    if extra:
        lab, _ = ndimage.label(gray > 200)
        for x, y in extra:
            i = lab[y, x]
            if not i: continue
            sl = ndimage.find_objects((lab == i).astype(np.int32))[0]
            reg = ndimage.binary_fill_holes(lab[sl] == i)
            m = np.pad(reg, 8)
            bl.append(((sl[1].start - 8, sl[0].start - 8, sl[1].stop + 8, sl[0].stop + 8), m))
    x0, y0, x1, y1 = c
    over = []
    for (bx0, by0, bx1, by1), m in bl:
        ix0, iy0, ix1, iy1 = max(x0, bx0), max(y0, by0), min(x1, bx1), min(y1, by1)
        if ix0 >= ix1 or iy0 >= iy1: continue
        if bx0 >= x0 and by0 >= y0 and bx1 <= x1 and by1 <= y1: continue
        frac = (ix1 - ix0) * (iy1 - iy0) / ((bx1 - bx0) * (by1 - by0))
        mm = m[:by1 - by0, :bx1 - bx0]
        inside = mm[max(0, y0 - by0):max(0, y1 - by0), max(0, x0 - bx0):max(0, x1 - bx0)].sum()
        if any(bx0 <= x <= bx1 and by0 <= y <= by1 for x, y in skip): continue   # 台本で「のせない」と決めた吹き出し
        if inside / max(1, mm.sum()) >= 0.19:            # 吹き出しの2割近くが切り抜きに入っているものだけ、形のままのせる（隣のコマの吹き出しはのせない）
            over.append(((bx0, by0, bx1, by1), m))
    ux0 = min([x0] + [b[0][0] for b in over]); uy0 = min([y0] + [b[0][1] for b in over])
    ux1 = max([x1] + [b[0][2] for b in over]); uy1 = max([y1] + [b[0][3] for b in over])
    rgb = img.convert("RGB")
    can = Image.new("RGBA", (ux1 - ux0, uy1 - uy0), (0, 0, 0, 0))
    can.paste(rgb.crop(c), (x0 - ux0, y0 - uy0))
    ImageDraw.Draw(can).rectangle([x0 - ux0, y0 - uy0, x1 - ux0 - 1, y1 - uy0 - 1], outline=(13, 13, 13, 255), width=7)
    for (bx0, by0, bx1, by1), m in over:
        can.alpha_composite(_clean_bubble(rgb, (bx0, by0, bx1, by1), m), (bx0 - ux0, by0 - uy0))
    return can, c
