Hfzune commited on
Commit
29f9e56
·
verified ·
1 Parent(s): 641c7b4

Upload 4 files

Browse files
Files changed (3) hide show
  1. README.md +7 -27
  2. app.py +400 -802
  3. requirements.txt +1 -2
README.md CHANGED
@@ -1,39 +1,19 @@
1
  ---
2
- title: DeepSeek-OCR Full-Page CSV
3
  emoji: 📄
4
  colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 5.49.1
8
- python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- # DeepSeek-OCR full-page CSV Space
14
 
15
- This Space runs `strangervisionhf/deepseek-ocr-transformers-v4.57.6`, provides full-page OCR, CSV preview/download, and an optional Telegram polling bot.
16
 
17
- ## Configuration
18
 
19
- In **Settings → Variables and secrets**, set:
20
-
21
- - Secret `TELEGRAM_BOT_TOKEN`: your Telegram bot token. Do not put it in source files. If a token has been exposed, revoke it with BotFather and replace this secret.
22
- - Variable `OCR_PROFILE`: `gundam`, `small`, or `tiny`.
23
- - Variable `GPU_DURATION_SECONDS`: requested GPU reservation duration. The platform's allowed duration/quota still applies.
24
-
25
- ## Install these files
26
-
27
- Keep `README.md`, `requirements.txt`, and `app.py` in the **root** of the Space repository. Commit them together, then use **Factory Rebuild** so the SDK version in this README is applied.
28
-
29
- The Gradio version is selected here by `sdk_version`. Do not add a separate `gradio` line to `requirements.txt`.
30
-
31
- ## CSV fields
32
-
33
- `voter_id`, `name`, `relative_type`, `relative_name`, `house_number`, `age`, `gender`, `photo_status`
34
-
35
- OCR and grouping may be imperfect. Review the output CSV against the source image before relying on it.
36
-
37
- ## Telegram
38
-
39
- Only one active polling process can use a bot token at a time. If logs show `Conflict: terminated by other getUpdates request`, stop other processes or Spaces using the same token.
 
1
  ---
2
+ title: DeepSeek OCR + Telegram
3
  emoji: 📄
4
  colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.49.1
8
+ python_version: 3.10.13
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
+ # DeepSeek-OCR Gradio Space with optional Telegram bot
14
 
15
+ This Space uses the `strangervisionhf/deepseek-ocr-transformers-v4.57.6` model.
16
 
17
+ To enable Telegram, add a Space secret named `TELEGRAM_BOT_TOKEN` with the token from BotFather. Long polling is used; `WEBHOOK_URL` is not required. For privacy, optionally set `TELEGRAM_ALLOWED_USER_IDS` to a comma-separated list of trusted Telegram numeric user IDs.
18
 
19
+ The app's CSV/database extraction targets voter-style rows only. Review OCR results before saving them and avoid uploading identity documents unless you are authorized to process them.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,13 +1,7 @@
1
- """DeepSeek-OCR full-page electoral-roll OCR with CSV export and Telegram bot.
2
 
3
- Environment variables (set these in Hugging Face Space Settings):
4
- TELEGRAM_BOT_TOKEN Telegram bot token (secret; never put it in this file)
5
- OCR_PROFILE tiny | small | base | large | gundam (default: gundam)
6
- GPU_DURATION_SECONDS ZeroGPU reservation requested by @spaces.GPU (default: 180)
7
-
8
- The app runs one model.infer() call for each uploaded full page. The Gundam profile
9
- allows the model to create internal crops while still processing the uploaded page
10
- as one OCR job. OCR-to-record grouping is heuristic; check a sample of the CSV.
11
  """
12
 
13
  from __future__ import annotations
@@ -15,889 +9,493 @@ from __future__ import annotations
15
  import asyncio
16
  import csv
17
  import html
18
- import io
19
  import logging
20
  import os
21
  import re
 
22
  import tempfile
23
  import threading
24
- import time
25
  from pathlib import Path
26
- from statistics import median
27
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
28
 
29
- import gradio as gr
 
30
  import torch
31
- from PIL import Image, ImageOps
 
32
  from transformers import AutoModel, AutoTokenizer
33
 
34
- try:
35
- import spaces
36
- except ImportError: # Useful for local parser/UI checks outside Hugging Face Spaces.
37
- class _SpacesFallback:
38
- @staticmethod
39
- def GPU(*args, **kwargs):
40
- def decorator(func):
41
- return func
42
- return decorator
43
- spaces = _SpacesFallback() # type: ignore[assignment]
44
-
45
-
46
- # -----------------------------------------------------------------------------
47
- # Configuration
48
- # -----------------------------------------------------------------------------
49
-
50
- MODEL_ID = "strangervisionhf/deepseek-ocr-transformers-v4.57.6"
51
- DEFAULT_PROFILE = os.getenv("OCR_PROFILE", "gundam").strip().lower()
52
- try:
53
- GPU_DURATION_SECONDS = int(os.getenv("GPU_DURATION_SECONDS", "180"))
54
- except ValueError:
55
- GPU_DURATION_SECONDS = 180
56
- GPU_DURATION_SECONDS = max(10, GPU_DURATION_SECONDS)
57
-
58
- # Telegram BotFather token must be set as a Hugging Face Space Secret.
59
- TELEGRAM_BOT_TOKEN = (
60
- os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
61
- or os.getenv("BOT_TOKEN", "").strip()
62
  )
63
 
64
- OCR_PROFILES: Dict[str, Dict[str, Any]] = {
65
- "tiny": {"base_size": 512, "image_size": 512, "crop_mode": False},
66
- "small": {"base_size": 640, "image_size": 640, "crop_mode": False},
67
- "base": {"base_size": 1024, "image_size": 1024, "crop_mode": False},
68
- "large": {"base_size": 1280, "image_size": 1280, "crop_mode": False},
69
- "gundam": {"base_size": 1024, "image_size": 640, "crop_mode": True},
70
- }
71
- if DEFAULT_PROFILE not in OCR_PROFILES:
72
- DEFAULT_PROFILE = "gundam"
73
-
74
- CSV_COLUMNS = [
75
- "voter_id",
76
- "name",
77
- "relative_type",
78
- "relative_name",
79
- "house_number",
80
- "age",
81
- "gender",
82
- "photo_status",
83
- ]
84
-
85
- # The standard DeepSeek-OCR document prompt, followed by explicit instructions
86
- # for the target document. The model may still emit grounded regions; the parser
87
- # below handles both HTML/Markdown tables and grounded region output.
88
- #OCR_PROMPT =
89
-
90
- OCR_PROMPT = """<image>
91
- <|grounding|>
92
- Perform OCR on the actual image of this complete Indian electoral-roll page.
93
-
94
- Read the printed content in every voter card across the entire page.
95
- Preserve the original Hindi and English text. Keep each field associated
96
- with the voter card where it appears. Do not combine details from different cards.
97
-
98
- Return the recognized document content only. Do not repeat these instructions.
99
- Do not invent or guess unreadable values.
100
-
101
- For each voter card, extract these fields:
102
- voter_id, name, relative_type, relative_name, house_number, age, gender, photo_status
103
-
104
- Return one Markdown table row per voter card, using exactly these eight columns.
105
- Leave a field blank if it cannot be read.
106
- If no voter information can be recognized, return:
107
- OCR_FAILED: No voter information recognized.
108
- """
109
 
110
  logging.basicConfig(
111
- level=os.getenv("LOG_LEVEL", "INFO").upper(),
112
  format="%(asctime)s %(levelname)s %(name)s: %(message)s",
113
  )
114
- logger = logging.getLogger("deepseek-ocr-csv")
115
-
116
-
117
- # -----------------------------------------------------------------------------
118
- # Load model once at startup
119
- # -----------------------------------------------------------------------------
120
-
121
- logger.info("Loading OCR model: %s", MODEL_ID)
122
- logger.info("Default OCR profile=%s; GPU reservation=%ss", DEFAULT_PROFILE, GPU_DURATION_SECONDS)
123
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
124
- logger.info("OCR device: %s", DEVICE)
125
 
126
- TOKENIZER = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
127
- MODEL = AutoModel.from_pretrained(
128
- MODEL_ID,
129
- trust_remote_code=True,
130
- use_safetensors=True,
131
  )
132
- MODEL = MODEL.to(DEVICE).eval()
133
- if DEVICE.type == "cuda":
 
 
 
 
 
 
 
134
  try:
