"""マンガのコマの切り抜きを、吹き出しを途中で切らない位置に直す(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