whoisscaramouchee Claude Opus 4.6 (1M context) commited on
Commit
32ac2af
·
1 Parent(s): 0991cc7

Major table extraction quality overhaul

Browse files

- Replace Tesseract with EasyOCR for cell text (much better Cyrillic support)
- Add OpenCV line detection fallback when Table Transformer misses rows/columns
- Use adaptive threshold (Gaussian) instead of Otsu for better screenshot handling
- Add grid cleanup: remove empty rows and columns
- Increase padding from 2px to 3px, CLAHE clipLimit from 1.5 to 2.0
- Better reading order: sort by y then x coordinates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. backend/ocr/table.py +154 -67
backend/ocr/table.py CHANGED
@@ -1,4 +1,4 @@
1
- """Table extraction using Microsoft Table Transformer."""
2
  import numpy as np
3
  import cv2
4
  import torch
@@ -9,7 +9,7 @@ from backend.models.schemas import TableRegion
9
 
10
 
11
  class TableTransformerEngine:
12
- """Extract tables from document images using Table Transformer."""
13
 
14
  MODEL_NAME = "microsoft/table-transformer-structure-recognition"
15
 
@@ -20,6 +20,13 @@ class TableTransformerEngine:
20
  self.MODEL_NAME
21
  ).to(self.device)
22
  self.model.eval()
 
 
 
 
 
 
 
23
 
24
  def detect_tables(self, image: Image.Image) -> list[dict]:
25
  """Detect table regions in image."""
@@ -58,14 +65,49 @@ class TableTransformerEngine:
58
  ) -> list[list[str]]:
59
  """Extract cell data from a cropped table region.
60
 
61
- Table Transformer returns column and row bounding boxes.
62
- We compute cell positions as intersections of columns x rows.
63
  """
64
- from backend.ocr.tesseract import TesseractEngine
65
-
66
  x, y, w, h = table_bbox
67
  cropped = image.crop((x, y, x + w, y + h))
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  inputs = self.processor(images=cropped, return_tensors="pt")
70
  inputs = {k: v.to(self.device) for k, v in inputs.items()}
71
 
@@ -91,41 +133,87 @@ class TableTransformerEngine:
91
  elif label.item() == 2: # row
92
  rows.append(box.tolist())
93
 
94
- if not columns or not rows:
95
- return self._fallback_extract(cropped)
96
 
97
- # Sort columns left-to-right, rows top-to-bottom
98
- columns.sort(key=lambda b: b[0])
99
- rows.sort(key=lambda b: b[1])
 
 
 
 
100
 
101
- # Deduplicate overlapping columns/rows
102
- columns = self._merge_overlapping(columns, axis=0)
103
- rows = self._merge_overlapping(rows, axis=1)
 
 
104
 
105
- tesseract = TesseractEngine()
106
- grid = []
107
- for row_box in rows:
108
- ry1, ry2 = int(row_box[1]), int(row_box[3])
109
- row_texts = []
110
- for col_box in columns:
111
- cx1, cx2 = int(col_box[0]), int(col_box[2])
112
- cell_img = cropped.crop((cx1, ry1, cx2, ry2))
113
- text = self._extract_cell_text(cell_img, tesseract)
114
- row_texts.append(text)
115
- grid.append(row_texts)
116
 
117
- return grid
 
 
118
 