135
- target_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
136
- MODEL = MODEL.to(dtype=target_dtype)
137
- logger.info("Model dtype: %s", target_dtype)
138
- except Exception:
139
- logger.exception("Could not select reduced-precision CUDA dtype; continuing with model default")
140
- logger.info("OCR model loaded successfully")
141
-
142
-
143
- # -----------------------------------------------------------------------------
144
- # OCR and parser helpers
145
- # -----------------------------------------------------------------------------
146
-
147
- # Supports electoral-roll style IDs such as UP/53/264/726430 and EPIC-like IDs
148
- # such as ABC1234567. The patterns are intentionally anchored by boundaries.
149
- SLASH_ID_RE = re.compile(
150
- r"(?<![A-Z0-9])([A-Z]{2,5}\s*/\s*\d{1,4}(?:\s*/\s*\d{1,8}){2,3})(?![A-Z0-9])",
151
- re.IGNORECASE,
152
- )
153
- EPIC_ID_RE = re.compile(r"(?<![A-Z0-9])([A-Z]{3}\s*[- ]?\s*\d{6,8})(?![A-Z0-9])", re.IGNORECASE)
154
-
155
- HTML_TD_RE = re.compile(r"<td\b[^>]*>(.*?)</td\s*>", re.IGNORECASE | re.DOTALL)
156
- HTML_ROW_RE = re.compile(r"<tr\b[^>]*>(.*?)</tr\s*>", re.IGNORECASE | re.DOTALL)
157
- GROUNDED_BLOCK_RE = re.compile(
158
- r"<\|ref\|>(?P<ref>.*?)<\|/ref\|>\s*"
159
- r"<\|det\|>\s*\[\[(?P<coords>.*?)\]\]\s*<\|/det\|>\s*"
160
- r"(?P<text>.*?)(?=<\|ref\|>|$)",
161
- re.DOTALL,
162
- )
163
 
164
- # Order matters: longer relative-name labels appear before the generic "नाम".
165
- LABEL_RE = re.compile(
166
- r"(?P<father>पिता\s*का\s*नाम|पिता\s*नाम|Father(?:'s)?\s+Name|Father)"
167
- r"|(?P<husband>पति\s*का\s*नाम|पति\s*नाम|Husband(?:'s)?\s+Name|Husband)"
168
- r"|(?P<mother>माता\s*का\s*नाम|माता\s*नाम|Mother(?:'s)?\s+Name|Mother)"
169
- r"|(?P<other>अन्य\s*का\s*नाम|संबंधी\s*का\s*नाम|Other\s+Relative)"
170
- r"|(?P<house>मकान\s*संख्या|मकान\s*नं\.?|घर\s*संख्या|House\s*(?:Number|No\.?)|House)"
171
- r"|(?P<age>आयु|उम्र|Age)"
172
- r"|(?P<gender>लिंग|Gender|Sex)"
173
- r"|(?P<name>(?<!का\s)नाम|(?<![A-Za-z])Name(?![A-Za-z]))",
174
- re.IGNORECASE,
175
  )
176
 
177
- PHOTO_AVAILABLE_RE = re.compile(r"फोटो\s*(?:उपलब्ध|मौजूद)\s*(?:है)?|photo\s+available", re.IGNORECASE)
178
- PHOTO_UNAVAILABLE_RE = re.compile(
179
- r"फोटो\s*(?:उपलब्ध\s*नहीं|नहीं\s*है|अनुपलब्ध)|photo\s+(?:not\s+available|unavailable)",
180
- re.IGNORECASE,
181
- )
182
 
 
 
 
 
 
 
 
 
183
 
184
- def empty_record() -> Dict[str, str]:
185
- return {column: "" for column in CSV_COLUMNS}
186
-
187
-
188
- def _clean_text(value: Any) -> str:
189
- """Turn model markup into readable text while preserving Hindi characters."""
190
- text = html.unescape(str(value or ""))
191
- text = re.sub(r"<\|ref\|>.*?<\|/ref\|>", " ", text, flags=re.DOTALL)
192
- text = re.sub(r"<\|det\|>.*?<\|/det\|>", " ", text, flags=re.DOTALL)
193
- text = re.sub(r"<\|[^|]+\|>", " ", text)
194
- text = re.sub(r"</?\s*(?:table|tbody|thead|tfoot|tr|td|th|p|div|span|li|ul|ol|strong|b|em)\b[^>]*>", "\n", text, flags=re.IGNORECASE)
195
- text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
196
- text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", text)
197
- text = re.sub(r"[`*_#]+", " ", text)
198
- text = re.sub(r"[\t\r]+", " ", text)
199
- text = re.sub(r"[ ]{2,}", " ", text)
200
- text = re.sub(r"\n\s*\n\s*\n+", "\n\n", text)
201
- return text.strip()
202
-
203
-
204
- def find_voter_ids(text: str) -> List[str]:
205
- clean = html.unescape(text or "")
206
- found: List[Tuple[int, str]] = []
207
- for pattern in (SLASH_ID_RE, EPIC_ID_RE):
208
- for match in pattern.finditer(clean):
209
- raw_id = match.group(1).strip()
210
- if "/" in raw_id:
211
- normalized = re.sub(r"\s*/\s*", "/", raw_id).upper()
212
- else:
213
- normalized = re.sub(r"\s*[- ]\s*", "", raw_id).upper()
214
- found.append((match.start(), normalized))
215
- found.sort(key=lambda item: item[0])
216
- result: List[str] = []
217
- seen = set()
218
- for _, voter_id in found:
219
- if voter_id not in seen:
220
- seen.add(voter_id)
221
- result.append(voter_id)
222
- return result
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
- def _value_between(text: str, start: int, end: int) -> str:
226
- value = text[start:end]
227
- value = re.sub(r"^[\s::=\-–—|,;]+", "", value)
228
- value = re.sub(r"[\s::=\-–—|,;]+$", "", value)
229
- value = re.sub(r"\s+", " ", value).strip()
230
- value = re.sub(r"(?:फोटो\s*(?:उपलब्ध|मौजूद)\s*(?:है)?|photo\s+available)", "", value, flags=re.IGNORECASE).strip()
 
 
231
  return value
232
 
233
 
