Spaces:
Sleeping
Sleeping
Download togyz/sheet.py from ansarzeinulla/9OCR: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/spaces/ansarzeinulla/9OCR/resolve/e5800bc28e49e54d6a0a6c50c2fa7269d61c8c8c/togyz/sheet.py
- Command line
-
hf download hf://spaces/ansarzeinulla/9OCR@e5800bc28e49e54d6a0a6c50c2fa7269d61c8c8c/togyz/sheet.py
-
curl -L -o sheet.py https://huggingface.co/spaces/ansarzeinulla/9OCR/resolve/e5800bc28e49e54d6a0a6c50c2fa7269d61c8c8c/togyz/sheet.py
19.2 kB
| """Locate the 8 printed move tables on a scoresheet photo and crop move cells. | |
| Federation scoresheet layout: two bands of 4 tables (moves 1-40 top, | |
| 41-80 bottom), each table = header row + 10 move rows with columns | |
| [No | Bast. (White) | Kost. (Black)]. Only these tables are read; the | |
| header, summary strips, and footer are ignored. | |
| Table boxes are found via printed grid lines (morphology + connected | |
| components). Row/column separators inside a table are refined from detected | |
| lines when they are complete, otherwise the known uniform structure is used | |
| (photos are often too low-res for reliable thin-line detection). | |
| """ | |
| from dataclasses import dataclass | |
| import cv2 | |
| import numpy as np | |
| from PIL import Image, ImageOps | |
| TABLES = 8 | |
| ROWS_PER_TABLE = 10 # plus one header row | |
| # column boundaries as fractions of table width: No | Bast | Kost | |
| COLUMN_FRACTIONS = (0.0, 0.20, 0.60, 1.0) | |
| CELL_MARGIN = 0.15 # expand crops; handwriting overflows the printed cells | |
| class Cell: | |
| move_no: int # move cells: 1-80; diagram cells: checkpoint move (10..80) | |
| side: str # "W" (Bast.) or "B" (Kost.); for pits this is the row owner | |
| bbox: tuple[int, int, int, int] # x, y, w, h in original image coords | |
| image: Image.Image | |
| quad: np.ndarray | None = None # 4x2 original-image corners (tilt-aware) | |
| kind: str = "move" # "move" | "kazan" | "pit" | |
| pit_index: int = 0 # 1-9 for pit cells (scoresheet numbering), else 0 | |
| def _grid_mask(gray: np.ndarray) -> np.ndarray: | |
| h, w = gray.shape | |
| thr = cv2.adaptiveThreshold( | |
| gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 25, 12 | |
| ) | |
| horiz = cv2.morphologyEx( | |
| thr, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (w // 30, 1)) | |
| ) | |
| vert = cv2.morphologyEx( | |
| thr, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (1, h // 40)) | |
| ) | |
| return cv2.dilate(cv2.add(horiz, vert), np.ones((3, 3), np.uint8)) | |
| def _order_corners(points: np.ndarray) -> np.ndarray: | |
| """Order 4 points as top-left, top-right, bottom-right, bottom-left.""" | |
| points = points.reshape(4, 2).astype(np.float32) | |
| sums, diffs = points.sum(axis=1), np.diff(points, axis=1).ravel() | |
| return np.array( | |
| [points[sums.argmin()], points[diffs.argmin()], | |
| points[sums.argmax()], points[diffs.argmax()]], | |
| np.float32, | |
| ) | |
| def _find_table_quads(gray: np.ndarray) -> list[np.ndarray]: | |
| """Corner quads of the 8 move tables (phone photos are tilted, so tables | |
| are general quadrilaterals, not axis-aligned rectangles).""" | |
| h, w = gray.shape | |
| mask = _grid_mask(gray) | |
| n, labels, stats, _ = cv2.connectedComponentsWithStats(mask) | |
| quads = [] | |
| for i in range(1, n): | |
| if stats[i][2] <= w * 0.1 or stats[i][3] <= h * 0.1: | |
| continue | |
| component = (labels == i).astype(np.uint8) | |
| contours, _ = cv2.findContours(component, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| hull = cv2.convexHull(max(contours, key=cv2.contourArea)) | |
| approx = cv2.approxPolyDP(hull, 0.02 * cv2.arcLength(hull, True), True) | |
| if len(approx) == 4: | |
| quad = _order_corners(approx) | |
| else: # fall back to the min-area rectangle corners | |
| quad = _order_corners(cv2.boxPoints(cv2.minAreaRect(hull))) | |
| quads.append((cv2.contourArea(quad), quad)) | |
| if len(quads) < TABLES: | |
| raise RuntimeError( | |
| f"Found only {len(quads)} move tables (need {TABLES}). " | |
| "Check photo quality/framing." | |
| ) | |
| # the 8 move tables all have near-identical area; drop outliers like the | |
| # footer/signature block, which can out-size a genuine table | |
| median_area = float(np.median([a for a, _ in quads])) | |
| consistent = [(a, q) for a, q in quads if 0.5 * median_area < a < 2.0 * median_area] | |
| if len(consistent) >= TABLES: | |
| quads = consistent | |
| quads = [q for _, q in sorted(quads, key=lambda t: -t[0])[:TABLES]] | |
| # split into top/bottom bands by y, order each band left to right | |
| quads.sort(key=lambda q: q[:, 1].mean()) | |
| top = sorted(quads[:4], key=lambda q: q[:, 0].mean()) | |
| bottom = sorted(quads[4:], key=lambda q: q[:, 0].mean()) | |
| return top + bottom | |
| def _detect_lines(mask: np.ndarray, axis: int, min_frac: float) -> list[int]: | |
| """Positions of long line clusters along `axis` (0=rows, 1=cols).""" | |
| profile = mask.sum(axis=1 - axis) | |
| limit = 255 * mask.shape[1 - axis] * min_frac | |
| positions = np.where(profile > limit)[0] | |
| clusters: list[list[int]] = [] | |
| for pos in positions: | |
| if clusters and pos - clusters[-1][-1] <= 2: | |
| clusters[-1].append(int(pos)) | |
| else: | |
| clusters.append([int(pos)]) | |
| return [int(np.mean(c)) for c in clusters] | |
| UPSCALE = 2 # rectified tables are rendered at 2x for a little more pixel room | |
| def _rectify_table(gray: np.ndarray, quad: np.ndarray): | |
| """Warp a (possibly tilted) table quad to a flat rectangle. | |
| Returns (warped gray image, inverse homography back to sheet coords). | |
| """ | |
| top = np.linalg.norm(quad[1] - quad[0]) | |
| bottom = np.linalg.norm(quad[2] - quad[3]) | |
| left = np.linalg.norm(quad[3] - quad[0]) | |
| right = np.linalg.norm(quad[2] - quad[1]) | |
| tw = int(round((top + bottom) / 2)) * UPSCALE | |
| th = int(round((left + right) / 2)) * UPSCALE | |
| target = np.array([[0, 0], [tw, 0], [tw, th], [0, th]], np.float32) | |
| matrix = cv2.getPerspectiveTransform(quad, target) | |
| warped = cv2.warpPerspective(gray, matrix, (tw, th), flags=cv2.INTER_CUBIC) | |
| return warped, np.linalg.inv(matrix) | |
| def _row_lines(warped: np.ndarray) -> list[int]: | |
| """Row separators in a rectified table; uniform fallback.""" | |
| h, w = warped.shape | |
| thr = cv2.adaptiveThreshold( | |
| warped, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 25, 12 | |
| ) | |
| horiz = cv2.morphologyEx( | |
| thr, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (max(w // 3, 3), 1)) | |
| ) | |
| rows = _detect_lines(horiz, axis=0, min_frac=0.4) | |
| if len(rows) != ROWS_PER_TABLE + 2: # header + 10 rows needs 12 lines | |
| rows = [round(i * h / (ROWS_PER_TABLE + 1)) for i in range(ROWS_PER_TABLE + 2)] | |
| return rows | |
| def _map_segment(inverse: np.ndarray, p0, p1) -> tuple[tuple[int, int], tuple[int, int]]: | |
| """Map a rectified-table segment back onto the original sheet.""" | |
| pts = np.array([p0, p1], np.float32).reshape(-1, 1, 2) | |
| mapped = cv2.perspectiveTransform(pts, inverse).reshape(2, 2) | |
| return ( | |
| (int(mapped[0][0]), int(mapped[0][1])), | |
| (int(mapped[1][0]), int(mapped[1][1])), | |
| ) | |
| def extract_cells(image, with_gridlines: bool = False): | |
| """All 160 move cells (80 moves x W/B) plus the upright sheet image. | |
| `image` is a path (str/Path) or a PIL.Image. Each table is | |
| perspective-rectified before being split, so cell crops stay aligned even | |
| on tilted phone photos. | |
| Returns (cells, sheet) or, with `with_gridlines`, (cells, sheet, gridlines) | |
| where gridlines is a list of ((x0, y0), (x1, y1)) sheet-coordinate segments | |
| for every row/column separator used during segmentation. | |
| """ | |
| if isinstance(image, Image.Image): | |
| pil = ImageOps.exif_transpose(image).convert("L") | |
| else: | |
| with Image.open(image) as img: | |
| pil = ImageOps.exif_transpose(img).convert("L") | |
| gray = np.asarray(pil) | |
| cells: list[Cell] = [] | |
| gridlines: list[tuple[tuple[int, int], tuple[int, int]]] = [] | |
| for t, quad in enumerate(_find_table_quads(gray)): | |
| warped, inverse = _rectify_table(gray, quad) | |
| th, tw = warped.shape | |
| rows = _row_lines(warped) | |
| cols = [round(f * tw) for f in COLUMN_FRACTIONS] | |
| if with_gridlines: | |
| for y in rows: | |
| gridlines.append(_map_segment(inverse, (0, y), (tw, y))) | |
| for x in cols: | |
| gridlines.append(_map_segment(inverse, (x, rows[0]), (x, rows[-1]))) | |
| for r in range(ROWS_PER_TABLE): | |
| y0, y1 = rows[r + 1], rows[r + 2] # rows[0..1] is the header | |
| for col_index, side in ((1, "W"), (2, "B")): | |
| x0, x1 = cols[col_index], cols[col_index + 1] | |
| mx = round((x1 - x0) * CELL_MARGIN) | |
| my = round((y1 - y0) * CELL_MARGIN) | |
| cx0, cy0 = max(0, x0 - mx), max(0, y0 - my) | |
| cx1, cy1 = min(tw, x1 + mx), min(th, y1 + my) | |
| crop = Image.fromarray(warped[cy0:cy1, cx0:cx1]) | |
| # map the cell corners back onto the original sheet | |
| corners = np.array( | |
| [[cx0, cy0], [cx1, cy0], [cx1, cy1], [cx0, cy1]], np.float32 | |
| ).reshape(-1, 1, 2) | |
| sheet_quad = cv2.perspectiveTransform(corners, inverse).reshape(4, 2) | |
| x_min, y_min = sheet_quad.min(axis=0) | |
| x_max, y_max = sheet_quad.max(axis=0) | |
| bbox = (int(x_min), int(y_min), int(x_max - x_min), int(y_max - y_min)) | |
| cells.append( | |
| Cell(t * ROWS_PER_TABLE + r + 1, side, bbox, crop, sheet_quad) | |
| ) | |
| if with_gridlines: | |
| return cells, pil, gridlines | |
| return cells, pil | |
| PIT_MARGIN_Y = 0.25 # pit digits regularly overflow the printed row height | |
| PIT_MARGIN_X = 0.15 # and bleed a little into neighboring columns | |
| def _strip_pit_grid(mask: np.ndarray, gray: np.ndarray, | |
| strip_bbox: tuple[int, int, int, int], checkpoint: int) -> list[Cell]: | |
| """The 18 pit cells of the 2x9 board diagram inside one summary strip. | |
| Upper row = Kost. (Black), pit indexes 9..1 left-to-right; lower row = | |
| Bast. (White), indexes 1..9. Grid lines are detected inside the strip; | |
| when detection is incomplete the known uniform 2x9 structure is used. | |
| """ | |
| x, y, bw, bh = strip_bbox | |
| h, w = gray.shape | |
| sub = mask[y : y + bh, x : x + bw] | |
| # horizontal separators: top / middle / bottom of the 2x9 grid | |
| hlines = _detect_lines(sub, axis=0, min_frac=0.55) | |
| if len(hlines) < 2: | |
| return [] | |
| top, bottom = hlines[0], hlines[-1] | |
| if bottom - top < 6: | |
| return [] | |
| mid_target = (top + bottom) / 2 | |
| inner = [v for v in hlines if top + 2 < v < bottom - 2] | |
| middle = min(inner, key=lambda v: abs(v - mid_target)) if inner else int(round(mid_target)) | |
| # vertical separators: 10 column lines across the grid band | |
| band = sub[top : bottom + 1] | |
| vlines = _detect_lines(band, axis=1, min_frac=0.5) | |
| if len(vlines) != 10: | |
| if len(vlines) >= 2: | |
| x0, x1 = vlines[0], vlines[-1] | |
| else: | |
| xs = np.where(band.sum(axis=0) > 0)[0] | |
| x0, x1 = (int(xs.min()), int(xs.max())) if len(xs) else (0, bw - 1) | |
| vlines = [round(x0 + j * (x1 - x0) / 9) for j in range(10)] | |
| cells: list[Cell] = [] | |
| rows = ((top, middle, "B"), (middle, bottom, "W")) | |
| for ry0, ry1, owner in rows: | |
| my = round((ry1 - ry0) * PIT_MARGIN_Y) | |
| for j in range(9): | |
| cx0, cx1 = vlines[j], vlines[j + 1] | |
| mx = round((cx1 - cx0) * PIT_MARGIN_X) | |
| gx0 = max(0, x + cx0 - mx) | |
| gx1 = min(w, x + cx1 + mx) | |
| gy0 = max(0, y + ry0 - my) | |
| gy1 = min(h, y + ry1 + my) | |
| if gx1 - gx0 < 4 or gy1 - gy0 < 4: | |
| continue | |
| pit_index = 9 - j if owner == "B" else j + 1 | |
| cells.append(Cell( | |
| checkpoint, owner, (gx0, gy0, gx1 - gx0, gy1 - gy0), | |
| Image.fromarray(gray[gy0:gy1, gx0:gx1]), | |
| kind="pit", pit_index=pit_index, | |
| )) | |
| return cells | |
| def extract_diagram_cells(sheet: Image.Image) -> list[Cell]: | |
| """All board-diagram cells from the 8 summary strips between table bands. | |
| After every 10 moves the scorer fills a small board diagram: the box | |
| protruding ABOVE the strip is the Kost. (Black) kazan, the box BELOW is | |
| the Bast. (White) kazan, and the 2x9 grid between them holds the pit | |
| counts ('x' = tuzdyk, '-' = 0, or a 1-2 digit number). Returned as Cell | |
| objects with move_no = the checkpoint move (10, 20, ... 80), kind | |
| "kazan"/"pit", side "B"/"W" (row owner for pits) and pit_index 1-9. | |
| """ | |
| gray = np.asarray(sheet) | |
| h, w = gray.shape | |
| mask = _grid_mask(gray) | |
| quads = _find_table_quads(gray) | |
| table_area = float(np.median([cv2.contourArea(q) for q in quads])) | |
| # Diagram strips are located by table position, not by enumeration order: | |
| # the strip for checkpoint 10*(4*band + col + 1) sits below table `col` of | |
| # band `band`, in the gap under that band. Handwriting sometimes bridges | |
| # neighboring strips into one connected component, so wide components are | |
| # kept and carved by each table's x-range. | |
| n, _, stats, _ = cv2.connectedComponentsWithStats(mask) | |
| components = [ | |
| tuple(int(v) for v in stats[i][:4]) | |
| for i in range(1, n) | |
| if stats[i][2] > w * 0.05 | |
| and h * 0.02 < stats[i][3] < h * 0.12 | |
| and stats[i][2] > 1.5 * stats[i][3] | |
| and stats[i][2] * stats[i][3] < 2.5 * table_area | |
| ] | |
| top_quads, bottom_quads = quads[:4], quads[4:] | |
| zones = ( | |
| (top_quads, (max(q[:, 1].max() for q in top_quads), | |
| min(q[:, 1].min() for q in bottom_quads))), | |
| (bottom_quads, (max(q[:, 1].max() for q in bottom_quads), h)), | |
| ) | |
| strips: list[tuple[int, tuple[int, int, int, int]]] = [] | |
| for band, (band_quads, (zy0, zy1)) in enumerate(zones): | |
| for col, quad in enumerate(band_quads): | |
| tx0, tx1 = float(quad[:, 0].min()), float(quad[:, 0].max()) | |
| parts = [] | |
| for cx, cy, cw, ch in components: | |
| yc = cy + ch / 2 | |
| overlap = min(cx + cw, tx1) - max(cx, tx0) | |
| if zy0 < yc < zy1 and overlap > 0.3 * (tx1 - tx0): | |
| parts.append((cx, cy, cw, ch)) | |
| if not parts: | |
| continue | |
| pad = int(0.02 * w) | |
| sx0 = max(int(tx0) - pad, min(cx for cx, *_ in parts)) | |
| sx1 = min(int(tx1) + pad, max(cx + cw for cx, _, cw, _ in parts)) | |
| sy0 = min(cy for _, cy, *_ in parts) | |
| sy1 = max(cy + ch for _, cy, _, ch in parts) | |
| checkpoint = (band * 4 + col + 1) * 10 | |
| strips.append((checkpoint, (sx0, sy0, sx1 - sx0, sy1 - sy0))) | |
| cells: list[Cell] = [] | |
| for checkpoint, (x, y, bw, bh) in strips: | |
| cells.extend(_strip_pit_grid(mask, gray, (x, y, bw, bh), checkpoint)) | |
| sub = mask[y : y + bh, x : x + bw] | |
| long_rows = np.where((sub > 0).sum(axis=1) > bw * 0.55)[0] | |
| if len(long_rows) == 0: | |
| continue | |
| # kazan boxes are ~1 row tall; a tight zone avoids swallowing the | |
| # neighboring table's header text below the strip | |
| zones = { | |
| "B": (max(0, y + int(long_rows.min()) - 30), y + int(long_rows.min()) - 1, False), | |
| "W": (y + int(long_rows.max()) + 3, min(h, y + int(long_rows.max()) + 31), True), | |
| } | |
| for side, (gy0, gy1, truncate_below) in zones.items(): | |
| if gy1 - gy0 < 8: | |
| continue | |
| zone = mask[gy0:gy1, x : x + bw] | |
| # stop at any full-width line (a neighboring table's grid) | |
| full = np.where((zone > 0).sum(axis=1) > bw * 0.7)[0] | |
| if len(full): | |
| if truncate_below: | |
| zone = zone[: full.min()] | |
| else: | |
| zone = zone[full.max() + 1 :] | |
| gy0 += int(full.max()) + 1 | |
| ys, xs = np.where(zone > 0) | |
| if len(xs) < 15: | |
| continue | |
| bx0, bx1 = int(xs.min()), int(xs.max()) | |
| by0, by1 = gy0 + int(ys.min()), gy0 + int(ys.max()) | |
| if bx1 - bx0 < 10 or by1 - by0 < 8: | |
| continue | |
| cx0, cy0 = max(0, x + bx0 - 3), max(0, by0 - 2) | |
| cx1, cy1 = min(w, x + bx1 + 4), min(h, by1 + 3) | |
| cells.append(Cell( | |
| checkpoint, side, (cx0, cy0, cx1 - cx0, cy1 - cy0), | |
| Image.fromarray(gray[cy0:cy1, cx0:cx1]), | |
| kind="kazan", | |
| )) | |
| return cells | |
| def clean_cell(image: Image.Image) -> tuple[Image.Image, float]: | |
| """Remove printed grid-line fragments from a cell crop. | |
| Returns the cleaned crop and the fraction of remaining ink pixels | |
| (handwriting). Blank cells score near zero even when grid lines cross | |
| the crop, so this is a classifier-independent emptiness signal. | |
| """ | |
| gray = np.asarray(image) | |
| h, w = gray.shape | |
| thr = cv2.adaptiveThreshold( | |
| gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 25, 12 | |
| ) | |
| # printed lines: long straight runs spanning most of the crop | |
| horiz = cv2.morphologyEx( | |
| thr, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (max(3, int(w * 0.6)), 1)) | |
| ) | |
| vert = cv2.morphologyEx( | |
| thr, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(3, int(h * 0.6)))) | |
| ) | |
| lines = cv2.dilate(cv2.add(horiz, vert), np.ones((3, 3), np.uint8)) | |
| background = int(np.median(gray[thr == 0])) if (thr == 0).any() else 255 | |
| cleaned = gray.copy() | |
| cleaned[lines > 0] = background | |
| ink = (thr > 0) & (lines == 0) | |
| ink[: h // 8, :] = ink[-h // 8 :, :] = False # ignore crop edges: neighbor | |
| ink[:, : w // 12] = ink[:, -w // 12 :] = False # rows/cells bleeding in | |
| ink_ratio = float(ink.sum()) / (h * w) | |
| return Image.fromarray(cleaned), ink_ratio | |
| def render_overlay(sheet: Image.Image, cells: list[Cell], labels: dict | None = None, | |
| diagram_cells: list[Cell] | None = None, | |
| diagram_labels: dict | None = None, | |
| gridlines: list | None = None) -> Image.Image: | |
| """Debug image: move-cell boxes (green) with predicted labels (red), | |
| kazan boxes (blue), pit cells (orange) with their reads, and the | |
| segmentation gridlines (gray). | |
| `diagram_labels` is keyed by (move_no, kind, side, pit_index). | |
| """ | |
| vis = cv2.cvtColor(np.asarray(sheet), cv2.COLOR_GRAY2BGR) | |
| for p0, p1 in gridlines or []: | |
| cv2.line(vis, p0, p1, (160, 160, 160), 1, cv2.LINE_AA) | |
| for cell in cells: | |
| x, y, w, h = cell.bbox | |
| if cell.quad is not None: | |
| cv2.polylines(vis, [cell.quad.astype(np.int32)], True, (0, 180, 0), 1) | |
| else: | |
| cv2.rectangle(vis, (x, y), (x + w, y + h), (0, 180, 0), 1) | |
| if labels: | |
| text = labels.get((cell.move_no, cell.side)) | |
| if text: | |
| cv2.putText(vis, text, (x + 2, y + h - 3), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 255), 1, cv2.LINE_AA) | |
| for cell in diagram_cells or []: | |
| x, y, w, h = cell.bbox | |
| color = (200, 120, 0) if cell.kind == "kazan" else (0, 140, 255) # BGR | |
| cv2.rectangle(vis, (x, y), (x + w, y + h), color, 1) | |
| if diagram_labels: | |
| text = diagram_labels.get((cell.move_no, cell.kind, cell.side, cell.pit_index)) | |
| if text: | |
| cv2.putText(vis, text, (x + 1, y + h - 2), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 255), 1, cv2.LINE_AA) | |
| return Image.fromarray(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB)) | |