don0726 commited on
Commit
d44cd95
Β·
verified Β·
1 Parent(s): 912c901

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +221 -29
app.py CHANGED
@@ -6,22 +6,27 @@ built-in "Free OCR" prompt mode (plain text extraction, no markdown/
6
  grounding boxes). A "Markdown" mode is also included since it's a
7
  one-line prompt swap.
8
 
9
- This version runs on CPU only (no CUDA, no flash-attention). The model
10
- stays in bfloat16, matching hardcoded dtype casts inside its own custom
11
- code β€” PyTorch supports bfloat16 tensors on CPU, just without hardware
12
- acceleration, so it works but is slow. DeepSeek-OCR is a vision-language
13
- model, so expect anywhere from ~30s to a few minutes per image depending
14
- on resolution preset and CPU. Use the "Tiny" or "Small" resolution
15
- presets for faster (if lower-fidelity) results.
 
16
  """
17
 
 
18
  import os
19
  import re
20
  import shutil
21
  import tempfile
22
 
 
23
  import gradio as gr
 
24
  import torch
 
25
  from transformers import AutoModel, AutoTokenizer
26
 
27
  MODEL_NAME = "deepseek-ai/DeepSeek-OCR"
@@ -30,6 +35,21 @@ MODEL_NAME = "deepseek-ai/DeepSeek-OCR"
30
  os.environ["CUDA_VISIBLE_DEVICES"] = ""
31
  DEVICE = "cpu"
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  # ---------------------------------------------------------------------------
34
  # Model loading (once, at startup)
35
  # ---------------------------------------------------------------------------
@@ -43,13 +63,6 @@ model = AutoModel.from_pretrained(
43
  trust_remote_code=True,
44
  use_safetensors=True,
45
  )
46
- # NOTE: don't cast to float32 here. DeepSeek-OCR's own forward pass hardcodes
47
- # casting image inputs to bfloat16 internally (independent of the model's
48
- # actual dtype), so the model's weights must also be bfloat16 or you get a
49
- # "Input type (BFloat16) and bias type (float) should be the same" crash.
50
- # PyTorch CPU does support bfloat16 tensors/ops (just without hardware
51
- # acceleration), so this works, it's just slow.
52
- model = model.eval().to(DEVICE).to(torch.bfloat16)
53
 
54
  # ---------------------------------------------------------------------------
55
  # DeepSeek-OCR's own `model.infer()` method has `.cuda()` calls hardcoded
@@ -59,10 +72,60 @@ model = model.eval().to(DEVICE).to(torch.bfloat16)
59
  # on CPU here, we monkey-patch `.cuda()` into a no-op that just returns the
60
  # tensor/module unchanged β€” this makes those hardcoded calls harmless instead
61
  # of failing.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  if not torch.cuda.is_available():
63
  torch.Tensor.cuda = lambda self, *args, **kwargs: self
64
  torch.nn.Module.cuda = lambda self, *args, **kwargs: self
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  # Preset resolution modes exposed by the model's own infer() signature
67
  SIZE_PRESETS = {
68
  "Tiny (512, fastest)": dict(base_size=512, image_size=512, crop_mode=False),
@@ -75,21 +138,137 @@ SIZE_PRESETS = {
75
  TASK_PROMPTS = {
76
  "Free OCR (plain text)": "<image>\nFree OCR. ",
77
  "Markdown (structure + tables)": "<image>\n<|grounding|>Convert the document to markdown. ",
 
78
  }
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
- def run_ocr(image, task_choice, size_choice, progress=gr.Progress(track_tqdm=True)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  if image is None:
83
- return "Please upload an image first."
84
 
85
  prompt = TASK_PROMPTS[task_choice]
86
  preset = SIZE_PRESETS[size_choice]
87
 
 
 
 
 
88
  # model.infer() reads the image from a file path and writes its result to
89
  # output_path. Point both at a temp dir.
90
  tmp_dir = tempfile.mkdtemp(prefix="deepseek_ocr_")
91
  image_path = os.path.join(tmp_dir, "input.png")
92
- image.convert("RGB").save(image_path)
93
 
94
  try:
95
  with torch.no_grad():
@@ -112,20 +291,26 @@ def run_ocr(image, task_choice, size_choice, progress=gr.Progress(track_tqdm=Tru
112
 
113
  result_file = os.path.join(tmp_dir, "result.mmd")
114
  if not os.path.exists(result_file):
115
- return ("No output file was produced. Try a different resolution "
116
- "preset, or check the Space logs for errors.")
 
117
 
118
  with open(result_file, "r", encoding="utf-8") as f:
119
- text = f.read()
120
 
121
- # Free OCR output is plain text already; Markdown/grounding output
122
- # can contain <|ref|>...<|/ref|> and <|det|>[[...]]<|/det|> tags β€”
123
- # strip those so the box shows clean readable text either way.
124
- text = re.sub(r"<\|ref\|>.*?<\|/ref\|>", "", text, flags=re.DOTALL)
 
 
 
 
 
 
125
  text = re.sub(r"<\|det\|>\[\[.*?\]\]<\|/det\|>", "", text, flags=re.DOTALL)
126
  text = text.strip()
127
-
128
- return text if text else "No text was detected in this image."
129
  finally:
130
  shutil.rmtree(tmp_dir, ignore_errors=True)
131
 
@@ -155,17 +340,24 @@ with gr.Blocks(title="DeepSeek-OCR (CPU)") as demo:
155
  value="Gundam (1024/640 crop, best for documents)",
156
  label="Resolution mode (lower = faster on CPU)",
157
  )
 
 
 
 
158
  run_button = gr.Button("Run OCR", variant="primary")
159
 
160
  with gr.Column():
161
  output_text = gr.Textbox(
162
- label="Extracted text", lines=25
 
 
 
163
  )
164
 
165
  run_button.click(
166
  fn=run_ocr,
167
- inputs=[image_input, task_dropdown, size_dropdown],
168
- outputs=output_text,
169
  )
170
 
171
  if __name__ == "__main__":
 
6
  grounding boxes). A "Markdown" mode is also included since it's a
7
  one-line prompt swap.
8
 
9
+ This version runs on CPU only (no CUDA, no flash-attention). The model runs
10
+ in float32 rather than bfloat16 β€” most CPUs have no fast bf16 GEMM kernel,
11
+ so float32 is typically 2x+ faster here β€” and PyTorch's thread count is
12
+ pinned to the number of available cores. DeepSeek-OCR is a vision-language
13
+ model, so even with these speedups expect anywhere from ~15s to a couple
14
+ of minutes per image depending on resolution preset and CPU. Use the
15
+ "Tiny" or "Small" resolution presets, and the auto-crop option, for the
16
+ fastest results.
17
  """