234
- def parse_fields(text: str) -> Dict[str, str]:
235
- """Extract the known fields from one voter-card text region."""
236
- text = _clean_text(text)
237
- rec = empty_record()
238
-
239
- ids = find_voter_ids(text)
240
- if ids:
241
- rec["voter_id"] = ids[0]
242
-
243
- if PHOTO_UNAVAILABLE_RE.search(text):
244
- rec["photo_status"] = "फोटो उपलब्ध नहीं है"
245
- elif PHOTO_AVAILABLE_RE.search(text):
246
- rec["photo_status"] = "फोटो उपलब्ध है"
247
-
248
- matches = list(LABEL_RE.finditer(text))
249
- for index, match in enumerate(matches):
250
- next_start = matches[index + 1].start() if index + 1 < len(matches) else len(text)
251
- value = _value_between(text, match.end(), next_start)
252
- if not value:
253
- continue
254
-
255
- if match.lastgroup == "father":
256
- rec["relative_type"] = "पिता"
257
- if not rec["relative_name"]:
258
- rec["relative_name"] = value
259
- elif match.lastgroup == "husband":
260
- rec["relative_type"] = "पति"
261
- if not rec["relative_name"]:
262
- rec["relative_name"] = value
263
- elif match.lastgroup == "mother":
264
- rec["relative_type"] = "माता"
265
- if not rec["relative_name"]:
266
- rec["relative_name"] = value
267
- elif match.lastgroup == "other":
268
- rec["relative_type"] = "अन्य"
269
- if not rec["relative_name"]:
270
- rec["relative_name"] = value
271
- elif match.lastgroup == "house":
272
- if not rec["house_number"]:
273
- rec["house_number"] = value
274
- elif match.lastgroup == "age":
275
- age_match = re.search(r"\d{1,3}", value)
276
- if age_match and not rec["age"]:
277
- rec["age"] = age_match.group(0)
278
- elif match.lastgroup == "gender":
279
- gender_match = re.search(r"पुरुष|स्त्री|महिला|महिल|पुरूष|male|female|other|अन्य", value, re.IGNORECASE)
280
- if gender_match and not rec["gender"]:
281
- rec["gender"] = gender_match.group(0)
282
- elif match.lastgroup == "name":
283
- if not rec["name"]:
284
- rec["name"] = value
285
-
286
- # If field labels appear inline and were not recognized due to OCR punctuation,
287
- # recover the common Hindi forms with simpler targeted expressions.
288
- if not rec["name"]:
289
- name_match = re.search(r"(?:^|\n)\s*नाम\s*[::]?\s*([^\n]+)", text)
290
- if name_match:
291
- candidate = re.split(r"पिता\s*का\s*नाम|पति\s*का\s*नाम|मकान\s*संख्या|आयु|लिंग", name_match.group(1))[0]
292
- rec["name"] = candidate.strip(" ::-")
293
- if not rec["relative_name"]:
294
- relative_match = re.search(
295
- r"(पिता\s*का\s*नाम|पति\s*का\s*नाम|माता\s*का\s*नाम)\s*[::]?\s*([^\n]+)", text
296
- )
297
- if relative_match:
298
- rec["relative_type"] = "पिता" if relative_match.group(1).startswith("पिता") else ("पति" if relative_match.group(1).startswith("पति") else "माता")
299
- rec["relative_name"] = re.split(r"मकान\s*संख्या|आयु|लिंग", relative_match.group(2))[0].strip(" ::-")
300
- if not rec["house_number"]:
301
- house_match = re.search(r"(?:मकान\s*संख्या|मकान\s*नं\.?|घर\s*संख्या)\s*[::]?\s*([^\n]+)", text)
302
- if house_match:
303
- rec["house_number"] = re.split(r"आयु|उम्र|लिंग", house_match.group(1))[0].strip(" ::-")
304
- if not rec["age"]:
305
- age_match = re.search(r"(?:आयु|उम्र|Age)\s*[::]?\s*(\d{1,3})", text, re.IGNORECASE)
306
- if age_match:
307
- rec["age"] = age_match.group(1)
308
- if not rec["gender"]:
309
- gender_match = re.search(r"(?:लिंग|Gender|Sex)\s*[::]?\s*(पुरुष|स्त्री|महिला|पुरूष|male|female)", text, re.IGNORECASE)
310
- if gender_match:
311
- rec["gender"] = gender_match.group(1)
312
-
313
- # Remove any leftover OCR artifacts from each value; don't translate/correct the content.
314
- for col in CSV_COLUMNS:
315
- rec[col] = re.sub(r"\s+", " ", rec[col]).strip(" ::|,;\t\n")
316
- return rec
317
-
318
-
319
- def record_has_fields(record: Dict[str, str]) -> bool:
320
- return any(record.get(key, "") for key in CSV_COLUMNS if key != "voter_id")
321
-
322
-
323
- def record_has_person_details(record: Dict[str, str]) -> bool:
324
- """Whether this region contains card details rather than only a photo label."""
325
- return any(record.get(key, "") for key in (
326
- "name", "relative_type", "relative_name", "house_number", "age", "gender"
327
- ))
328
-
329
-
330
- def merge_record(target: Dict[str, str], source: Dict[str, str]) -> None:
331
- """Fill empty target fields. Existing non-empty values are not overwritten."""
332
- for column in CSV_COLUMNS:
333
- value = (source.get(column) or "").strip()
334
- if value and not target.get(column):
335
- target[column] = value
336
-
337
-
338
- def _records_from_html(raw: str) -> List[Dict[str, str]]:
339
- """Parse HTML cells emitted by the model; pair IDs/details by document order."""
340
- raw = html.unescape(raw or "")
341
- raw_cells = HTML_TD_RE.findall(raw)
342
- if not raw_cells:
343
  return []
344
 
345
- cells = [_clean_text(cell) for cell in raw_cells]
346
- cells = [cell for cell in cells if cell]
347
- if not cells:
348
- return []
349
 
350
- # Preserve the flattened cell order. Some model outputs omit </table>, and some
351
- # put multiple voter cards into a single visual <tr>, so row-level grouping alone
352
- # is not safe for all layouts.
353
- id_entries: List[Tuple[str, Dict[str, str], int]] = []
354
- detail_entries: List[Tuple[Dict[str, str], int]] = []
355
- photo_entries: List[Tuple[str, int]] = []
356
-
357
- for cell_index, cell in enumerate(cells):
358
- ids = find_voter_ids(cell)
359
- parsed = parse_fields(cell)
360
- if ids:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  for voter_id in ids:
362
- id_entries.append((voter_id, parsed, cell_index))
363
- elif record_has_person_details(parsed):
364
- detail_entries.append((parsed, cell_index))
365
- elif parsed.get("photo_status"):
366
- photo_entries.append((parsed["photo_status"], cell_index))
367
-
368
- if not id_entries:
369
- return []
370
-
371
- records: List[Dict[str, str]] = []
372
- for voter_id, parsed, _ in id_entries:
373
- rec = empty_record()
374
- rec["voter_id"] = voter_id
375
- merge_record(rec, parsed)
376
- records.append(rec)
377
-
378
- # In most source pages each ID cell has a corresponding details cell. Flattening
379
- # both lists and pairing by index also handles several cards per visual row.
380
- details_only = [entry[0] for entry in detail_entries]
381
- if details_only:
382
- for index, details in enumerate(details_only):
383
- # If one cell already contains a complete record, don't shift later details.
384
- target_index = index
385
- if target_index < len(records):
386
- merge_record(records[target_index], details)
387
-
388
- # Photo status is often in a separate cell. Attach statuses in cell reading order
389
- # when there is one status cell per record; otherwise attach conservatively by index.
390
- if photo_entries:
391
- for index, (status, _) in enumerate(photo_entries):
392
- if index < len(records) and not records[index].get("photo_status"):
393
- records[index]["photo_status"] = status
394
-
395
- return _deduplicate_records(records)
396
-
397
-
398
- def _parse_grounded_blocks(raw: str) -> List[Dict[str, Any]]:
399
- raw = html.unescape(raw or "")
400
- blocks: List[Dict[str, Any]] = []
401
- for match in GROUNDED_BLOCK_RE.finditer(raw):
402
- coordinates = [int(round(float(number))) for number in re.findall(r"-?\d+(?:\.\d+)?", match.group("coords"))]
403
- if len(coordinates) < 4:
404
- continue
405
- x1, y1, x2, y2 = coordinates[:4]
406
- text = _clean_text(match.group("text"))
407
- if not text:
408
- continue
409
- blocks.append({
410
- "text": text,
411
- "bbox": (x1, y1, x2, y2),
412
- "cx": (x1 + x2) / 2.0,
413
- "cy": (y1 + y2) / 2.0,
414
- "record": parse_fields(text),
415
- })
416
- return blocks
417
-
418
-
419
- def _median_step(values: Sequence[float], fallback: float = 1000.0) -> float:
420
- unique = sorted({round(value, 1) for value in values})
421
- differences = [unique[i + 1] - unique[i] for i in range(len(unique) - 1)]
422
- differences = [difference for difference in differences if difference > 20]
423
- if differences:
424
- return float(median(differences))
425
- return fallback
426
-
427
-
428
- def _records_from_grounded(raw: str) -> List[Dict[str, str]]:
429
- blocks = _parse_grounded_blocks(raw)
430
- if not blocks:
431
- return []
432
-
433
- anchors: List[Dict[str, Any]] = []
434
- for block in blocks:
435
- voter_ids = find_voter_ids(block["text"])
436
- if not voter_ids:
437
- continue
438
- for voter_id in voter_ids:
439
- rec = empty_record()
440
- rec["voter_id"] = voter_id
441
- merge_record(rec, block["record"])
442
- anchors.append({"voter_id": voter_id, "cx": block["cx"], "cy": block["cy"], "record": rec})
443
-
444
- if not anchors:
445
- # Retain readable details as rows, but don't invent missing IDs.
446
- fallback = []
447
- for block in blocks:
448
- rec = block["record"]
449
- if record_has_fields(rec):
450
- fallback.append(rec)
451
- return _deduplicate_records(fallback)
452
-
453
- # Stable row-major reading order. Coordinates are normalized by DeepSeek-OCR
454
- # to approximately a 0..1000 coordinate system.
455
- anchors.sort(key=lambda anchor: (anchor["cy"], anchor["cx"]))
456
- x_step = _median_step([a["cx"] for a in anchors], 1000.0)
457
- y_step = _median_step([a["cy"] for a in anchors], 1000.0)
458
- expected_detail_dx = 0.60 * x_step # voter ID is often above/right of personal details
459
- expected_down_dy = -0.35 * y_step # ID tends to be higher on the same card
460
-
461
- anchor_ids = {a["voter_id"] for a in anchors}
462
- for block in blocks:
463
- block_ids = find_voter_ids(block["text"])
464
- if block_ids:
465
- # IDs already created anchor rows. If this block also contains fields,
466
- # merge its text with the matching row.
467
- for voter_id in block_ids:
468
- for anchor in anchors:
469
- if anchor["voter_id"] == voter_id:
470
- merge_record(anchor["record"], block["record"])
471
- break
472
- continue
473
 
