Spaces:
Running on Zero
Running on Zero
feat(extract): mirror VLM deskew preprocessing into the extract Space
Browse filesScanned POs / spec sheets (the high-frequency input routed to hitech-extract) often
arrive tilted, which hurts NuExtract field extraction. Adds _preprocess_image
(Hough-line median-angle deskew, falls back to the raw image on any error) verbatim
from the VLM Space, wires it into _build_messages, and adds opencv-python-headless to
requirements. Closes the long-standing gap (VLM had deskew, extract didn't).
NOTE: takes effect only after a Space redeploy (scripts/sync_spaces.ps1) — HF git is
unreachable from the automated env, so the user runs it. py_compile clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +49 -1
- requirements.txt +1 -0
app.py
CHANGED
|
@@ -30,7 +30,9 @@ import json
|
|
| 30 |
import re
|
| 31 |
from typing import Any
|
| 32 |
|
|
|
|
| 33 |
import gradio as gr
|
|
|
|
| 34 |
import spaces
|
| 35 |
import torch
|
| 36 |
from PIL import Image
|
|
@@ -94,6 +96,51 @@ model = AutoModelForImageTextToText.from_pretrained(
|
|
| 94 |
).eval()
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def _clean_json(text: str) -> str:
|
| 98 |
"""Drop <think> blocks, code fences and prose; keep the JSON object.
|
| 99 |
|
|
@@ -121,7 +168,8 @@ def _clean_json(text: str) -> str:
|
|
| 121 |
def _build_messages(prompt: str, image_path: str | None) -> list[dict]:
|
| 122 |
content: list[dict] = []
|
| 123 |
if image_path:
|
| 124 |
-
|
|
|
|
| 125 |
content.append({"type": "text", "text": prompt})
|
| 126 |
return [{"role": "user", "content": content}]
|
| 127 |
|
|
|
|
| 30 |
import re
|
| 31 |
from typing import Any
|
| 32 |
|
| 33 |
+
import cv2
|
| 34 |
import gradio as gr
|
| 35 |
+
import numpy as np
|
| 36 |
import spaces
|
| 37 |
import torch
|
| 38 |
from PIL import Image
|
|
|
|
| 96 |
).eval()
|
| 97 |
|
| 98 |
|
| 99 |
+
def _preprocess_image(pil_img: Image.Image) -> Image.Image:
|
| 100 |
+
"""Deskew + auto-rotate a scanned document before extraction.
|
| 101 |
+
|
| 102 |
+
Scanned POs and spec sheets — the high-frequency input routed to this Space —
|
| 103 |
+
often arrive slightly tilted from a phone camera or flatbed scanner, which hurts
|
| 104 |
+
NuExtract's field extraction. Mirrors the VLM Space's preprocessing exactly:
|
| 105 |
+
|
| 106 |
+
1. Convert to grayscale and threshold to isolate text/content.
|
| 107 |
+
2. Find the dominant skew angle via Hough lines and rotate to correct it.
|
| 108 |
+
3. Return as RGB PIL Image (unchanged if preprocessing fails for any reason).
|
| 109 |
+
"""
|
| 110 |
+
try:
|
| 111 |
+
img = np.array(pil_img.convert("RGB"))
|
| 112 |
+
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
| 113 |
+
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
| 114 |
+
|
| 115 |
+
# Detect lines to estimate skew angle
|
| 116 |
+
lines = cv2.HoughLinesP(binary, 1, np.pi / 180, threshold=100, minLineLength=100, maxLineGap=10)
|
| 117 |
+
if lines is None or len(lines) == 0:
|
| 118 |
+
return pil_img
|
| 119 |
+
|
| 120 |
+
angles = []
|
| 121 |
+
for line in lines:
|
| 122 |
+
x1, y1, x2, y2 = line[0]
|
| 123 |
+
if x2 != x1:
|
| 124 |
+
angles.append(np.degrees(np.arctan2(y2 - y1, x2 - x1)))
|
| 125 |
+
|
| 126 |
+
if not angles:
|
| 127 |
+
return pil_img
|
| 128 |
+
|
| 129 |
+
# Median angle — robust against outlier lines
|
| 130 |
+
skew = float(np.median(angles))
|
| 131 |
+
# Only correct small skews (> 0.5° and < 45°) to avoid false rotations
|
| 132 |
+
if abs(skew) < 0.5 or abs(skew) > 45:
|
| 133 |
+
return pil_img
|
| 134 |
+
|
| 135 |
+
h, w = img.shape[:2]
|
| 136 |
+
center = (w / 2, h / 2)
|
| 137 |
+
M = cv2.getRotationMatrix2D(center, skew, 1.0)
|
| 138 |
+
rotated = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
| 139 |
+
return Image.fromarray(rotated)
|
| 140 |
+
except Exception:
|
| 141 |
+
return pil_img
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def _clean_json(text: str) -> str:
|
| 145 |
"""Drop <think> blocks, code fences and prose; keep the JSON object.
|
| 146 |
|
|
|
|
| 168 |
def _build_messages(prompt: str, image_path: str | None) -> list[dict]:
|
| 169 |
content: list[dict] = []
|
| 170 |
if image_path:
|
| 171 |
+
img = _preprocess_image(Image.open(image_path).convert("RGB"))
|
| 172 |
+
content.append({"type": "image", "image": img})
|
| 173 |
content.append({"type": "text", "text": prompt})
|
| 174 |
return [{"role": "user", "content": content}]
|
| 175 |
|
requirements.txt
CHANGED
|
@@ -10,3 +10,4 @@ transformers>=4.57.0
|
|
| 10 |
accelerate>=1.0
|
| 11 |
torchvision
|
| 12 |
pillow
|
|
|
|
|
|
| 10 |
accelerate>=1.0
|
| 11 |
torchvision
|
| 12 |
pillow
|
| 13 |
+
opencv-python-headless
|