18
 
19
+ import ast
20
  import os
21
  import re
22
  import shutil
23
  import tempfile
24
 
25
+ import cv2
26
  import gradio as gr
27
+ import numpy as np
28
  import torch
29
+ from PIL import Image, ImageDraw, ImageFont
30
  from transformers import AutoModel, AutoTokenizer
31
 
32
  MODEL_NAME = "deepseek-ai/DeepSeek-OCR"
 
35
  os.environ["CUDA_VISIBLE_DEVICES"] = ""
36
  DEVICE = "cpu"
37
 
38
+ # ---------------------------------------------------------------------------
39
+ # CPU threading: containers sometimes leave PyTorch's thread count lower
40
+ # than the number of cores actually available, which silently caps
41
+ # performance. Pin it explicitly.
42
+ # ---------------------------------------------------------------------------
43
+ _num_cores = os.cpu_count() or 1
44
+ torch.set_num_threads(_num_cores)
45
+ try:
46
+ # Must be called before any inter-op parallel work has happened; if
47
+ # something already triggered that, this raises β€” safe to ignore.
48
+ torch.set_num_interop_threads(max(1, _num_cores // 2))
49
+ except RuntimeError:
50
+ pass
51
+ print(f"PyTorch using {_num_cores} threads for CPU inference.")
52
+
53
  # ---------------------------------------------------------------------------
54
  # Model loading (once, at startup)
55
  # ---------------------------------------------------------------------------
 
63
  trust_remote_code=True,
64
  use_safetensors=True,
65
  )
 
 
 
 
 
 
 
66
 
67
  # ---------------------------------------------------------------------------
68
  # DeepSeek-OCR's own `model.infer()` method has `.cuda()` calls hardcoded
 
72
  # on CPU here, we monkey-patch `.cuda()` into a no-op that just returns the
73
  # tensor/module unchanged β€” this makes those hardcoded calls harmless instead
74
  # of failing.
75
+ #
76
+ # The same custom code also hardcodes casting some tensors to bfloat16 via
77
+ # `.bfloat16()`, independent of the model's own weight dtype. Most CPUs
78
+ # (anything short of newer server Xeons with AVX512_BF16/AMX, or recent
79
+ # high-end AMD/Apple silicon) have no fast vectorized bf16 GEMM kernel, so
80
+ # PyTorch silently falls back to a much slower generic path for bf16 on
81
+ # those CPUs β€” often 2-4x slower than plain float32, which does have fast
82
+ # native kernels everywhere. We keep the whole model in float32 and
83
+ # monkey-patch `.bfloat16()` to a no-op (returning the tensor/module
84
+ # converted to float32 instead) so those hardcoded casts don't undo it and
85
+ # don't crash on a dtype mismatch either.
86
+ #
87
+ # NOTE: if you know for a fact your Space's CPU has native bf16
88
+ # acceleration (uncommon on free/shared tiers), this patch may not help,
89
+ # or could even hurt β€” comment out this block and use `.to(torch.bfloat16)`
90
+ # below instead to compare.
91
  if not torch.cuda.is_available():
92
  torch.Tensor.cuda = lambda self, *args, **kwargs: self
93
  torch.nn.Module.cuda = lambda self, *args, **kwargs: self
94
 
95
+ def _redirect_bf16_to_fp32(args, kwargs):
96
+ args = list(args)
97
+ for i, a in enumerate(args):
98
+ if a is torch.bfloat16:
99
+ args[i] = torch.float32
100
+ if kwargs.get("dtype") is torch.bfloat16:
101
+ kwargs["dtype"] = torch.float32
102
+ return tuple(args), kwargs
103
+
104
+ # `.bfloat16()` patch (in case that exact method is called anywhere)...
105
+ torch.Tensor.bfloat16 = lambda self, *args, **kwargs: self.float()
106
+ torch.nn.Module.bfloat16 = lambda self, *args, **kwargs: self.float()
107
+
108
+ # ...but the actual crash showed the model's code uses
109
+ # `.to(torch.bfloat16)` / `.to(dtype=torch.bfloat16)` instead, which
110
+ # bypasses the patch above entirely (`.to()` is a different method).
111
+ # Intercept `.to()` itself and swap any bfloat16 request for float32,
112
+ # for both tensors and modules.
113
+ _orig_tensor_to = torch.Tensor.to
114
+ _orig_module_to = torch.nn.Module.to
115
+
116
+ def _patched_tensor_to(self, *args, **kwargs):
117
+ args, kwargs = _redirect_bf16_to_fp32(args, kwargs)
118
+ return _orig_tensor_to(self, *args, **kwargs)
119
+
120
+ def _patched_module_to(self, *args, **kwargs):
121
+ args, kwargs = _redirect_bf16_to_fp32(args, kwargs)
122
+ return _orig_module_to(self, *args, **kwargs)
123
+
124
+ torch.Tensor.to = _patched_tensor_to
125
+ torch.nn.Module.to = _patched_module_to
126
+
127
+ model = model.eval().to(DEVICE).to(torch.float32)
128
+
129
  # Preset resolution modes exposed by the model's own infer() signature
130
  SIZE_PRESETS = {
131
  "Tiny (512, fastest)": dict(base_size=512, image_size=512, crop_mode=False),
 
138
  TASK_PROMPTS = {
139
  "Free OCR (plain text)": "<image>\nFree OCR. ",
140
  "Markdown (structure + tables)": "<image>\n<|grounding|>Convert the document to markdown. ",
141
+ "Detect text regions (numbered boxes, no text output)": "<image>\n<|grounding|>OCR this image. ",
142
  }
143
 
144
+ # The model emits boxes as <|ref|>label<|/ref|><|det|>[[x1,y1,x2,y2], ...]<|/det|>
145
+ # with coordinates normalized to a 0-999 scale (independent of actual image
146
+ # size), in the order the model reads them β€” so numbering them in the order
147
+ # they appear already gives a natural left-to-right/top-to-bottom-ish
148
+ # reading-order sequence.
149
+ _REF_DET_RE = re.compile(
150
+ r"<\|ref\|>(.*?)<\|/ref\|>\s*<\|det\|>\s*(\[\[.*?\]\])\s*<\|/det\|>",
151
+ re.DOTALL,
152
+ )
153
+
154
+
155
+ def _load_font(size: int):
156
+ try:
157
+ return ImageFont.truetype(
158
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size
159
+ )
160
+ except Exception:
161
+ return ImageFont.load_default()
162
+
163
+
164
+ def parse_grounding_boxes(raw_text: str):
165
+ """
166
+ Extract [(label, [x1, y1, x2, y2]), ...] box groups from the model's raw
167
+ grounding output, in the order they appear. Coordinates are 0-999
168
+ normalized; the caller scales them to the actual image size. A single
169
+ <|ref|>/<|det|> pair can itself contain multiple boxes (e.g. a repeated
170
+ word) β€” those are flattened into separate entries in order too.
171
+ """
172
+ boxes = []
173
+ for label, coords_str in _REF_DET_RE.findall(raw_text):
174
+ try:
175
+ coord_list = ast.literal_eval(coords_str)
176
+ except (ValueError, SyntaxError):
177
+ continue
178
+ for coords in coord_list:
179
+ if len(coords) == 4:
180
+ boxes.append((label.strip(), coords))
181
+ return boxes
182
+
183
+
184
+ def draw_numbered_boxes(image: Image.Image, boxes) -> Image.Image:
185
+ """Draw each box from parse_grounding_boxes() with a sequence number
186
+ (1, 2, 3, ...) in the order given β€” no recognized text is shown."""
187
+ img_out = image.convert("RGB").copy()
188
+ draw = ImageDraw.Draw(img_out)
189
+ w, h = img_out.size
190
+ font = _load_font(max(14, min(w, h) // 40))
191
+
192
+ for i, (_, (x1, y1, x2, y2)) in enumerate(boxes, start=1):
193
+ # Coordinates are normalized 0-999 regardless of actual image size.
194
+ px1, py1 = x1 / 999 * w, y1 / 999 * h
195
+ px2, py2 = x2 / 999 * w, y2 / 999 * h
196
 
197
+ draw.rectangle([px1, py1, px2, py2], outline=(255, 0, 0), width=3)
198
+
199
+ label = str(i)
200
+ text_bbox = draw.textbbox((0, 0), label, font=font)
201
+ tw, th = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]
202
+ tag_x0, tag_y0 = px1, max(0, py1 - th - 6)
203
+ draw.rectangle([tag_x0, tag_y0, tag_x0 + tw + 6, tag_y0 + th + 6],
204
+ fill=(255, 0, 0))
205
+ draw.text((tag_x0 + 3, tag_y0 + 2), label, fill=(255, 255, 255), font=font)
206
+
207
+ return img_out
208
+
209
+
210
+ def auto_crop_to_content(pil_img: Image.Image, pad_frac: float = 0.03) -> Image.Image:
211
+ """
212
+ Crop out large flat background regions (wall, table, frame margin, etc.)
213
+ so the model only has to encode the actual document/text area.
214
+
215
+ This is a cheap OpenCV threshold + bounding-box operation, not another
216
+ neural model β€” it costs a few milliseconds, versus the multi-second/
217
+ multi-tile cost DeepSeek-OCR would otherwise spend encoding background
218
+ pixels (especially in "Gundam" crop mode, where a large image gets
219
+ split into several 640x640 tiles). If nothing usable is found, the
220
+ original image is returned unchanged.
221
+ """
222
+ img = np.array(pil_img.convert("RGB"))
223
+ gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
224
+
225
+ # Otsu's method picks a threshold automatically; THRESH_BINARY_INV so
226
+ # content (usually darker than a plain background) shows up as
227
+ # foreground. Works well for "document/poster on a plain background"
228
+ # photos; less reliable for busy/textured backgrounds.
229
+ _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
230
+
231
+ # Close small gaps so text lines merge into solid blobs rather than
232
+ # hundreds of tiny disconnected contours.
233
+ kernel = np.ones((15, 15), np.uint8)
234
+ thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
235
+
236
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
237
+ if not contours:
238
+ return pil_img
239
+
240
+ all_points = np.vstack(contours)
241
+ x, y, w, h = cv2.boundingRect(all_points)
242
+
243
+ # Bail out if the detected region is basically the whole image already
244
+ # (nothing to gain) or suspiciously tiny (likely noise, not content).
245
+ img_area = img.shape[0] * img.shape[1]
246
+ box_area = w * h
247
+ if box_area < 0.02 * img_area or box_area > 0.98 * img_area:
248
+ return pil_img
249
+
250
+ pad_x, pad_y = int(w * pad_frac), int(h * pad_frac)
251
+ x0, y0 = max(0, x - pad_x), max(0, y - pad_y)
252
+ x1, y1 = min(img.shape[1], x + w + pad_x), min(img.shape[0], y + h + pad_y)
253
+ return pil_img.crop((x0, y0, x1, y1))
254
+
255
+
256
+ def run_ocr(image, task_choice, size_choice, auto_crop, progress=gr.Progress(track_tqdm=True)):
257
  if image is None:
258
+ return "Please upload an image first.", None
259
 
260
  prompt = TASK_PROMPTS[task_choice]
261
  preset = SIZE_PRESETS[size_choice]
262
 
263
+ image = image.convert("RGB")
264
+ if auto_crop:
265
+ image = auto_crop_to_content(image)
266
+
267
  # model.infer() reads the image from a file path and writes its result to
268
  # output_path. Point both at a temp dir.
269
  tmp_dir = tempfile.mkdtemp(prefix="deepseek_ocr_")
270
  image_path = os.path.join(tmp_dir, "input.png")
271
+ image.save(image_path)
272
 
273
  try:
274
  with torch.no_grad():
 
291
 
292
  result_file = os.path.join(tmp_dir, "result.mmd")
293
  if not os.path.exists(result_file):
294
+ msg = ("No output file was produced. Try a different resolution "
295
+ "preset, or check the Space logs for errors.")
296
+ return msg, None
297
 
298
  with open(result_file, "r", encoding="utf-8") as f:
299
+ raw_text = f.read()
300
 
301
+ if task_choice == "Detect text regions (numbered boxes, no text output)":
302
+ boxes = parse_grounding_boxes(raw_text)
303
+ if not boxes:
304
+ return ("No text regions were detected in this image.", None)
305
+ annotated = draw_numbered_boxes(image, boxes)
306
+ return (f"Detected {len(boxes)} text region(s) β€” see numbered "
307
+ f"boxes on the image.", annotated)
308
+
309
+ # Plain-text tasks: strip grounding markup if present and show text.
310
+ text = re.sub(r"<\|ref\|>.*?<\|/ref\|>", "", raw_text, flags=re.DOTALL)
311
  text = re.sub(r"<\|det\|>\[\[.*?\]\]<\|/det\|>", "", text, flags=re.DOTALL)
312
  text = text.strip()
313
+ return (text if text else "No text was detected in this image.", None)
 
314
  finally:
315
  shutil.rmtree(tmp_dir, ignore_errors=True)
316
 
 
340
  value="Gundam (1024/640 crop, best for documents)",
341
  label="Resolution mode (lower = faster on CPU)",
342
  )
343
+ auto_crop_checkbox = gr.Checkbox(
344
+ value=True,
345
+ label="Auto-crop background before OCR (faster on photos with a lot of empty space)",
346
+ )
347
  run_button = gr.Button("Run OCR", variant="primary")
348
 
349
  with gr.Column():
350
  output_text = gr.Textbox(
351
+ label="Extracted text / status", lines=15
352
+ )
353
+ output_image = gr.Image(
354
+ label="Detected regions (numbered boxes)", visible=True
355
  )
356
 
357
  run_button.click(
358
  fn=run_ocr,
359
+ inputs=[image_input, task_dropdown, size_dropdown, auto_crop_checkbox],
360
+ outputs=[output_text, output_image],
361
  )
362
 
363
  if __name__ == "__main__":