474
- rec_fields = block["record"]
475
- if not record_has_fields(rec_fields):
 
476
  continue
 
 
 
 
477
 
478
- is_photo = bool(rec_fields.get("photo_status")) and not any(
479
- rec_fields.get(col) for col in ("name", "relative_name", "house_number", "age", "gender")
480
- )
481
- if len(anchors) == 1:
482
- chosen = anchors[0]
483
- else:
484
- scored: List[Tuple[float, Dict[str, Any]]] = []
485
- for anchor in anchors:
486
- dx = anchor["cx"] - block["cx"]
487
- dy = anchor["cy"] - block["cy"]
488
- expected_dx = 0.0 if is_photo else expected_detail_dx
489
- expected_dy = expected_down_dy
490
- # Scale each axis by the inferred spacing between ID anchors.
491
- sx = max(80.0, 0.55 * x_step)
492
- sy = max(80.0, 0.55 * y_step)
493
- score = abs(dx - expected_dx) / sx + abs(dy - expected_dy) / sy
494
- scored.append((score, anchor))
495
- chosen = min(scored, key=lambda item: item[0])[1]
496
- merge_record(chosen["record"], rec_fields)
497
-
498
- return _deduplicate_records([anchor["record"] for anchor in anchors])
499
-
500
-
501
- def _records_from_markdown_or_plain(raw: str) -> List[Dict[str, str]]:
502
- """Fallback for Markdown tables or text output without grounded coordinates."""
503
- raw = html.unescape(raw or "")
504
- rows: List[str] = []
505
- for row_match in HTML_ROW_RE.finditer(raw):
506
- row = _clean_text(row_match.group(1))
507
- if row:
508
- rows.append(row)
509
-
510
- # Markdown tables: parse a table line at a time, keeping all cells in the same row.
511
- for line in raw.splitlines():
512
- if "|" in line and re.search(r"[A-Za-z\u0900-\u097F]", line):
513
- cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
514
- joined = "\n".join(_clean_text(cell) for cell in cells if cell.strip(" -:"))
515
- if joined and not re.fullmatch(r"[-: |]+", joined):
516
- rows.append(joined)
517
-
518
- if not rows:
519
- # Keep separated paragraphs distinct. This avoids assuming the whole page is one voter.
520
- rows = [_clean_text(chunk) for chunk in re.split(r"\n\s*\n+", raw) if _clean_text(chunk)]
521
-
522
- parsed_rows: List[Dict[str, str]] = []
523
- id_only: List[str] = []
524
- detail_only: List[Dict[str, str]] = []
525
- for row in rows:
526
- ids = find_voter_ids(row)
527
- rec = parse_fields(row)
528
- if ids and record_has_fields(rec):
529
- for voter_id in ids:
530
- new_rec = dict(rec)
531
- new_rec["voter_id"] = voter_id
532
- parsed_rows.append(new_rec)
533
- elif ids:
534
- id_only.extend(ids)
535
- elif record_has_fields(rec):
536
- detail_only.append(rec)
537
-
538
- if id_only and detail_only:
539
- for index, voter_id in enumerate(id_only):
540
- rec = empty_record()
541
- rec["voter_id"] = voter_id
542
- if index < len(detail_only):
543
- merge_record(rec, detail_only[index])
544
- parsed_rows.append(rec)
545
- else:
546
- for voter_id in id_only:
547
- rec = empty_record()
548
- rec["voter_id"] = voter_id
549
- parsed_rows.append(rec)
550
- parsed_rows.extend(detail_only)
551
-
552
- # Last-chance single-region parse; never copy one set of fields across many IDs.
553
- if not parsed_rows:
554
- rec = parse_fields(raw)
555
- if rec.get("voter_id") or record_has_fields(rec):
556
- parsed_rows.append(rec)
557
-
558
- return _deduplicate_records(parsed_rows)
559
-
560
-
561
- def _deduplicate_records(records: Iterable[Dict[str, str]]) -> List[Dict[str, str]]:
562
- output: List[Dict[str, str]] = []
563
- by_id: Dict[str, Dict[str, str]] = {}
564
- for original in records:
565
- rec = empty_record()
566
- for column in CSV_COLUMNS:
567
- rec[column] = re.sub(r"\s+", " ", str(original.get(column, "") or "")).strip()
568
- voter_id = rec.get("voter_id", "")
569
- if voter_id:
570
- if voter_id in by_id:
571
- merge_record(by_id[voter_id], rec)
572
- else:
573
- by_id[voter_id] = rec
574
- output.append(rec)
575
- elif record_has_fields(rec):
576
- output.append(rec)
577
  return output
578
 
579
 
580
- def parse_ocr_records(raw: str) -> List[Dict[str, str]]:
581
- """Convert model output into one CSV record per detected voter ID/card."""
582
- if not raw or not raw.strip():
583
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
 
585
- html_records = _records_from_html(raw)
586
- if html_records and any(record.get("voter_id") for record in html_records):
587
- return html_records
588
 
589
- grounded_records = _records_from_grounded(raw)
590
- if grounded_records:
591
- return grounded_records
592
 
593
- return _records_from_markdown_or_plain(raw)
 
 
 
 
 
 
 
 
 
 
 
 
594
 
 
 
595
 
596
- # -----------------------------------------------------------------------------
597
- # GPU inference
598
- # -----------------------------------------------------------------------------
599
 
600
  @spaces.GPU(duration=GPU_DURATION_SECONDS)
601
- def run_ocr(image: Image.Image, profile_name: str = DEFAULT_PROFILE) -> str:
602
- """Run one full-page OCR inference; Grounding/Gundam may use internal crops."""
603
- if image is None:
604
- raise ValueError("Please send or upload an image first.")
605
-
606
- profile = (profile_name or DEFAULT_PROFILE).strip().lower()
607
- if profile not in OCR_PROFILES:
608
- profile = DEFAULT_PROFILE
609
- config = OCR_PROFILES[profile]
610
-
611
- # Normalize orientation/color, without shrinking or cropping the page.
612
- page = ImageOps.exif_transpose(image).convert("RGB")
613
- started = time.perf_counter()
614
- with tempfile.TemporaryDirectory(prefix="deepseek_ocr_") as output_dir:
615
- image_path = os.path.join(output_dir, "input_page.png")
616
- page.save(image_path, format="PNG", optimize=True)
617
- with torch.inference_mode():
618
- result = MODEL.infer(
619
- TOKENIZER,
620
- prompt=OCR_PROMPT,
621
  image_file=image_path,
622
- output_path=output_dir,
623
- base_size=config["base_size"],
624
- image_size=config["image_size"],
625
- crop_mode=config["crop_mode"],
626
- save_results=True,
627
  test_compress=True,
628
  eval_mode=True,
629
  )
630
- elapsed = time.perf_counter() - started
631
- logger.info(
632
- "OCR finished in %.1fs with profile=%s, base_size=%s, image_size=%s, crop_mode=%s",
633
- elapsed, profile, config["base_size"], config["image_size"], config["crop_mode"],
634
- )
635
- return str(result or "").strip()
636
 
637
 
638
- def records_to_csv_bytes(records: Sequence[Dict[str, str]]) -> bytes:
639
- stream = io.StringIO(newline="")
640
- writer = csv.DictWriter(stream, fieldnames=CSV_COLUMNS, extrasaction="ignore", lineterminator="\n")
641
- writer.writeheader()
642
- for record in records:
643
- writer.writerow({column: record.get(column, "") for column in CSV_COLUMNS})
644
- return stream.getvalue().encode("utf-8-sig")
645
-
646
-
647
- def save_csv_tempfile(records: Sequence[Dict[str, str]]) -> str:
648
- data = records_to_csv_bytes(records)
649
- with tempfile.NamedTemporaryFile(prefix="voter_records_", suffix=".csv", delete=False) as file_obj:
650
- file_obj.write(data)
651
- return file_obj.name
652
-
653
-
654
- def records_to_dataframe(records: Sequence[Dict[str, str]]) -> List[List[str]]:
655
- return [[record.get(column, "") for column in CSV_COLUMNS] for record in records]
656
-
657
-
658
- def process_gradio_image(image: Optional[Image.Image], profile: str):
659
  if image is None:
