File size: 5,230 Bytes
2ebf7ae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | """Classical CV table structure detection using line detection.
Detects horizontal and vertical lines in table images using morphological
operations, finds cell boundaries from line intersections, and returns
a structured grid.
This works better than TATR on engineering drawing BOM tables because:
- BOM tables have clear ruled lines (horizontal + vertical)
- Morphological line detection is robust to line thickness variation
- No ML model needed β pure geometry
Usage:
from scripts.inference.table_structure import detect_table_structure
cells = detect_table_structure(table_crop_bgr)
"""
from __future__ import annotations
import cv2
import numpy as np
def detect_table_structure(
image: np.ndarray,
min_line_length_ratio: float = 0.15,
line_thickness: int = 2,
) -> dict | None:
"""Detect table rows and columns from ruled lines.
Args:
image: BGR table crop image.
min_line_length_ratio: Minimum line length as fraction of image dimension.
line_thickness: Morphological kernel size for line detection.
Returns:
dict with row_boundaries, col_boundaries, cell_grid or None if no structure found.
"""
h, w = image.shape[:2]
# Upscale small images
if h < 400:
scale = 400 / h
image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
h, w = image.shape[:2]
# Convert to grayscale and binarize
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Invert: lines become white on black
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# ββ Detect horizontal lines ββ
h_kernel_len = max(int(w * min_line_length_ratio), 20)
h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (h_kernel_len, 1))
h_lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, h_kernel, iterations=2)
# ββ Detect vertical lines ββ
v_kernel_len = max(int(h * min_line_length_ratio), 20)
v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, v_kernel_len))
v_lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, v_kernel, iterations=2)
# ββ Extract line positions ββ
h_positions = _find_line_positions(h_lines, axis="horizontal")
v_positions = _find_line_positions(v_lines, axis="vertical")
if len(h_positions) < 2 or len(v_positions) < 2:
return None
# Add image boundaries if missing
if h_positions[0] > h * 0.05:
h_positions.insert(0, 0)
if h_positions[-1] < h * 0.95:
h_positions.append(h)
if v_positions[0] > w * 0.05:
v_positions.insert(0, 0)
if v_positions[-1] < w * 0.95:
v_positions.append(w)
# Merge lines that are too close (within 5px)
h_positions = _merge_close_positions(h_positions, threshold=max(5, h * 0.01))
v_positions = _merge_close_positions(v_positions, threshold=max(5, w * 0.01))
n_rows = len(h_positions) - 1
n_cols = len(v_positions) - 1
if n_rows < 1 or n_cols < 1:
return None
# Build cell grid
cells = []
for r in range(n_rows):
for c in range(n_cols):
y1 = int(h_positions[r])
y2 = int(h_positions[r + 1])
x1 = int(v_positions[c])
x2 = int(v_positions[c + 1])
# Skip very thin cells (likely line artifacts)
if (y2 - y1) < 5 or (x2 - x1) < 5:
continue
cells.append({
"row": r,
"col": c,
"bbox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
})
return {
"row_count": n_rows,
"col_count": n_cols,
"row_boundaries": [int(p) for p in h_positions],
"col_boundaries": [int(p) for p in v_positions],
"cells": cells,
"image_size": {"width": w, "height": h},
}
def _find_line_positions(line_mask: np.ndarray, axis: str) -> list[float]:
"""Find positions of detected lines by projecting onto an axis."""
if axis == "horizontal":
# Sum along x-axis to get row profile
projection = np.sum(line_mask, axis=1)
else:
# Sum along y-axis to get column profile
projection = np.sum(line_mask, axis=0)
# Threshold: positions where projection is significant
threshold = np.max(projection) * 0.3 if np.max(projection) > 0 else 0
peaks = np.where(projection > threshold)[0]
if len(peaks) == 0:
return []
# Cluster close peaks into single line positions
positions = []
cluster = [peaks[0]]
for p in peaks[1:]:
if p - cluster[-1] <= 3:
cluster.append(p)
else:
positions.append(float(np.mean(cluster)))
cluster = [p]
positions.append(float(np.mean(cluster)))
return positions
def _merge_close_positions(positions: list[float], threshold: float) -> list[float]:
"""Merge positions that are within threshold pixels of each other."""
if not positions:
return positions
merged = [positions[0]]
for p in positions[1:]:
if p - merged[-1] > threshold:
merged.append(p)
else:
# Replace with average
merged[-1] = (merged[-1] + p) / 2
return merged
|