""" 問題自動分割プロトタイプ v2. 連結成分ベースで ☑ だけを抽出する. 左端 x<200 の領域内で「サイズ ~30x30、矩形に近い」成分を ☑ とみなす. """ import os from typing import List, Optional, Tuple import numpy as np from PIL import Image import cv2 HERE = os.path.dirname(os.path.abspath(__file__)) SAMPLES_DIR = os.path.join(HERE, "samples") DEBUG_DIR = os.path.join(HERE, "debug") os.makedirs(DEBUG_DIR, exist_ok=True) def find_problem_boundaries_generic( page_rgb: np.ndarray, min_gap_rows: int = 10, blank_ratio: float = 0.03, dark_thr: int = -1, # -1 = 自動 (背景輝度から推定) ) -> List[int]: """☑ がないページ向けの汎用境界検出 ([8][9]形式対応版). 問題と問題の間にある「水平方向の空白帯」の中央 y 座標を返す. カメラ写真対応: dark_thr=-1 のとき、ヒストグラムから背景輝度を自動推定し 「インクと紙」を分離できる閾値を算出する. 綴じ部の影対応: 左端 12% をスキップして dark_per_row を計算し、 綴じ影が空白帯を「非空白」と誤判定するのを防ぐ. ★行フィルタ: gap 直後の行が問題開始マーカー(★難易度行)でなければ 図の周囲余白や小問間の空白とみなして境界を除去する. """ gray = cv2.cvtColor(page_rgb, cv2.COLOR_RGB2GRAY) H, W = gray.shape if dark_thr < 0: # 背景 = 明るいほうから数えて 75 パーセンタイル付近 bg = float(np.percentile(gray, 75)) # インク閾値: 背景の 70% 以下をインクとみなす dark_thr = max(80, int(bg * 0.70)) # 綴じ影スキップ: 左端 12% はカメラ写真の綴じ部影が集中するため除外 x_skip = max(10, int(W * 0.12)) region = gray[:, x_skip:] rW = region.shape[1] dark_per_row = (region < dark_thr).sum(axis=1) is_blank = dark_per_row < (rW * blank_ratio) gap_candidates = [] in_gap, start = False, 0 for y in range(H): if is_blank[y] and not in_gap: in_gap, start = True, y elif not is_blank[y] and in_gap: in_gap = False gap_h = y - start if gap_h >= min_gap_rows: gap_candidates.append((start, y)) # ★行フィルタ + 上下端マージン除外 star_end = min(x_skip + 200, int(W * 0.35)) right_W = W - star_end edge = max(30, int(H * 0.05)) # 上下端 5% はマージンとして除外 boundaries = [] for (gs, ge) in gap_candidates: mid = gs + (ge - gs) // 2 if mid <= edge or mid >= H - edge: continue # 上下端マージンは境界としない # gap 直後 30 行に ★ 行 (左strip にのみ暗ピクセル) があるか確認 # 綴じ影対策: gap 内の平均 ld と比較し、明らかに増えた行のみ ★ 行と判定 gap_ld_vals = [int((gray[y, x_skip:star_end] < dark_thr).sum()) for y in range(gs, ge)] gap_ld_avg = float(np.mean(gap_ld_vals)) if gap_ld_vals else 0.0 ld_threshold = max(20, gap_ld_avg * 2.0) # gap平均の2倍以上かつ最低20px found_star = False for y in range(ge, min(ge + 30, H)): row = gray[y] ld = int((row[x_skip:star_end] < dark_thr).sum()) rd = int((row[star_end:] < dark_thr).sum()) if right_W > 0 else 0 if ld >= ld_threshold and rd < right_W * 0.025: found_star = True break if found_star: boundaries.append(mid) # 短すぎるセグメントをマージ (★行と[N]ボックスの二重検出を除去) # min_h: ページ高の 1/20 以上かつ最低 100px を1問の最小高さとする if boundaries: min_h = max(100, H // 20) ys = [0] + sorted(boundaries) + [H] changed = True while changed: changed = False min_seg, mi = H, -1 for i in range(len(ys) - 1): s = ys[i + 1] - ys[i] if s < min_seg: min_seg, mi = s, i if min_seg < min_h and mi >= 0: ln = ys[mi] - ys[mi - 1] if mi > 0 else H rn = ys[mi + 2] - ys[mi + 1] if mi + 2 < len(ys) else H ys.pop(mi if ln <= rn else mi + 1) changed = True boundaries = ys[1:-1] return boundaries def derive_bboxes_from_boundaries( page_shape: Tuple[int, int], boundaries: List[int], right_margin_ratio: float = 0.99, left: int = 10, ) -> List[Tuple[int, int, int, int]]: """境界 y 座標リストから bbox リストを生成.""" H, W = page_shape[:2] right = int(W * right_margin_ratio) ys = [0] + boundaries + [H] bboxes = [] for i in range(len(ys) - 1): y0, y1 = ys[i], ys[i + 1] # コンテンツがほぼない薄いスライスは除外 if y1 - y0 > 30: bboxes.append((left, y0, right, y1)) return bboxes def extract_page(img_rgb: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int, int, int]]: """灰色背景から白い紙面を切り出す.""" gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) _, white = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY) contours, _ = cv2.findContours(white, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: h, w = gray.shape return img_rgb, (0, 0, w, h) c = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(c) pad = 2 x0, y0 = x + pad, y + pad x1, y1 = x + w - pad, y + h - pad return img_rgb[y0:y1, x0:x1].copy(), (x0, y0, x1, y1) def find_checkboxes_by_cc(page_rgb: np.ndarray, left_strip_x: int = 200, size_min: int = 22, size_max: int = 35, aspect_tol: float = 0.25, density_min: float = 0.10, density_max: float = 0.60) -> List[Tuple[int, int, int, int]]: """連結成分で ☑ を検出. ☑の特徴 (実測値, 1086px幅 ページ): - サイズ ~27x27 (size_min=22 で(1)等のサブ問題マーカー w=20を除外) - ほぼ正方 (aspect_tol=0.25) - 密度 0.15-0.30 (外枠+チェックのみ。塗りつぶし文字を除外) """ gray = cv2.cvtColor(page_rgb, cv2.COLOR_RGB2GRAY) strip = gray[:, :left_strip_x].copy() _, binary = cv2.threshold(strip, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) n, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8) boxes = [] for i in range(1, n): x, y, w, h, area = stats[i] if not (size_min <= w <= size_max): continue if not (size_min <= h <= size_max): continue ar = w / max(h, 1) if not (1 - aspect_tol <= ar <= 1 + aspect_tol): continue density = area / (w * h) if density < density_min or density > density_max: continue boxes.append((x, y, w, h)) boxes.sort(key=lambda b: b[1]) return boxes def find_section_bands(page_rgb: np.ndarray, min_h: int = 25, mean_max: float = 248, std_min: float = 15, full_width_ratio: float = 0.35) -> List[Tuple[int, int]]: """A / B / 発展 のセクション帯 (y_start, y_end) を検出する. 帯の特徴: - ページ幅の 35% 以上が中間グレー (100-250) - 行平均 < 248 かつ 行std > 15 (純白でも純黒でもない) - 連続する行数が min_h 以上 """ gray = cv2.cvtColor(page_rgb, cv2.COLOR_RGB2GRAY) H, W = gray.shape row_mean = gray.mean(axis=1) row_std = gray.std(axis=1) coverage = ((gray > 100) & (gray < 250)).sum(axis=1) / W is_band = (row_mean < mean_max) & (row_std > std_min) & (coverage > full_width_ratio) bands: List[Tuple[int, int]] = [] in_band, start = False, 0 for y in range(H): if is_band[y] and not in_band: in_band, start = True, y elif not is_band[y] and in_band: in_band = False if y - start >= min_h: bands.append((start, y)) if in_band and H - start >= min_h: bands.append((start, H)) return bands def derive_problem_bboxes(page_shape: Tuple[int, int], checkboxes: List[Tuple[int, int, int, int]], right_margin_ratio: float = 0.99, section_bands: Optional[List[Tuple[int, int]]] = None, ) -> List[Tuple[int, int, int, int]]: """各☑から次の☑ (またはセクション帯) までを 1 問の bbox とする. section_bands を渡すと、問題とセクション帯の間でカットする. """ h, w = page_shape[:2] right = int(w * right_margin_ratio) left = 50 out = [] for i, (cx, cy, cw, ch) in enumerate(checkboxes): y0 = max(0, cy - 5) if i + 1 < len(checkboxes): next_y = checkboxes[i + 1][1] - 5 # セクション帯が間にあれば、帯の直前でカット if section_bands: for band_y0, band_y1 in section_bands: if cy < band_y0 < next_y: next_y = band_y0 - 5 break y1 = next_y else: y1 = h - 20 out.append((left, y0, right, y1)) return out def visualize(page_rgb: np.ndarray, checkboxes: List[Tuple[int, int, int, int]], bboxes: List[Tuple[int, int, int, int]]) -> np.ndarray: out = page_rgb.copy() for x, y, w, h in checkboxes: cv2.rectangle(out, (x, y), (x + w, y + h), (0, 200, 0), 2) for i, (x0, y0, x1, y1) in enumerate(bboxes): cv2.rectangle(out, (x0, y0), (x1, y1), (220, 0, 0), 4) cv2.putText(out, f"Q{i+1}", (x0 + 10, y0 + 35), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (220, 0, 0), 3) return out def process(sample_path: str, name: str): print(f"\n=== {name} ===") img = np.array(Image.open(sample_path).convert("RGB")) page, page_box = extract_page(img) print(f"page: {page.shape}") cbs = find_checkboxes_by_cc(page) print(f"checkboxes detected: {len(cbs)}") for i, (x, y, w, h) in enumerate(cbs): print(f" ☑{i+1}: x={x:4d} y={y:4d} w={w:2d} h={h:2d}") bboxes = derive_problem_bboxes(page.shape, cbs) vis = visualize(page, cbs, bboxes) out_path = os.path.join(DEBUG_DIR, f"{name}_detected.png") Image.fromarray(vis).save(out_path) print(f"-> {out_path}") return cbs, bboxes if __name__ == "__main__": process(os.path.join(SAMPLES_DIR, "sample02_p45.png"), "p45") process(os.path.join(SAMPLES_DIR, "sample03_p47.png"), "p47")