660
- raise gr.Error("Upload a complete page image first.")
 
 
661
  try:
662
- raw = run_ocr(image, profile)
 
 
 
663
  except Exception as exc:
664
  logger.exception("Gradio OCR failed")
665
- message = str(exc)
666
- if "quota" in message.lower() or "duration" in message.lower() or "time limit" in message.lower():
667
- raise gr.Error(
668
- "The ZeroGPU request was rejected or timed out. Check GPU_DURATION_SECONDS and your remaining quota; "
669
- "try OCR_PROFILE=tiny/small if the page still takes too long."
670
- ) from exc
671
- raise gr.Error(f"OCR failed: {message[:500]}") from exc
672
-
673
- records = parse_ocr_records(raw)
674
- csv_path = save_csv_tempfile(records) if records else None
675
- if not records:
676
- summary = (
677
- "No complete voter records could be grouped automatically. The raw OCR output is shown above. "
678
- "Try Gundam for a clearer scan, then verify the text/coordinates before relying on a CSV."
679
- )
680
- else:
681
- incomplete = sum(1 for record in records if not record.get("voter_id") or not record.get("name"))
682
- summary = f"Parsed {len(records)} row(s). {incomplete} row(s) are missing a voter ID or name and need manual review."
683
- return raw, records_to_dataframe(records), csv_path, summary
684
 
685
 
686
- # -----------------------------------------------------------------------------
687
- # Telegram bot: send the original image as a document for best resolution.
688
- # -----------------------------------------------------------------------------
 
 
689
 
690
- _bot_thread: Optional[threading.Thread] = None
691
- _bot_lock = threading.Lock()
692
 
693
-
694
- def _telegram_error_message(exc: Exception) -> str:
695
- message = str(exc).lower()
696
- if "quota" in message or "duration" in message or "time limit" in message:
697
- return (
698
- "The OCR task reached or was refused by the GPU time/quota limit. "
699
- "Try a clearer/smaller page or set OCR_PROFILE=tiny/small; increasing GPU_DURATION_SECONDS only works "
700
- "when ZeroGPU permits that reservation and enough quota remains."
701
- )
702
- if "out of memory" in message or "cuda" in message:
703
- return "The GPU could not complete this image. Try OCR_PROFILE=tiny/small or send a clearer, smaller page."
704
- return "OCR could not finish this image. Please try again or send the image as a document for better resolution."
705
-
706
-
707
- async def telegram_start(update, context) -> None:
708
- message = update.effective_message
709
- if message:
710
- await message.reply_text(
711
- "Send one full-page electoral-roll image. I will OCR it and return a CSV file.\n\n"
712
- "For best quality, send the page as a document/file so Telegram does not resize it. "
713
- f"Current profile: {DEFAULT_PROFILE}."
714
  )
715
 
716
 
717
- async def telegram_help(update, context) -> None:
718
  message = update.effective_message
719
- if message:
720
- await message.reply_text(
721
- "How to use:\n"
722
- "1. Send a clear image of one complete page.\n"
723
- "2. Prefer sending it as a document/file to preserve resolution.\n"
724
- "3. The bot returns voter_records.csv with voter ID, name, relative name/type, house number, age, gender and photo status.\n\n"
725
- "Review the CSV before use: complex page layouts can cause OCR/grouping errors."
726
- )
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
 
729
- async def _download_image_from_telegram(update, context) -> Optional[Image.Image]:
730
- message = update.effective_message
731
- if not message:
732
- return None
733
 