119
- def _extract_cell_text(
120
- self,
121
- cell_img: Image.Image,
122
- tesseract,
123
- ) -> str:
124
- """Extract text from a single table cell with preprocessing."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  processed = self._preprocess_cell_image(cell_img)
126
- # PSM 6 = uniform block of text (best for table cells)
127
- text, _ = tesseract.extract_text_psm(processed, psm=6)
128
- return text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  def _preprocess_cell_image(self, image: Image.Image) -> Image.Image:
131
  """Preprocess a cell image for better OCR."""
@@ -135,18 +223,19 @@ class TableTransformerEngine:
135
  else:
136
  gray = arr
137
 
138
- # Add 2px padding to avoid cutting text at borders
139
- gray = cv2.copyMakeBorder(gray, 2, 2, 2, 2, cv2.BORDER_CONSTANT, value=255)
140
 
141
- # Otsu binarization
142
- _, binary = cv2.threshold(
143
- gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
 
144
  )
145
 
146
  # Upscale if cell is too small
147
  h, w = binary.shape
148
- if h < 30 or w < 30:
149
- scale = max(30 / h, 30 / w, 2.0)
150
  binary = cv2.resize(
151
  binary,
152
  (int(w * scale), int(h * scale)),
@@ -187,28 +276,28 @@ class TableTransformerEngine:
187
 
188
  return merged
189
 
190
- def _cluster_cells_into_rows(
191
- self,
192
- cells: list[list[float]],
193
- threshold: float,
194
- ) -> list[list[list[float]]]:
195
- """Cluster cell boxes into rows based on Y coordinate."""
196
- rows = []
197
- current_row = []
198
- last_y = None
199
 
200
- for cell in sorted(cells, key=lambda c: c[1]):
201
- if last_y is None or abs(cell[1] - last_y) < threshold:
202
- current_row.append(cell)
203
- else:
204
- rows.append(sorted(current_row, key=lambda c: c[0]))
205
- current_row = [cell]
206
- last_y = cell[1]
207
 
208
- if current_row:
209
- rows.append(sorted(current_row, key=lambda c: c[0]))
210
 
211
- return rows
 
 
 
 
 
 
 
 
 
 
212
 
213
  def _fallback_extract(self, image: Image.Image) -> list[list[str]]:
214
  """Fallback: split image by horizontal projection."""
@@ -218,8 +307,8 @@ class TableTransformerEngine:
218
  white_rows = np.where(row_projection > threshold)[0]
219
 
220
  if len(white_rows) < 2:
221
- from backend.ocr.tesseract import TesseractEngine
222
- text, _ = TesseractEngine().extract_text(image)
223
  return [[text]]
224
 
225
  row_bands = []
@@ -230,13 +319,11 @@ class TableTransformerEngine:
230
  start = white_rows[i]
231
  row_bands.append((start, white_rows[-1]))
232
 
233
- from backend.ocr.tesseract import TesseractEngine
234
- tesseract = TesseractEngine()
235
  grid = []
236
-
237
  for y1, y2 in row_bands:
238
  row_img = image.crop((0, int(y1), image.width, int(y2)))
239
- text, _ = tesseract.extract_text(row_img)
240
  cols = text.split()
241
  grid.append(cols)
242
 
 
1
+ """Table extraction using Microsoft Table Transformer + EasyOCR."""
2
  import numpy as np
3
  import cv2
4
  import torch
 
9
 
10
 
11
  class TableTransformerEngine:
12
+ """Extract tables from document images using Table Transformer + EasyOCR."""
13
 
14
  MODEL_NAME = "microsoft/table-transformer-structure-recognition"
15
 
 
20
  self.MODEL_NAME
21
  ).to(self.device)
22
  self.model.eval()
23
+ self._easyocr = None
24
+
25
+ def _get_easyocr(self):
26
+ if self._easyocr is None:
27
+ import easyocr
28
+ self._easyocr = easyocr.Reader(['ru', 'en'], gpu=False, verbose=False)
29
+ return self._easyocr
30
 
31
  def detect_tables(self, image: Image.Image) -> list[dict]:
32
  """Detect table regions in image."""
 
65
  ) -> list[list[str]]:
66
  """Extract cell data from a cropped table region.
67
 
