Spaces:
Sleeping
Sleeping
Download ocr_support.py from ozsparkhub/pdf-table-extract-api: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/spaces/ozsparkhub/pdf-table-extract-api/resolve/main/ocr_support.py
- Command line
-
hf download hf://spaces/ozsparkhub/pdf-table-extract-api/ocr_support.py
-
curl -L -o ocr_support.py https://huggingface.co/spaces/ozsparkhub/pdf-table-extract-api/resolve/main/ocr_support.py
17.4 kB
| """Bounded English and Chinese OCR for image-only PDF pages, shared by both converters. | |
| Recognition runs on the existing server. No document is sent to another API. | |
| """ | |
| from contextlib import contextmanager | |
| import csv | |
| import io | |
| import math | |
| import os | |
| import re | |
| from pathlib import Path | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| import time | |
| import pdfplumber | |
| import PyPDF2 | |
| OCR_LANGUAGES = { | |
| 'eng': ('eng', 'English'), | |
| 'chi_sim': ('chi_sim+eng', 'Simplified Chinese and English'), | |
| 'chi_tra': ('chi_tra+eng', 'Traditional Chinese and English'), | |
| } | |
| CJK = r'[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]' | |
| def validate_ocr_language(language): | |
| if language not in OCR_LANGUAGES: | |
| raise OcrError('Choose English, Simplified Chinese + English, or Traditional Chinese + English for OCR.') | |
| return language | |
| def normalise_cjk(text): | |
| # Remove single OCR-inserted spaces between CJK glyphs; retain line breaks. | |
| return re.sub(r'(?<=' + CJK + r') (?=' + CJK + r')', '', str(text)) | |
| MAX_OCR_PAGES = 3 | |
| MAX_RENDER_EDGE = 3600 | |
| OCR_BUDGET_SECONDS = 32 | |
| class OcrError(ValueError): | |
| def __init__(self, message, code=None): | |
| super().__init__(message) | |
| self.code = code | |
| def needs_ocr(page): | |
| text = (page.extract_text() or '').strip() | |
| images = {(image['x0'], image['top'], image['x1'], image['bottom']) for image in page.images} | |
| image_area = sum(max(0, box[2] - box[0]) * max(0, box[3] - box[1]) for box in images) | |
| # A short selectable footer must not hide an otherwise scanned page. | |
| return len(text) < 200 and image_area > page.width * page.height * .55 | |
| def centers(indices): | |
| groups = [] | |
| for value in indices: | |
| if not groups or value - groups[-1][-1] > 2: | |
| groups.append([]) | |
| groups[-1].append(int(value)) | |
| return [int(sum(group) / len(group)) for group in groups] | |
| def preprocess(image): | |
| import cv2 | |
| import numpy as np | |
| gray = np.array(image.convert('L')) | |
| _, ink = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) | |
| # Correct a small skew only when long ruling lines provide a clear angle. | |
| # Edge pixels prevent a solid chart or photo from outvoting real rules. | |
| edges = cv2.Canny(gray, 50, 150) | |
| detected = cv2.HoughLinesP(edges, 1, np.pi / 1800, 100, | |
| minLineLength=gray.shape[1] // 4, maxLineGap=15) | |
| if detected is not None: | |
| angles = [math.degrees(math.atan2(y2 - y1, x2 - x1)) | |
| for x1, y1, x2, y2 in detected[:, 0] | |
| if x2 > x1 and abs(y2 - y1) < (x2 - x1) * .12] | |
| angle = float(np.median(angles)) if len(angles) >= 3 else 0 | |
| if .2 < abs(angle) < 5: | |
| transform = cv2.getRotationMatrix2D((gray.shape[1] / 2, gray.shape[0] / 2), angle, 1) | |
| gray = cv2.warpAffine(gray, transform, (gray.shape[1], gray.shape[0]), | |
| flags=cv2.INTER_CUBIC, borderValue=255) | |
| _, ink = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) | |
| horizontal = cv2.morphologyEx(ink, cv2.MORPH_OPEN, | |
| cv2.getStructuringElement(cv2.MORPH_RECT, (max(40, gray.shape[1] // 30), 1))) | |
| vertical = cv2.morphologyEx(ink, cv2.MORPH_OPEN, | |
| cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(40, gray.shape[0] // 40)))) | |
| # Keep real rules, not the long strokes inside Chinese glyphs. | |
| for mask, axis, minimum in [(horizontal, 2, max(120, gray.shape[1] // 8)), | |
| (vertical, 3, max(120, gray.shape[0] // 25))]: | |
| contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| retained = np.zeros_like(mask) | |
| for contour in contours: | |
| box = cv2.boundingRect(contour) | |
| thickness = box[3 if axis == 2 else 2] | |
| if box[axis] >= minimum and thickness <= max(15, gray.shape[0 if axis == 2 else 1] // 150): | |
| cv2.drawContours(retained, [contour], -1, 255, -1) | |
| mask[:] = retained | |
| rules = cv2.bitwise_or(horizontal, vertical) | |
| clean = gray.copy() | |
| clean[cv2.dilate(rules, np.ones((7, 7), np.uint8)) > 0] = 255 | |
| return clean, horizontal, vertical, ink | |
| def read_words(path, page_number, language='eng'): | |
| with open(path, encoding='utf-8') as stream: | |
| words = [row for row in csv.DictReader(stream, delimiter='\t') | |
| if row['level'] == '5' and row.get('text', '').strip() and float(row['conf']) >= 0] | |
| length = sum(len(word['text']) for word in words) | |
| score = sum(float(word['conf']) * len(word['text']) for word in words) / max(length, 1) | |
| uncertain = sum(len(word['text']) for word in words if float(word['conf']) < 40) / max(length, 1) | |
| chinese = sum(len(re.findall(CJK, word['text'])) for word in words) | |
| minimum = 20 if language != 'eng' and chinese >= 6 else 40 | |
| if length < minimum or score < 70 or uncertain > .2: | |
| raise OcrError(f'Page {page_number}: OCR could not read this scan reliably. Use a clearer, upright printed scan and select its OCR language. Handwriting and vertical Chinese are not supported.', code='unreadable') | |
| return words | |
| def table_grids(horizontal, vertical, ink, words, dpi): | |
| """Keep real row separators; infer columns only from clear full-height gutters.""" | |
| import cv2 | |
| import numpy as np | |
| contours, _ = cv2.findContours(horizontal, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| rules = sorted([cv2.boundingRect(item) for item in contours], key=lambda box: box[1]) | |
| groups = [] | |
| for x, y, width, height in rules: | |
| if width < ink.shape[1] * .35: | |
| continue | |
| group = next((group for group in groups | |
| if abs(x - group['x']) < dpi / 24 and abs(width - group['width']) < dpi / 16), None) | |
| if group is None: | |
| group = {'x': x, 'width': width, 'ys': []} | |
| groups.append(group) | |
| group['ys'].append(y + height // 2) | |
| tables = [] | |
| for group in groups: | |
| ys = centers(sorted(group['ys'])) | |
| if len(ys) < 3: | |
| continue | |
| spacing = float(np.median(np.diff(ys))) | |
| if not dpi * 6 / 72 <= spacing <= dpi * 100 / 72: | |
| continue | |
| x0, x1 = group['x'], group['x'] + group['width'] - 1 | |
| top = ys[0] | |
| # Header rows often have only a bottom rule. Include an aligned header | |
| # immediately above that rule, while excluding a preceding paragraph. | |
| above = [word for word in words | |
| if top - spacing * 1.2 < int(word['top']) + int(word['height']) / 2 < top | |
| and x0 <= int(word['left']) <= x1] | |
| if above: | |
| last_baseline = max(int(word['top']) for word in above) | |
| above = sorted([word for word in above if abs(int(word['top']) - last_baseline) < dpi * 5 / 72], | |
| key=lambda word: int(word['left'])) | |
| gaps = sum(int(right['left']) - int(left['left']) - int(left['width']) > dpi * 12 / 72 | |
| for left, right in zip(above, above[1:])) | |
| if gaps >= 2: | |
| top = max(0, min(int(word['top']) for word in above) - int(dpi * 4 / 72)) | |
| ys.insert(0, top) | |
| bottom = ys[-1] | |
| if bottom <= top: | |
| continue | |
| region = ink[top:bottom + 1, x0:x1 + 1].copy() | |
| region[horizontal[top:bottom + 1, x0:x1 + 1] > 0] = 0 | |
| vertical_strength = np.count_nonzero(vertical[top:bottom + 1, x0:x1 + 1], axis=0) | |
| xs = [x0 + value for value in centers(np.where(vertical_strength > (bottom - top) * .8)[0])] | |
| if len(xs) < 3: | |
| occupied = np.count_nonzero(region, axis=0) | |
| blank = np.where(occupied <= 1)[0] | |
| runs = [] | |
| for value in blank: | |
| if not runs or value > runs[-1][-1] + 1: | |
| runs.append([]) | |
| runs[-1].append(int(value)) | |
| gutters = [x0 + int((run[0] + run[-1]) / 2) for run in runs | |
| if len(run) >= dpi * 8 / 72 and run[0] > dpi * 6 / 72 | |
| and run[-1] < x1 - x0 - dpi * 6 / 72] | |
| xs = [x0, *gutters, x1] | |
| if not 3 <= len(xs) <= 13: | |
| continue | |
| # Every proposed column must contain recognised text, including a header. | |
| if not all(any(left < int(word['left']) + int(word['width']) / 2 < right | |
| and top < int(word['top']) + int(word['height']) / 2 < bottom | |
| for word in words) for left, right in zip(xs, xs[1:])): | |
| continue | |
| tables.append({'xs': xs, 'ys': ys}) | |
| return tables | |
| def financial_table_grids(words, dpi, existing): | |
| """Recover common borderless invoices only with a clear header and numeric rows.""" | |
| rows = [] | |
| for word in sorted(words, key=lambda item: (int(item['top']) + int(item['height']) / 2, int(item['left']))): | |
| center = int(word['top']) + int(word['height']) / 2 | |
| if not rows or abs(center - rows[-1]['center']) > dpi * 4 / 72: | |
| rows.append({'center': center, 'words': []}) | |
| rows[-1]['words'].append(word) | |
| def groups(row): | |
| result = [] | |
| for word in sorted(row['words'], key=lambda item: int(item['left'])): | |
| left, right = int(word['left']), int(word['left']) + int(word['width']) | |
| if not result or left - result[-1]['right'] > dpi * 12 / 72: | |
| result.append({'left': left, 'right': right, 'text': word['text']}) | |
| else: | |
| result[-1]['right'] = max(right, result[-1]['right']) | |
| result[-1]['text'] += ' ' + word['text'] | |
| return result | |
| def bounds(row): | |
| return min(int(word['top']) for word in row['words']), max(int(word['top']) + int(word['height']) for word in row['words']) | |
| result = [] | |
| for index, row in enumerate(rows): | |
| header = groups(row) | |
| if not 3 <= len(header) <= 8: | |
| continue | |
| keywords = re.findall(r'\b(?:quantity|qty|amount|price|total|balance|debit|credit|rate|hours)\b', | |
| normalise_cjk(' '.join(group['text'] for group in header)), re.I) | |
| keywords += re.findall(r'数量|數量|金额|金額|单价|單價|合计|合計|总计|總計|余额|餘額|税额|稅額|工时|工時', normalise_cjk(' '.join(group['text'] for group in header))) | |
| if len(keywords) < 2: | |
| continue | |
| top, bottom = bounds(row) | |
| if any(grid['ys'][0] <= row['center'] <= grid['ys'][-1] for grid in existing): | |
| continue | |
| selected = [(row, header)] | |
| for candidate in rows[index + 1:]: | |
| candidate_top, candidate_bottom = bounds(candidate) | |
| if candidate_top - bottom > dpi * 36 / 72: | |
| break | |
| cells = groups(candidate) | |
| numeric_end = bool(cells and re.fullmatch(r'(?:[$£€¥¥]|[A-Z]{3})?\s*[+-]?\(?\d[\d,.]*\)?%?', cells[-1]['text'].strip())) | |
| if len(cells) < 2 or not numeric_end: | |
| break | |
| selected.append((candidate, cells)) | |
| bottom = candidate_bottom | |
| if len(selected) < 2: | |
| continue | |
| full_rows = [cells for _, cells in selected if len(cells) == len(header)] | |
| if len(full_rows) < 2: | |
| continue | |
| xs = [min(cells[0]['left'] for cells in full_rows) - dpi * 3 / 72] | |
| for column in range(len(header) - 1): | |
| right = max(cells[column]['right'] for cells in full_rows) | |
| left = min(cells[column + 1]['left'] for cells in full_rows) | |
| if left - right < dpi * 3 / 72: | |
| break | |
| xs.append((right + left) / 2) | |
| if len(xs) != len(header): | |
| continue | |
| xs.append(max(cells[-1]['right'] for _, cells in selected) + dpi * 3 / 72) | |
| ys = [top - dpi * 3 / 72] | |
| for (previous, _), (following, _) in zip(selected, selected[1:]): | |
| ys.append((bounds(previous)[1] + bounds(following)[0]) / 2) | |
| ys.append(bottom + dpi * 3 / 72) | |
| result.append({'xs': xs, 'ys': ys, 'row_groups': [cells for _, cells in selected]}) | |
| return result | |
| def add_table_rules(pdf_path, grids, image_size, output): | |
| if not grids: | |
| return pdf_path | |
| from reportlab.pdfgen import canvas | |
| reader = PyPDF2.PdfReader(pdf_path) | |
| page = reader.pages[0] | |
| width, height = float(page.mediabox.width), float(page.mediabox.height) | |
| scale_x, scale_y = width / image_size[0], height / image_size[1] | |
| overlay = io.BytesIO() | |
| drawing = canvas.Canvas(overlay, pagesize=(width, height)) | |
| drawing.setLineWidth(.3) | |
| for grid in grids: | |
| xs, ys = grid['xs'], grid['ys'] | |
| for y in ys: | |
| drawing.line(xs[0] * scale_x, height - y * scale_y, xs[-1] * scale_x, height - y * scale_y) | |
| for column, x in enumerate(xs): | |
| for row, (top, bottom) in enumerate(zip(ys, ys[1:])): | |
| merged = 0 < column < len(xs) - 1 and any(group['left'] < x < group['right'] | |
| for group in grid.get('row_groups', [[] for _ in ys])[row]) | |
| if not merged: | |
| drawing.line(x * scale_x, height - top * scale_y, x * scale_x, height - bottom * scale_y) | |
| drawing.save() | |
| overlay.seek(0) | |
| page.merge_page(PyPDF2.PdfReader(overlay).pages[0]) | |
| writer = PyPDF2.PdfWriter() | |
| writer.add_page(page) | |
| with open(output, 'wb') as stream: | |
| writer.write(stream) | |
| return output | |
| def recognise_page(page, page_number, workdir, deadline, require_table, language='eng'): | |
| from PIL import Image | |
| executable = shutil.which('tesseract') | |
| if not executable: | |
| raise OcrError('OCR is temporarily unavailable. Try a PDF with selectable text or return later.') | |
| dpi = min(300, MAX_RENDER_EDGE * 72 / max(page.width, page.height)) | |
| image = page.to_image(resolution=dpi).original | |
| clean, horizontal, vertical, ink = preprocess(image) | |
| image.close() | |
| prefix = Path(workdir).resolve() / f'ocr-{page_number}' | |
| Image.fromarray(clean).save(str(prefix) + '.png', dpi=(dpi, dpi)) | |
| remaining = deadline - time.monotonic() | |
| if remaining <= 1: | |
| raise OcrError('OCR reached the processing time limit. Split this PDF into fewer scanned pages and try again.') | |
| try: | |
| process = subprocess.run([executable, str(prefix) + '.png', str(prefix), '-l', OCR_LANGUAGES[language][0], | |
| '--dpi', str(round(dpi)), '--psm', '3', 'pdf', 'tsv'], | |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE, | |
| timeout=min(15, remaining), env={**os.environ, 'OMP_THREAD_LIMIT': '2'}) | |
| except subprocess.TimeoutExpired as error: | |
| raise OcrError(f'Page {page_number}: OCR timed out. Try a clearer scan or split the PDF into fewer pages.') from error | |
| if process.returncode != 0: | |
| raise OcrError(f'Page {page_number}: OCR could not process this scan. Try a clearer, upright printed scan and check the OCR language.') | |
| words = read_words(str(prefix) + '.tsv', page_number, language) | |
| grids = table_grids(horizontal, vertical, ink, words, dpi) | |
| grids.extend(financial_table_grids(words, dpi, grids)) | |
| if require_table and not grids: | |
| raise OcrError(f'Page {page_number}: no clear scanned table was found. Use visible row separators or clear quantity/amount headers. Complex or merged scan layouts need manual conversion.') | |
| return add_table_rules(str(prefix) + '.pdf', grids, (clean.shape[1], clean.shape[0]), str(prefix) + '-tables.pdf') | |
| def prepare_for_extraction(pdf_path, require_table=False, enabled=True, ocr_language='eng', fallback_pages=None): | |
| validate_ocr_language(ocr_language) | |
| deadline = time.monotonic() + OCR_BUDGET_SECONDS | |
| with pdfplumber.open(pdf_path) as pdf: | |
| scanned = [index for index, page in enumerate(pdf.pages) if needs_ocr(page)] | |
| if not scanned: | |
| yield pdf_path, 0 | |
| return | |
| if not enabled: | |
| raise OcrError('This PDF needs OCR. Reload the converter to use scan recognition, then try again.') | |
| if len(scanned) > MAX_OCR_PAGES: | |
| raise OcrError('OCR supports up to 3 scanned pages per PDF. Use Split PDF first; text-based PDFs can still have up to 10 pages.') | |
| with tempfile.TemporaryDirectory(prefix='pdf-ocr-') as workdir: | |
| reader = PyPDF2.PdfReader(pdf_path) | |
| writer = PyPDF2.PdfWriter() | |
| for index, page in enumerate(pdf.pages): | |
| if index in scanned: | |
| try: | |
| recognised = recognise_page(page, index + 1, workdir, deadline, require_table, ocr_language) | |
| except OcrError as error: | |
| if fallback_pages is None or error.code != 'unreadable': | |
| raise | |
| fallback_pages.append(index) | |
| writer.add_page(reader.pages[index]) | |
| else: | |
| writer.add_page(PyPDF2.PdfReader(recognised).pages[0]) | |
| else: | |
| writer.add_page(reader.pages[index]) | |
| output = str(Path(workdir) / 'recognised.pdf') | |
| with open(output, 'wb') as stream: | |
| writer.write(stream) | |
| yield output, len(scanned) - len(fallback_pages or []) | |