734
- telegram_file = None
735
- if message.photo:
736
- telegram_file = await context.bot.get_file(message.photo[-1].file_id)
737
- elif message.document:
738
- document = message.document
739
- file_name = (document.file_name or "").lower()
740
- mime_type = (document.mime_type or "").lower()
741
- if not (mime_type.startswith("image/") or file_name.endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"))):
742
- await message.reply_text("Please send a JPG, PNG, WEBP, BMP, or TIFF image. For best quality, send it as a document.")
743
- return None
744
- telegram_file = await context.bot.get_file(document.file_id)
745
- if telegram_file is None:
746
- return None
747
 
748
- buffer = io.BytesIO()
749
- await telegram_file.download_to_memory(buffer)
750
- buffer.seek(0)
751
  try:
752
- image = Image.open(buffer)
753
- image.load()
754
- return ImageOps.exif_transpose(image).convert("RGB")
755
- except Exception as exc:
756
- logger.warning("Telegram attachment was not a readable image: %s", exc)
757
- await message.reply_text("I could not open this image. Try sending it as a JPG or PNG file.")
758
- return None
759
-
760
-
761
- async def _process_telegram_image(update, context) -> None:
762
- message = update.effective_message
763
- if not message:
764
- return
765
- image = await _download_image_from_telegram(update, context)
766
- if image is None:
767
- return
768
 
769
- await message.reply_text(f"Processing the complete page with profile '{DEFAULT_PROFILE}'…")
770
- try:
771
- # Keep the Telegram event loop responsive while the GPU call is running.
772
- raw = await asyncio.to_thread(run_ocr, image, DEFAULT_PROFILE)
773
- records = parse_ocr_records(raw)
774
- if records:
775
- csv_stream = io.BytesIO(records_to_csv_bytes(records))
776
- csv_stream.name = "voter_records.csv"
777
- incomplete = sum(1 for record in records if not record.get("voter_id") or not record.get("name"))
778
- caption = f"Extracted {len(records)} row(s). Rows needing review: {incomplete}. Please verify against the original page."
779
- await message.reply_document(
780
- document=csv_stream,
781
- filename="voter_records.csv",
782
- caption=caption,
783
  )
784
- else:
785
- raw_stream = io.BytesIO((raw or "No text was returned by the OCR model.").encode("utf-8"))
786
- raw_stream.name = "ocr_raw_output.txt"
787
- await message.reply_text(
788
- "I could not reliably group the result into voter records, so I have not guessed the CSV rows. "
789
- "Here is the raw OCR output for inspection. Try a full-resolution document upload or another profile."
 
 
 
 
 
 
 
 
 
 
 
 
790
  )
791
- await message.reply_document(document=raw_stream, filename="ocr_raw_output.txt")
792
  except Exception as exc:
793
- logger.exception("Telegram OCR request failed")
794
- await message.reply_text(_telegram_error_message(exc))
795
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
796
 
797
- async def telegram_photo(update, context) -> None:
798
- await _process_telegram_image(update, context)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
799
 
800
 
801
- async def telegram_document(update, context) -> None:
802
- await _process_telegram_image(update, context)
803
 
804
 
805
- def _telegram_worker() -> None:
806
- # Imports live here so the Gradio UI can start even when PTB isn't installed.
807
- try:
808
- from telegram import error as telegram_error
809
- from telegram.ext import Application, CommandHandler, MessageHandler, filters
810
- except Exception:
811
- logger.exception("python-telegram-bot is not installed; Telegram bot is disabled")
812
  return
813
 
814
- #application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
815
- from telegram.request import HTTPXRequest
816
- telegram_request = HTTPXRequest(
817
- connect_timeout=30,
818
- read_timeout=30,
819
- write_timeout=30,
820
- pool_timeout=30,
821
- )
822
- application = (
823
- Application.builder()
824
- .token(TELEGRAM_BOT_TOKEN)
825
- .request(telegram_request)
826
- .build()
827
- )
828
- application.add_handler(CommandHandler("start", telegram_start))
829
- application.add_handler(CommandHandler("help", telegram_help))
830
- application.add_handler(MessageHandler(filters.PHOTO, telegram_photo))
831
- application.add_handler(MessageHandler(filters.Document.ALL, telegram_document))
832
-
833
- logger.warning("Starting Telegram polling in PID=%s (once per app process)", os.getpid())
834
- try:
835
- # run_polling normally installs signal handlers, which is not allowed in a thread.
836
- application.run_polling(drop_pending_updates=True, stop_signals=None)
837
- except telegram_error.Conflict:
838
- logger.error(
839
- "Telegram polling conflict: another process is already using getUpdates for this bot token. "
840
- "Stop any other bot instance and keep only one active polling process."
841
- )
842
- except Exception:
843
- logger.exception("Telegram bot stopped unexpectedly")
844
 
 
 
 
 
 
 
 
 
 
845
 
846
- def start_telegram_bot_once() -> None:
847
- global _bot_thread
848
- if not TELEGRAM_BOT_TOKEN:
849
- logger.info("TELEGRAM_BOT_TOKEN/BOT_TOKEN is not set; starting Gradio UI without Telegram")
850
- return
851
- with _bot_lock:
852
- if _bot_thread is not None and _bot_thread.is_alive():
853
- logger.info("Telegram polling thread already started in this process; skipping duplicate start")
854
- return
855
- _bot_thread = threading.Thread(target=_telegram_worker, name="telegram-polling", daemon=True)
856
- _bot_thread.start()
857
 
858
 
859
- # -----------------------------------------------------------------------------
860
- # Gradio UI
861
- # -----------------------------------------------------------------------------
862
 
863
- with gr.Blocks(title="DeepSeek-OCR — Full Page to CSV") as demo:
864
  gr.Markdown(
865
- "# DeepSeek-OCR: Full page to CSV\n"
866
- "Upload one complete page. The app makes one OCR inference call, groups detected fields into rows, "
867
- "and creates a UTF-8 CSV. For smaller text, try **Gundam** and send the original image as a document in Telegram."
868
  )
869
  with gr.Row():
870
- with gr.Column(scale=1):
871
- image_input = gr.Image(type="pil", label="Complete page image", sources=["upload", "clipboard"])
872
- profile_input = gr.Dropdown(
873
- choices=["tiny", "small", "base", "large", "gundam"],
874
- value=DEFAULT_PROFILE,
875
- label="OCR profile",
876
- info="Gundam uses internal crops for detail; Tiny/Small are faster but may miss small text.",
877
  )
878
- run_button = gr.Button("OCR full page and create CSV", variant="primary")
879
- with gr.Column(scale=2):
880
- summary_output = gr.Textbox(label="Result summary", lines=2, interactive=False)
881
- table_output = gr.Dataframe(
882
- headers=CSV_COLUMNS,
883
- datatype=["str"] * len(CSV_COLUMNS),
884
- label="Parsed CSV rows — review before use",
885
- interactive=False,
886
- wrap=True,
887
- )
888
- csv_output = gr.File(label="Download CSV")
889
- raw_output = gr.Textbox(label="Raw OCR output (for troubleshooting)", lines=12, max_lines=30, interactive=False)
890
  gr.Markdown(
891
- "**Important:** OCR and row grouping can make mistakes, especially when cards are close together or text is blurry. "
892
- "Verify names, voter IDs and row associations against the source page."
893
- )
894
- run_button.click(
895
- fn=process_gradio_image,
896
- inputs=[image_input, profile_input],
897
- outputs=[raw_output, table_output, csv_output, summary_output],
898
  )
899
 
900
-
901
  if __name__ == "__main__":
902
- start_telegram_bot_once()
903
- demo.queue(max_size=6).launch(server_name="0.0.0.0", ssr_mode=False)
 
1
+ """DeepSeek-OCR Gradio app with optional Telegram long-polling bot.
2
 
3
+ Use a Gradio Space configured for Gradio 5.49.1 and ZeroGPU/GPU hardware.
4
+ Set TELEGRAM_BOT_TOKEN in Space Settings -> Variables and secrets to enable Telegram.
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
 
9
  import asyncio
10
  import csv
11
  import html
12
+ import json
13
  import logging
14
  import os
15
  import re
16
+ import sqlite3
17
  import tempfile
18
  import threading
19
+ import uuid
20
  from pathlib import Path
21
+ from typing import Any
 
22
 
23
+ # Import spaces before torch, as recommended for ZeroGPU Spaces.
24
+ import spaces
25
  import torch
26
+ import gradio as gr
27
+ from PIL import Image
28
  from transformers import AutoModel, AutoTokenizer
29
 
30
+ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
31
+ from telegram.ext import (
32
+ Application,
33
+ CallbackQueryHandler,
34
+ CommandHandler,
35
+ ContextTypes,
36
+ MessageHandler,
37
+ filters,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  )
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  logging.basicConfig(
42
+ level=os.getenv("LOG_LEVEL", "INFO"),
43
  format="%(asctime)s %(levelname)s %(name)s: %(message)s",
44
  )
45
+ logger = logging.getLogger("deepseek_ocr")
 
 
 
 
 
 
 
 
 
 
46
 
47
+ MODEL_ID = os.getenv(
48
+ "MODEL_ID", "strangervisionhf/deepseek-ocr-transformers-v4.57.6"
 
 
 
49
  )
50
+ GPU_DURATION_SECONDS = int(os.getenv("GPU_DURATION_SECONDS", "120"))
51
+ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
52
+ DB_PATH = os.getenv("DB_PATH", "ocr_records.db")
53
+ MAX_TELEGRAM_FILE_BYTES = 20 * 1024 * 1024
54
+
55
+ # Optional comma-separated allowlist of Telegram numeric user IDs.
56
+ # Example: TELEGRAM_ALLOWED_USER_IDS=123456789,987654321
57
+ _allowed_raw = os.getenv("TELEGRAM_ALLOWED_USER_IDS", "").strip()
58
+ if _allowed_raw:
59
  try:
60
+ ALLOWED_TELEGRAM_USER_IDS: set[int] | None = {
61
+ int(item.strip()) for item in _allowed_raw.split(",") if item.strip()
62
+ }
63
+ except ValueError as exc:
64
+ raise RuntimeError(
65
+ "TELEGRAM_ALLOWED_USER_IDS must contain comma-separated numeric IDs."
66
+ ) from exc
67
+ else:
68
+ ALLOWED_TELEGRAM_USER_IDS = None
69
+
70
+ PROMPTS = {
71
+ "Convert to Markdown": "<image>\n<|grounding|>Convert the document to markdown.",
72
+ "Free OCR": "<image>\nFree OCR.",
73
+ "Parse Figure": "<image>\nParse the figure.",
74
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
+ # Keep the existing voter-register field extraction, but support HTML tables,
77
+ # Markdown tables, and plain OCR text. Verify every extracted field before saving.
78
+ VOTER_ID_RE = re.compile(
79
+ r"(?<![A-Z0-9])(?:UP/\d+/\d+/\d+|[A-Z]{3}\d{7,})(?![A-Z0-9])",
80
+ flags=re.IGNORECASE,
 
 
 
 
 
 
81
  )
82
 
 
 
 
 
 
83
 
84
+ def _clean_markup(value: str) -> str:
85
+ value = html.unescape(value or "")
86
+ value = re.sub(r"<br\s*/?>", "\n", value, flags=re.IGNORECASE)
87
+ value = re.sub(r"</(?:td|th|tr|p|div|li|section)>\s*", "\n", value, flags=re.IGNORECASE)
88
+ value = re.sub(r"<[^>]+>", " ", value)
89
+ value = value.replace("\xa0", " ").replace("|", " ")
90
+ lines = [re.sub(r"[ \t\r]+", " ", line).strip() for line in value.splitlines()]
91
+ return "\n".join(line for line in lines if line)
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
+ def _extract_field(text: str, label: str) -> str:
95
+ labels = (
96
+ r"पिता\s*का\s*नाम|पति\s*का\s*नाम|मकान\s*संख्या|आयु|लिंग|(?<!का )नाम"
97
+ )
98
+ if label == "name":
99
+ start = r"(?<!का )नाम"
100
+ elif label == "father_name":
101
+ start = r"पिता\s*का\s*नाम"
102
+ elif label == "husband_name":
103
+ start = r"पति\s*का\s*नाम"
104
+ elif label == "house_no":
105
+ start = r"मकान\s*संख्या"
106
+ elif label == "age":
107
+ start = r"आयु"
108
+ elif label == "gender":
109
+ start = r"लिंग"
110
+ else:
111
+ return ""
112
 
113
+ match = re.search(
114
+ rf"{start}\s*[::]?\s*(.*?)(?=\s*(?:{labels})\s*[::]?|$)",
115
+ text,
116
+ flags=re.DOTALL,
117
+ )
118
+ if not match:
119
+ return ""
120
+ value = re.sub(r"\s+", " ", match.group(1)).strip(" \t\r\n::-*|")
121
  return value
122
 
123
 
124
+ def _record_from_text(row_text: str, voter_id: str) -> dict[str, Any] | None:
125
+ cleaned = _clean_markup(row_text)
126
+ if "नाम" not in cleaned:
127
+ return None
128
+ age_text = _extract_field(cleaned, "age")
129
+ age_match = re.search(r"\d{1,3}", age_text)
130
+ return {
131
+ "voter_id": voter_id.strip(),
132
+ "name": _extract_field(cleaned, "name"),
133
+ "father_name": _extract_field(cleaned, "father_name"),
134
+ "husband_name": _extract_field(cleaned, "husband_name"),
135
+ "house_no": _extract_field(cleaned, "house_no"),
136
+ "age": int(age_match.group()) if age_match else 0,
137
+ "gender": _extract_field(cleaned, "gender"),
138
+ }
139
+
140
+
141
+ def parse_markdown(raw: str) -> list[dict[str, Any]]:
142
+ """Extract rows from OCR output when it contains voter-like IDs and labels."""
143
+ if not raw:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  return []
145
 
146
+ candidate_rows: list[str] = []
 
 
 
147
 
148
+ # HTML tables are common in DeepSeek-OCR's Markdown/table output.
149
+ html_rows = re.findall(r"<tr\b[^>]*>(.*?)</tr>", raw, flags=re.IGNORECASE | re.DOTALL)
150
+ if html_rows:
151
+ for row in html_rows:
152
+ cells = re.findall(r"<t[dh]\b[^>]*>(.*?)</t[dh]>", row, flags=re.IGNORECASE | re.DOTALL)
153
+ candidate_rows.append("\n".join(cells) if cells else row)
154
+ else:
155
+ # Markdown tables: inspect each pipe-delimited row while skipping separators.
156
+ for line in raw.splitlines():
157
+ if "|" not in line:
158
+ continue
159
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
160
+ if not cells or all(re.fullmatch(r":?-{3,}:?", c.replace(" ", "")) for c in cells if c):
161
+ continue
162
+ candidate_rows.append("\n".join(cells))
163
+
164
+ # Fallback for OCR text that is not formatted as a table: split around IDs.
165
+ if not candidate_rows:
166
+ clean = _clean_markup(raw)
167
+ found = list(VOTER_ID_RE.finditer(clean))
168
+ for index, match in enumerate(found):
169
+ end = found[index + 1].start() if index + 1 < len(found) else len(clean)
170
+ candidate_rows.append(f"{match.group()}\n{clean[match.start():end]}")
171
+
172
+ output: list[dict[str, Any]] = []
173
+ seen: set[str] = set()
174
+ id_only: list[str] = []
175
+ details_only: list[str] = []
176
+
177
+ for row in candidate_rows:
178
+ cleaned_row = _clean_markup(row)
179
+ ids = list(dict.fromkeys(m.group().strip() for m in VOTER_ID_RE.finditer(cleaned_row)))
180
+ if ids and "नाम" in cleaned_row:
181
  for voter_id in ids:
182
+ record = _record_from_text(cleaned_row, voter_id)
183
+ if record and voter_id not in seen:
184
+ output.append(record)
185
+ seen.add(voter_id)
186
+ elif ids:
187
+ id_only.extend(v for v in ids if v not in seen)
188
+ elif "नाम" in cleaned_row:
189
+ details_only.append(cleaned_row)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
+ # Some OCR layouts put the ID and details in adjacent table cells/rows.
192
+ for voter_id, details in zip(id_only, details_only):
193
+ if voter_id in seen:
194
  continue
195
+ record = _record_from_text(details, voter_id)
196
+ if record:
197
+ output.append(record)
198
+ seen.add(voter_id)
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  return output
201
 
202
 
203
+ def init_db() -> None:
204
+ db_parent = Path(DB_PATH).expanduser().resolve().parent
205
+ db_parent.mkdir(parents=True, exist_ok=True)
206
+ conn = sqlite3.connect(DB_PATH)
207
+ try:
208
+ conn.execute(
209
+ """CREATE TABLE IF NOT EXISTS voters (
210
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
211
+ voter_id TEXT,
212
+ name TEXT,
213
+ father_name TEXT,
214
+ husband_name TEXT,
215
+ house_no TEXT,
216
+ age INTEGER,
217
+ gender TEXT,
218
+ raw_json TEXT
219
+ )"""
220
+ )
221
+ conn.commit()
222
+ finally:
223
+ conn.close()
224
 
 
 
 
225
 
226
+ logger.info("Loading model: %s", MODEL_ID)
227
+ logger.info("Torch version: %s | CUDA visible: %s", torch.__version__, torch.cuda.is_available())
 
228
 
229
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
230
+ load_device = "cuda" if torch.cuda.is_available() else "cpu"
231
+ load_dtype = torch.bfloat16 if load_device == "cuda" else torch.float32
232
+ model = AutoModel.from_pretrained(
233
+ MODEL_ID,
234
+ trust_remote_code=True,
235
+ use_safetensors=True,
236
+ _attn_implementation="eager",
237
+ torch_dtype=load_dtype,
238
+ low_cpu_mem_usage=True,
239
+ ).eval()
240
+ model = model.to(load_device)
241
+ logger.info("DeepSeek-OCR loaded on %s (%s).", load_device, load_dtype)
242
 
243
+ # The model is shared by Gradio and Telegram, so serialize inference calls.
244
+ _INFERENCE_LOCK = threading.Lock()
245
 
 
 
 
246
 
247
  @spaces.GPU(duration=GPU_DURATION_SECONDS)
248
+ def run_ocr(image_path: str, prompt: str = PROMPTS["Convert to Markdown"]) -> str:
249
+ """Run the model. The model is placed on the selected device at startup."""
250
+ output_dir = tempfile.TemporaryDirectory(prefix="deepseek_ocr_out_")
251
+ try:
252
+ with _INFERENCE_LOCK, torch.inference_mode():
253
+ result = model.infer(
254
+ tokenizer,
255
+ prompt=prompt,
 
 
 
 
 
 
 
 
 
 
 
 
256
  image_file=image_path,
257
+ output_path=output_dir.name,
258
+ base_size=1024,
259
+ image_size=640,
260
+ crop_mode=True,
261
+ save_results=False,
262
  test_compress=True,
263
  eval_mode=True,
264
  )
265
+ return result if isinstance(result, str) else str(result)
266
+ finally:
267
+ output_dir.cleanup()
 
 
 
268
 
269
 
270
+ def gradio_ocr(image: Image.Image | None, task: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  if image is None:
272
+ raise gr.Error("Upload an image first.")
273
+ prompt = PROMPTS.get(task, PROMPTS["Convert to Markdown"])
274
+ temp_path = None
275
  try:
276
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle:
277
+ temp_path = handle.name
278
+ image.convert("RGB").save(temp_path, format="PNG")
279
+ return run_ocr(temp_path, prompt)
280
  except Exception as exc:
281
  logger.exception("Gradio OCR failed")
282
+ raise gr.Error(f"OCR failed: {type(exc).__name__}: {exc}") from exc
283
+ finally:
284
+ if temp_path and os.path.exists(temp_path):
285
+ os.remove(temp_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
 
288
+ def _authorized(update: Update) -> bool:
289
+ if ALLOWED_TELEGRAM_USER_IDS is None:
290
+ return True
291
+ user = update.effective_user
292
+ return user is not None and user.id in ALLOWED_TELEGRAM_USER_IDS
293
 
 
 
294
 
295
+ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
296
+ if not _authorized(update):
297
+ if update.effective_message:
298
+ await update.effective_message.reply_text("This bot is not enabled for your account.")
299
+ return
300
+ if update.effective_message:
301
+ await update.effective_message.reply_text(
302
+ "Send an image as a photo or image document. I will run DeepSeek-OCR and, "
303
+ "when voter-style rows are detected, return a CSV for you to review. "
304
+ "Use the Save button only after checking the extracted data."
 
 
 
 
 
 
 
 
 
 
 
305
  )
306
 
307
 
308
+ async def on_image(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
309
  message = update.effective_message
310
+ if message is None:
311
+ return
312
+ if not _authorized(update):
313
+ await message.reply_text("This bot is not enabled for your account.")
314
+ return
 
 
 
315
 
316
+ document = message.document
317
+ photo = message.photo
318
+ if document:
319
+ mime_type = document.mime_type or ""
320
+ if not mime_type.startswith("image/"):
321
+ await message.reply_text("Please send an image file (PNG, JPG, or similar).")
322
+ return
323
+ file_id = document.file_id
324
+ original_name = document.file_name or "upload.png"
325
+ file_size = document.file_size
326
+ elif photo:
327
+ file_id = photo[-1].file_id
328
+ original_name = f"telegram_{message.message_id}.jpg"
329
+ file_size = photo[-1].file_size
330
+ else:
331
+ return
332
 
333
+ if file_size and file_size > MAX_TELEGRAM_FILE_BYTES:
334
+ await message.reply_text("This image is too large. Please send one under 20 MB.")
335
+ return
 
336
 
337
+ suffix = Path(original_name).suffix.lower()
338
+ if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}:
339
+ suffix = ".jpg"
340
+ local_path = None
341
+ status = await message.reply_text("Image received. Running DeepSeek-OCR…")
 
 
 
 
 
 
 
 
342
 
 
 
 
343
  try:
344
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as handle:
345
+ local_path = handle.name
346
+ telegram_file = await context.bot.get_file(file_id)
347
+ await telegram_file.download_to_drive(custom_path=local_path)
348
+
349
+ raw = await asyncio.to_thread(run_ocr, local_path, PROMPTS["Convert to Markdown"])
350
+ records = parse_markdown(raw)
351
+ if not records:
352
+ await status.edit_text("OCR finished, but I could not confidently parse voter-style rows. Raw OCR follows:")
353
+ await message.reply_text(raw[:3500] or "The model returned no text.")
354
+ return
 
 
 
 
 
355
 
356
+ csv_path = os.path.join(tempfile.gettempdir(), f"deepseek_{uuid.uuid4().hex}.csv")
357
+ with open(csv_path, "w", newline="", encoding="utf-8-sig") as csv_file:
358
+ writer = csv.DictWriter(
359
+ csv_file,
360
+ fieldnames=["voter_id", "name", "father_name", "husband_name", "house_no", "age", "gender"],
 
 
 
 
 
 
 
 
 
361
  )
362
+ writer.writeheader()
363
+ writer.writerows(records)
364
+
365
+ context.user_data["csv_path"] = csv_path
366
+ context.user_data["records"] = records
367
+ keyboard = InlineKeyboardMarkup(
368
+ [[
369
+ InlineKeyboardButton("✅ Save to database", callback_data="save"),
370
+ InlineKeyboardButton("❌ Cancel", callback_data="cancel"),
371
+ ]]
372
+ )
373
+ await status.edit_text(f"OCR finished. Found {len(records)} possible records; review the CSV before saving.")
374
+ with open(csv_path, "rb") as csv_file:
375
+ await message.reply_document(
376
+ document=csv_file,
377
+ filename="extracted_records.csv",
378
+ caption=f"{len(records)} possible records. Verify the CSV before saving.",
379
+ reply_markup=keyboard,
380
  )
 
381
  except Exception as exc:
382
+ logger.exception("Telegram OCR pipeline failed")
383
+ try:
384
+ await status.edit_text(f"OCR failed: {type(exc).__name__}. Check the Space logs for details.")
385
+ except Exception:
386
+ await message.reply_text(f"OCR failed: {type(exc).__name__}. Check the Space logs for details.")
387
+ finally:
388
+ if local_path and os.path.exists(local_path):
389
+ os.remove(local_path)
390
+
391
+
392
+ async def on_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
393
+ query = update.callback_query
394
+ if query is None:
395
+ return
396
+ await query.answer()
397
+ if not _authorized(update):
398
+ await query.edit_message_caption(caption="This bot is not enabled for your account.")
399
+ return
400
 
401
+ csv_path = context.user_data.get("csv_path")
402
+ records = context.user_data.get("records")
403
+ try:
404
+ if query.data == "save" and records:
405
+ conn = sqlite3.connect(DB_PATH)
406
+ try:
407
+ conn.executemany(
408
+ """INSERT INTO voters
409
+ (voter_id, name, father_name, husband_name, house_no, age, gender, raw_json)
410
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
411
+ [
412
+ (
413
+ row["voter_id"], row["name"], row["father_name"],
414
+ row["husband_name"], row["house_no"], row["age"],
415
+ row["gender"], json.dumps(row, ensure_ascii=False),
416
+ )
417
+ for row in records
418
+ ],
419
+ )
420
+ conn.commit()
421
+ finally:
422
+ conn.close()
423
+ await query.edit_message_caption(caption=f"Saved {len(records)} records to the local database.")
424
+ elif query.data == "cancel":
425
+ await query.edit_message_caption(caption="Cancelled. No records were saved.")
426
+ except Exception:
427
+ logger.exception("Could not save/cancel records")
428
+ await query.edit_message_caption(caption="Could not complete that action. Check the Space logs.")
429
+ finally:
430
+ if csv_path and os.path.exists(csv_path):
431
+ os.remove(csv_path)
432
+ context.user_data.pop("csv_path", None)
433
+ context.user_data.pop("records", None)
434
 
