ocr_backend / src /services /markdown_formatter.py
ashu-choudhury's picture
Add 18 files
06bd65a verified
Raw
History Blame Contribute Delete
10.4 kB
"""
markdown_formatter.py
─────────────────────
Converts raw PaddleOCR bounding-box results into clean, structured Markdown.
PaddleOCR returns a list of items, each shaped as:
[[x1,y1], [x2,y2], [x3,y3], [x4,y4]], (text, confidence)
We use the bounding boxes to reconstruct reading order, detect key-value
pairs, infer headings, and build Markdown tables β€” without any extra models.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import List, Optional, Tuple
# ─────────────────────────────────────────────
# Tunables
# ─────────────────────────────────────────────
CONFIDENCE_THRESHOLD = 0.55 # Drop lines below this score (garbage characters)
HEADING_MAX_WORDS = 6 # Lines with ≀ this many words may be headings
HEADING_MIN_CONF = 0.80 # Headings must be recognised with high confidence
PARA_GAP_MULTIPLIER = 1.6 # Y-gap > avg_line_height Γ— this β†’ new paragraph
TABLE_X_TOLERANCE = 40 # Pixels: columns within this X range are "aligned"
TABLE_MIN_ROWS = 2 # Need at least this many rows to form a table
@dataclass
class OcrLine:
"""One detected text line with all spatial & recognition metadata."""
text: str
confidence: float
x_min: float
x_max: float
y_min: float
y_max: float
@property
def height(self) -> float:
return self.y_max - self.y_min
@property
def word_count(self) -> int:
return len(self.text.split())
@property
def x_center(self) -> float:
return (self.x_min + self.x_max) / 2
# ─────────────────────────────────────────────
# Public entry point
# ─────────────────────────────────────────────
def format_ocr_result(raw_result) -> Tuple[str, str]:
"""
Convert raw PaddleOCR output into both a plain-text string and a
structured Markdown string.
Args:
raw_result: The list returned by ocr.ocr(img, cls=True)[0]
Returns:
(plain_text, markdown_text) β€” both are complete strings
"""
lines = _parse_raw(raw_result)
lines = _filter_low_confidence(lines)
if not lines:
return "", ""
plain_text = "\n".join(l.text for l in lines)
markdown = _build_markdown(lines)
return plain_text, markdown
# ─────────────────────────────────────────────
# Parsing
# ─────────────────────────────────────────────
def _parse_raw(raw_result) -> List[OcrLine]:
"""Turn PaddleOCR raw list into OcrLine objects, sorted top-to-bottom."""
lines: List[OcrLine] = []
if not raw_result:
return lines
for item in raw_result:
try:
box, (text, conf) = item
# box is 4 corner points β€” extract bounding rectangle
xs = [p[0] for p in box]
ys = [p[1] for p in box]
lines.append(OcrLine(
text=text.strip(),
confidence=conf,
x_min=min(xs),
x_max=max(xs),
y_min=min(ys),
y_max=max(ys),
))
except Exception:
continue
# Sort by vertical position so we read top-to-bottom
lines.sort(key=lambda l: l.y_min)
return lines
def _filter_low_confidence(lines: List[OcrLine]) -> List[OcrLine]:
"""Remove lines whose OCR confidence is below the threshold."""
return [l for l in lines if l.confidence >= CONFIDENCE_THRESHOLD]
# ─────────────────────────────────────────────
# Markdown builder
# ─────────────────────────────────────────────
def _build_markdown(lines: List[OcrLine]) -> str:
"""
Walk through sorted lines and emit Markdown tokens.
Strategy:
1. Group lines into paragraphs using Y-gap analysis.
2. Within each group, attempt to detect a Markdown table.
3. Otherwise, classify each line as: heading, key-value pair, or body text.
"""
if not lines:
return ""
avg_height = sum(l.height for l in lines) / len(lines)
para_gap = avg_height * PARA_GAP_MULTIPLIER
# Split into paragraph groups
groups: List[List[OcrLine]] = []
current_group: List[OcrLine] = [lines[0]]
for prev, cur in zip(lines, lines[1:]):
gap = cur.y_min - prev.y_max
if gap > para_gap:
groups.append(current_group)
current_group = [cur]
else:
current_group.append(cur)
groups.append(current_group)
md_parts: List[str] = []
for group in groups:
table_md = _try_build_table(group)
if table_md:
md_parts.append(table_md)
else:
for line in group:
md_parts.append(_classify_line(line))
return "\n\n".join(p for p in md_parts if p.strip())
def _classify_line(line: OcrLine) -> str:
"""
Classify a single line and return its Markdown representation.
Priority:
heading > key-value > body text
"""
# Heading detection: short, high-confidence, uppercase-ish or title-case
if (
line.word_count <= HEADING_MAX_WORDS
and line.confidence >= HEADING_MIN_CONF
and _looks_like_heading(line.text)
):
return f"## {line.text}"
# Key-value detection: "Label: Value" or "Label - Value"
kv = _extract_key_value(line.text)
if kv:
key, val = kv
return f"**{key}:** {val}"
# Default: plain body text
return line.text
def _looks_like_heading(text: str) -> bool:
"""
Heuristic: a line looks like a heading if it is mostly uppercase,
or in title case, and contains no sentence-ending punctuation mid-string.
"""
# Strip trailing punctuation for comparison
core = text.rstrip(".:,;")
if not core:
return False
words = core.split()
# Mostly uppercase words (like "GOVERNMENT OF INDIA")
upper_words = sum(1 for w in words if w.isupper() and len(w) > 1)
if upper_words / len(words) >= 0.6:
return True
# Title case (like "Birth Certificate")
title_words = sum(1 for w in words if w.istitle())
if title_words / len(words) >= 0.7:
return True
return False
def _extract_key_value(text: str) -> Optional[Tuple[str, str]]:
"""
Detect common key-value patterns in OCR'd text.
Examples:
"DOB: 01/06/2007" β†’ ("DOB", "01/06/2007")
"Name of Father - Kumar" β†’ ("Name of Father", "Kumar")
"Aadhaar no.issued:..." β†’ ("Aadhaar no.issued", "...")
"""
# Pattern: anything before ":" or " - " then a value
m = re.match(r'^([A-Za-z][^:\-]{1,40}?)\s*[:\-]\s*(.+)$', text)
if m:
key, val = m.group(1).strip(), m.group(2).strip()
# Reject if key is suspiciously long or value is empty
if val and len(key.split()) <= 6:
return key, val
return None
# ─────────────────────────────────────────────
# Table detection
# ─────────────────────────────────────────────
def _try_build_table(group: List[OcrLine]) -> Optional[str]:
"""
If lines in a group appear to form columns (aligned X-centers),
reconstruct them as a Markdown table.
Returns the table string or None if no table structure is found.
"""
if len(group) < TABLE_MIN_ROWS:
return None
# Collect all unique X-centers and cluster them into columns
x_centers = [l.x_center for l in group]
columns = _cluster_values(x_centers, TABLE_X_TOLERANCE)
# Need at least 2 distinct columns to form a table
if len(columns) < 2:
return None
# Assign each line to a column by nearest center
# Build a row-major dict: row_y_key β†’ {col_idx: text}
row_map: dict = {}
for line in group:
col_idx = _nearest_cluster(line.x_center, columns)
row_key = round(line.y_min / 15) # bucket by ~15px rows
if row_key not in row_map:
row_map[row_key] = {}
row_map[row_key][col_idx] = line.text
rows = [row_map[k] for k in sorted(row_map.keys())]
n_cols = len(columns)
# Build markdown table
header = rows[0] if rows else {}
header_cells = [header.get(i, "") for i in range(n_cols)]
separator = ["---"] * n_cols
md_rows = [
"| " + " | ".join(header_cells) + " |",
"| " + " | ".join(separator) + " |",
]
for row in rows[1:]:
cells = [row.get(i, "") for i in range(n_cols)]
md_rows.append("| " + " | ".join(cells) + " |")
return "\n".join(md_rows)
def _cluster_values(values: List[float], tolerance: float) -> List[float]:
"""Group a list of floats into clusters separated by at least `tolerance`."""
if not values:
return []
sorted_vals = sorted(set(values))
clusters: List[float] = [sorted_vals[0]]
for v in sorted_vals[1:]:
if v - clusters[-1] > tolerance:
clusters.append(v)
else:
# Merge into cluster centroid
clusters[-1] = (clusters[-1] + v) / 2
return clusters
def _nearest_cluster(value: float, clusters: List[float]) -> int:
"""Return the index of the nearest cluster to `value`."""
return min(range(len(clusters)), key=lambda i: abs(clusters[i] - value))