68
+ Uses Table Transformer for structure detection and EasyOCR for text.
69
+ Falls back to line-based detection if model misses rows/columns.
70
  """
 
 
71
  x, y, w, h = table_bbox
72
  cropped = image.crop((x, y, x + w, y + h))
73
 
74
+ # Try model-based detection first
75
+ columns, rows = self._detect_structure(cropped)
76
+
77
+ if not columns or not rows:
78
+ # Fallback: detect lines using OpenCV
79
+ columns, rows = self._detect_lines(cropped)
80
+
81
+ if not columns or not rows:
82
+ # Final fallback: horizontal projection
83
+ return self._fallback_extract(cropped)
84
+
85
+ # Sort and deduplicate
86
+ columns.sort(key=lambda b: b[0])
87
+ rows.sort(key=lambda b: b[1])
88
+ columns = self._merge_overlapping(columns, axis=0)
89
+ rows = self._merge_overlapping(rows, axis=1)
90
+
91
+ # Extract text from each cell
92
+ easyocr = self._get_easyocr()
93
+ grid = []
94
+ for row_box in rows:
95
+ ry1, ry2 = int(row_box[1]), int(row_box[3])
96
+ row_texts = []
97
+ for col_box in columns:
98
+ cx1, cx2 = int(col_box[0]), int(col_box[2])
99
+ cell_img = cropped.crop((cx1, ry1, cx2, ry2))
100
+ text = self._extract_cell_text(cell_img, easyocr)
101
+ row_texts.append(text)
102
+ grid.append(row_texts)
103
+
104
+ # Post-process: remove empty rows/columns
105
+ grid = self._clean_grid(grid)
106
+
107
+ return grid
108
+
109
+ def _detect_structure(self, cropped: Image.Image):
110
+ """Detect rows and columns using Table Transformer."""
111
  inputs = self.processor(images=cropped, return_tensors="pt")
112
  inputs = {k: v.to(self.device) for k, v in inputs.items()}
113
 
 
133
  elif label.item() == 2: # row
134
  rows.append(box.tolist())
135
 
136
+ return columns, rows
 
137
 
138
+ def _detect_lines(self, cropped: Image.Image):
139
+ """Fallback: detect table structure using OpenCV line detection."""
140
+ arr = np.array(cropped)
141
+ if len(arr.shape) == 3:
142
+ gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
143
+ else:
144
+ gray = arr
145
 
146
+ # Adaptive threshold for better line detection
147
+ binary = cv2.adaptiveThreshold(
148
+ gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
149
+ cv2.THRESH_BINARY_INV, 11, 2
150
+ )
151
 
152
+ h, w = binary.shape
 
 
 
 
 
 
 
 
 
 
153
 
154
+ # Detect horizontal lines
155
+ horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 1))
156
+ horizontal = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horizontal_kernel, iterations=2)
157
 
158
+ # Detect vertical lines
159
+ vertical_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 40))
160
+ vertical = cv2.morphologyEx(binary, cv2.MORPH_OPEN, vertical_kernel, iterations=2)
161
+
162
+ columns = []
163
+ rows = []
164
+
165
+ # Extract horizontal line positions as row boundaries
166
+ h_coords = np.where(np.sum(horizontal, axis=1) > w * 0.1)[0]
167
+ if len(h_coords) > 0:
168
+ # Cluster nearby horizontal lines
169
+ row_bounds = [int(h_coords[0])]
170
+ for i in range(1, len(h_coords)):
171
+ if h_coords[i] - h_coords[i - 1] > 10:
172
+ row_bounds.append(int(h_coords[i]))
173
+ # Create row boxes between consecutive lines
174
+ for i in range(len(row_bounds) - 1):
175
+ y1, y2 = row_bounds[i], row_bounds[i + 1]
176
+ if y2 - y1 > 5:
177
+ rows.append([0, y1, w, y2])
178
+
179
+ # Extract vertical line positions as column boundaries
180
+ v_coords = np.where(np.sum(vertical, axis=0) > h * 0.1)[0]
181
+ if len(v_coords) > 0:
182
+ col_bounds = [int(v_coords[0])]
183
+ for i in range(1, len(v_coords)):
184
+ if v_coords[i] - v_coords[i - 1] > 10:
185
+ col_bounds.append(int(v_coords[i]))
186
+ for i in range(len(col_bounds) - 1):
187
+ x1, x2 = col_bounds[i], col_bounds[i + 1]
188
+ if x2 - x1 > 5:
189
+ columns.append([x1, 0, x2, h])
190
+
191
+ return columns, rows
192
+
193
+ def _extract_cell_text(self, cell_img: Image.Image, easyocr) -> str:
194
+ """Extract text from a single table cell using EasyOCR."""
195
  processed = self._preprocess_cell_image(cell_img)
196
+ arr = np.array(processed)
197
+
198
+ # CLAHE for contrast enhancement
199
+ if len(arr.shape) == 3:
200
+ gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
201
+ else:
202
+ gray = arr
203
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
204
+ enhanced = clahe.apply(gray)
205
+
206
+ # EasyOCR with detail=1 to get bounding boxes
207
+ result = easyocr.readtext(enhanced, detail=1, paragraph=False)
208
+
209
+ if not result:
210
+ return ""
211
+
212
+ # Sort by y-coordinate then x-coordinate for proper reading order
213
+ result.sort(key=lambda r: (r[0][0][1] // 10 * 10, r[0][0][0]))
214
+
215
+ texts = [r[1] for r in result]
216
+ return " ".join(texts).strip()
217
 
218
  def _preprocess_cell_image(self, image: Image.Image) -> Image.Image:
219
  """Preprocess a cell image for better OCR."""
 
223
  else:
224
  gray = arr
225
 
226
+ # Add padding to avoid cutting text at borders
227
+ gray = cv2.copyMakeBorder(gray, 3, 3, 3, 3, cv2.BORDER_CONSTANT, value=255)
228
 
229
+ # Adaptive threshold — works better than Otsu for screenshots
230
+ binary = cv2.adaptiveThreshold(
231
+ gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
232
+ cv2.THRESH_BINARY, 11, 2
233
  )
234
 
235
  # Upscale if cell is too small
236
  h, w = binary.shape
237
+ if h < 25 or w < 25:
238
+ scale = max(25 / h, 25 / w, 2.0)
239
  binary = cv2.resize(
240
  binary,
241
  (int(w * scale), int(h * scale)),
 
276
 
277
  return merged
278
 
279
+ def _clean_grid(self, grid: list[list[str]]) -> list[list[str]]:
280
+ """Remove empty rows and columns from the grid."""
281
+ if not grid:
282
+ return grid
 
 
 
 
 
283
 
284
+ # Remove completely empty rows
285
+ grid = [row for row in grid if any(cell.strip() for cell in row)]
 
 
 
 
 
286
 
287
+ if not grid:
288
+ return grid
289
 
290
+ # Remove completely empty columns
291
+ n_cols = max(len(row) for row in grid)
292
+ non_empty_cols = []
293
+ for c in range(n_cols):
294
+ if any(row[c].strip() if c < len(row) else "" for row in grid):
295
+ non_empty_cols.append(c)
296
+
297
+ if non_empty_cols:
298
+ grid = [[row[c] if c < len(row) else "" for c in non_empty_cols] for row in grid]
299
+
300
+ return grid
301
 
302
  def _fallback_extract(self, image: Image.Image) -> list[list[str]]:
303
  """Fallback: split image by horizontal projection."""
 
307
  white_rows = np.where(row_projection > threshold)[0]
308
 
309
  if len(white_rows) < 2:
310
+ easyocr = self._get_easyocr()
311
+ text = self._extract_cell_text(image, easyocr)
312
  return [[text]]
313
 
314
  row_bands = []
 
319
  start = white_rows[i]
320
  row_bands.append((start, white_rows[-1]))
321
 
322
+ easyocr = self._get_easyocr()
 
323
  grid = []
 
324
  for y1, y2 in row_bands:
325
  row_img = image.crop((0, int(y1), image.width, int(y2)))
326
+ text = self._extract_cell_text(row_img, easyocr)
327
  cols = text.split()
328
  grid.append(cols)
329