435
 
436
+ async def telegram_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
437
+ logger.error("Telegram handler error", exc_info=context.error)
438
 
439
 
440
+ def start_telegram_bot() -> None:
441
+ """Start the bot using long polling; no inbound webhook route is needed."""
442
+ if not TELEGRAM_BOT_TOKEN:
443
+ logger.warning("TELEGRAM_BOT_TOKEN is not set; Telegram integration is disabled.")
 
 
 
444
  return
445
 
446
+ bot_app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
447
+ bot_app.add_handler(CommandHandler("start", start))
448
+ bot_app.add_handler(
449
+ MessageHandler((filters.PHOTO | filters.Document.IMAGE) & ~filters.COMMAND, on_image)
450
+ )
451
+ bot_app.add_handler(CallbackQueryHandler(on_button))
452
+ bot_app.add_error_handler(telegram_error)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
+ async def worker() -> None:
455
+ await bot_app.initialize()
456
+ # Polling and webhook modes cannot run at the same time. Clear any old webhook.
457
+ await bot_app.bot.delete_webhook(drop_pending_updates=False)
458
+ assert bot_app.updater is not None
459
+ await bot_app.updater.start_polling(drop_pending_updates=False)
460
+ await bot_app.start()
461
+ logger.info("Telegram bot started in long-polling mode.")
462
+ await asyncio.Event().wait()
463
 
464
+ def thread_target() -> None:
465
+ try:
466
+ asyncio.run(worker())
467
+ except Exception:
468
+ logger.exception("Telegram bot failed to start. Check TELEGRAM_BOT_TOKEN and polling conflicts.")
469
+
470
+ threading.Thread(target=thread_target, daemon=True, name="telegram-polling").start()
 
 
 
 
471
 
472
 
473
+ init_db()
474
+ start_telegram_bot()
 
475
 
476
+ with gr.Blocks(title="DeepSeek-OCR") as demo:
477
  gr.Markdown(
478
+ "# DeepSeek-OCR\n"
479
+ "Upload an image for OCR. The same model is available through Telegram when "
480
+ "`TELEGRAM_BOT_TOKEN` is configured in Space secrets."
481
  )
482
  with gr.Row():
483
+ with gr.Column():
484
+ image_input = gr.Image(type="pil", label="Image", sources=["upload", "clipboard"])
485
+ task_input = gr.Dropdown(
486
+ choices=list(PROMPTS.keys()),
487
+ value="Convert to Markdown",
488
+ label="OCR task",
 
489
  )
490
+ run_button = gr.Button("Run OCR", variant="primary")
491
+ with gr.Column():
492
+ output_text = gr.Textbox(label="OCR output", lines=18, show_copy_button=True)
493
+ run_button.click(fn=gradio_ocr, inputs=[image_input, task_input], outputs=output_text)
 
 
 
 
 
 
 
 
494
  gr.Markdown(
495
+ "**Note:** OCR can be inaccurate. Verify any extracted identity or demographic information "
496
+ "before using or storing it. The local SQLite database may be lost when the Space restarts "
497
+ "unless you have separately configured persistent storage."
 
 
 
 
498
  )
499
 
 
500
  if __name__ == "__main__":
501
+ demo.queue(max_size=8).launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)
 
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- # Gradio is provided by the Space SDK version in README.md (5.49.1).
2
  transformers==4.57.6
3
  tokenizers==0.22.2
4
  accelerate>=0.33,<2.0
@@ -15,4 +15,3 @@ pymupdf
15
  python-telegram-bot>=22.0,<23.0
16
  httpx>=0.27,<1.0
17
  huggingface-hub>=0.34.0,<1.0
18
- spaces>=0.30.0
 
1
+ # Keep this stack aligned with the DeepSeek-OCR Transformers 4.57.6 model card.
2
  transformers==4.57.6
3
  tokenizers==0.22.2
4
  accelerate>=0.33,<2.0
 
15
  python-telegram-bot>=22.0,<23.0
16
  httpx>=0.27,<1.0
17
  huggingface-hub>=0.34.0,<1.0