cooldan commited on
Commit
ca90d21
·
verified ·
1 Parent(s): e69b9cd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1492 -359
app.py CHANGED
@@ -1,401 +1,1534 @@
1
  #!/usr/bin/env python3
2
  """
3
- Client application for SPaCial AI OCR Service
4
- Enhanced version with full feature support
5
  """
6
 
7
- import gradio as gr
8
- import requests
9
- from PIL import Image, ImageDraw, ImageFont
10
- import io
11
- import json
12
- from typing import Tuple, Dict, Any, Optional
13
-
14
- # API URL for the SPaCial OCR server
15
- API_URL = "https://cooldan-spacial-server-api.hf.space"
16
-
17
- def format_tolerance_info(tolerance_info: Optional[Dict]) -> str:
18
- """Format tolerance information for display"""
19
- if not tolerance_info:
20
- return ""
21
-
22
- value = tolerance_info.get("value", "")
23
- tol_type = tolerance_info.get("tolerance_type", "")
24
- is_diameter = tolerance_info.get("is_diameter", False)
25
-
26
- if tol_type == "none":
27
- prefix = "Ø" if is_diameter else ""
28
- return f" → {prefix}{value}"
29
- elif tol_type == "±":
30
- tol = tolerance_info.get("tolerance_plus", 0)
31
- prefix = "Ø" if is_diameter else ""
32
- return f" → {prefix}{value}±{tol}"
33
- elif tol_type == "+/-":
34
- plus = tolerance_info.get("tolerance_plus", 0)
35
- minus = tolerance_info.get("tolerance_minus", 0)
36
- prefix = "Ø" if is_diameter else ""
37
- return f" → {prefix}{value}+{plus}/-{minus}"
38
- else:
39
- return f" → {tol_type}"
40
-
41
- def get_orientation_symbol(orientation: int) -> str:
42
- """Get symbol for text orientation"""
43
- if orientation == 0:
44
- return "→" # Horizontal
45
- elif orientation == 90:
46
- return "↓" # Vertical down
47
- elif orientation == 180:
48
- return "←" # Horizontal reversed
49
- elif orientation == 270:
50
- return "↑" # Vertical up
51
- elif orientation == 45:
52
- return "↘" # Diagonal
53
- elif orientation == 135:
54
- return "↙" # Diagonal
55
- elif orientation == 225:
56
- return "↖" # Diagonal
57
- elif orientation == 315:
58
- return "↗" # Diagonal
59
- else:
60
- return "⊙" # Other
61
-
62
- def get_bbox_color(zone: Dict) -> Tuple[int, int, int]:
63
- """Determine bbox color based on zone properties"""
64
- is_dimension = zone.get("is_dimension", False)
65
- orientation = zone.get("text_orientation", 0)
66
-
67
- if is_dimension:
68
- # Dimensions in blue shades
69
- if orientation in [45, 135, 225, 315]:
70
- return (0, 100, 255) # Bright blue for diagonal dimensions
71
- else:
72
- return (0, 0, 255) # Standard blue for dimensions
73
- else:
74
- # Regular text in green shades
75
- if orientation in [90, 270]:
76
- return (0, 200, 0) # Bright green for vertical text
77
- elif orientation in [45, 135, 225, 315]:
78
- return (100, 255, 100) # Light green for diagonal text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  else:
80
- return (0, 255, 0) # Standard green for horizontal text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
- def client_process_ocr(pil_img: Image.Image, mode: str = "fast", rotation: int = 0) -> Tuple[Image.Image, str, str]:
83
- """
84
- Function that sends the image to the FastAPI server for OCR processing.
 
 
85
 
86
- Args:
87
- pil_img: PIL Image object
88
- mode: OCR mode ('fast', 'accurate', or 'hardcore')
89
- rotation: Rotation angle in degrees (0, 90, 180, 270)
 
 
 
 
 
 
 
 
 
90
 
91
- Returns:
92
- Tuple of (processed_image, extracted_text, statistics)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  """
 
 
94
 
95
- if pil_img is None:
96
- return None, "Please upload an image first.", ""
 
 
 
 
 
97
 
98
- # 1. Prepare the Image for POST Request
99
- img_byte_arr = io.BytesIO()
100
- # Save the image in JPEG format in memory
101
- pil_img.save(img_byte_arr, format='JPEG')
102
- img_byte_arr.seek(0)
103
 
104
- # Prepare data for submission (multipart/form-data)
105
- files = {'file': ('image.jpg', img_byte_arr, 'image/jpeg')}
 
 
 
106
 
107
- # The processing endpoint is "/ocr/process"
108
- endpoint_url = f"{API_URL}/ocr/process"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
- # Add query parameters
111
- params = {
112
- 'mode': mode,
113
- 'rotation': rotation
114
- }
115
 
116
  try:
117
- gr.Info(f"Sending image to OCR server (mode: {mode}, rotation: {rotation}°)...")
 
118
 
119
- # 2. Send HTTP POST Request
120
- response = requests.post(endpoint_url, files=files, params=params, timeout=60)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
- # Check status code
123
- if response.status_code != 200:
124
- return pil_img, f"Server Error ({response.status_code}): {response.text}", ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
- # 3. Process JSON Response
127
- result_data = response.json()
 
 
 
 
128
 
129
- # Extract zones and metadata
130
- zones = result_data.get("zones", [])
131
- metadata = result_data.get("metadata", {})
132
 
133
- if not zones:
134
- return pil_img, "No text detected in image.", f"Total zones: 0"
 
 
135
 
136
- # 4. Draw Bounding Boxes on Image
137
- draw_img = pil_img.copy()
138
- draw = ImageDraw.Draw(draw_img, 'RGBA')
139
 
140
- # Statistics counters
141
- stats = {
142
- 'total': len(zones),
143
- 'dimensions': 0,
144
- 'with_tolerance': 0,
145
- 'horizontal': 0,
146
- 'vertical': 0,
147
- 'diagonal': 0
148
- }
149
 
150
- full_text_output = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
- for i, zone in enumerate(zones):
153
- text = zone.get("text", "")
154
- confidence = zone.get("confidence", 0.0)
155
- bbox = zone.get("bbox", {})
156
- is_dimension = zone.get("is_dimension", False)
157
- tolerance_info = zone.get("tolerance_info")
158
- orientation = zone.get("text_orientation", 0)
159
-
160
- # Update statistics
161
- if is_dimension:
162
- stats['dimensions'] += 1
163
- if tolerance_info and tolerance_info.get("tolerance_type") != "none":
164
- stats['with_tolerance'] += 1
165
- if orientation in [0, 180]:
166
- stats['horizontal'] += 1
167
- elif orientation in [90, 270]:
168
- stats['vertical'] += 1
169
- elif orientation in [45, 135, 225, 315]:
170
- stats['diagonal'] += 1
171
-
172
- # Text formatting for output
173
- prefix = "📐" if is_dimension else "📝"
174
- orientation_symbol = get_orientation_symbol(orientation)
175
- tolerance_str = format_tolerance_info(tolerance_info)
176
-
177
- full_text_output.append(
178
- f"{i+1}. {prefix} {orientation_symbol} [{confidence:.2f}] {text}{tolerance_str}"
179
- )
180
-
181
- # Draw BBox with color coding
182
- if bbox:
183
- # Fixed: bbox is always dict format from updated server
184
- x1 = bbox.get("x1", 0)
185
- y1 = bbox.get("y1", 0)
186
- x2 = bbox.get("x2", 0) # ✅ FIXED: was x2 before
187
- y2 = bbox.get("y2", 0) # ✅ FIXED: was x2 (BUG!)
 
 
 
 
 
 
 
 
 
 
 
188
 
189
- # Get color based on zone type
190
- color = get_bbox_color(zone)
 
 
191
 
192
- # Draw semi-transparent filled rectangle
193
- draw.rectangle([(x1, y1), (x2, y2)], outline=color, width=3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
- # Draw zone number
196
- try:
197
- # Try to use a font, fallback to default
198
- font = ImageFont.truetype("arial.ttf", 14)
199
- except:
200
- font = ImageFont.load_default()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
- # Draw label background
203
- label = f"{i+1}"
204
- bbox_label = draw.textbbox((x1, y1 - 20), label, font=font)
205
- draw.rectangle(bbox_label, fill=(255, 255, 255, 200))
206
- draw.text((x1, y1 - 20), label, fill=color, font=font)
207
-
208
- gr.Info(f"Processing completed. Found {len(zones)} text zones.")
209
-
210
- # Create statistics string
211
- stats_str = f"""📊 **Statistics:**
212
- - Total zones: {stats['total']}
213
- - Dimensions: {stats['dimensions']} 📐
214
- - With tolerances: {stats['with_tolerance']} ±
215
- - Horizontal: {stats['horizontal']} →
216
- - Vertical: {stats['vertical']} ↓
217
- - Diagonal: {stats['diagonal']} ↘
218
-
219
- 🎯 **Metadata:**
220
- - Detected angle: {metadata.get('detected_angle', 0)}°
221
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
- # Return the drawn image, text, and stats
224
- return draw_img, "\n".join(full_text_output), stats_str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
- except requests.exceptions.Timeout:
227
- return pil_img, f"Timeout Error: Server took too long to respond (>60s).", ""
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- except requests.exceptions.ConnectionError:
230
- return pil_img, f"Connection Error: Cannot reach server at {API_URL}\nMake sure the server is running.", ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
- except requests.exceptions.RequestException as e:
233
- return pil_img, f"API Connection Error: {str(e)}", ""
 
234
 
235
- except json.JSONDecodeError:
236
- return pil_img, f"Error: Invalid response (non-JSON) from server.\nResponse: {response.text[:200]}", ""
 
 
 
 
 
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  except Exception as e:
239
- import traceback
240
- return pil_img, f"Unexpected error: {str(e)}\n\nTraceback:\n{traceback.format_exc()}", ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
- def check_api_status() -> str:
243
- """Check if the API server is running and accessible."""
 
 
 
 
 
 
 
 
244
  try:
245
- response = requests.get(f"{API_URL}/health", timeout=10)
246
- if response.status_code == 200:
247
- data = response.json()
248
- return f"""✅ **API Status: Running**
249
-
250
- 🔧 Service: {data.get('service', 'Unknown')}
251
- 📦 Version: {data.get('version', 'Unknown')}
252
- 🤖 OCR Initialized: {data.get('ocr_initialized', False)}
253
- 🌐 URL: {API_URL}
254
- """
255
- else:
256
- return f"❌ API Error: {response.status_code}\n{response.text}"
257
- except requests.exceptions.Timeout:
258
- return f"⏱️ Timeout: Server is not responding"
259
- except requests.exceptions.ConnectionError:
260
- return f"❌ Connection Error: Cannot reach {API_URL}\n\nMake sure the server is deployed and running."
261
  except Exception as e:
262
- return f"❌ Error: {str(e)}"
263
-
264
- # --- Gradio Interface ---
265
-
266
- custom_css = """
267
- .gradio-container {
268
- font-family: 'Arial', sans-serif;
269
- }
270
- .gr-button-primary {
271
- background: linear-gradient(90deg, #4CAF50, #45a049) !important;
272
- border: none !important;
273
- }
274
- .gr-button-secondary {
275
- background: linear-gradient(90deg, #2196F3, #1976D2) !important;
276
- border: none !important;
277
- }
278
- """
279
 
280
- with gr.Blocks(title="SPaCial OCR Client", css=custom_css) as demo:
281
- gr.Markdown(
282
- """
283
- # 🖼️ SPaCial AI OCR Client (Enhanced)
284
-
285
- This interface connects to the **SPaCial AI OCR API** server for advanced dimension detection.
286
-
287
- ## 🎨 **Color Legend:**
288
- - 🟢 **Green**: Regular text (horizontal/vertical)
289
- - 💚 **Light Green**: Diagonal text (45°)
290
- - 🔵 **Blue**: Dimensions (horizontal/vertical)
291
- - 💙 **Bright Blue**: Diagonal dimensions (45°)
292
-
293
- ## 📐 **Orientation Symbols:**
294
- - → Horizontal | ↓ Vertical | ↘ Diagonal (45°) | ↙ Diagonal (135°)
295
-
296
- ## ⚙️ **OCR Modes:**
297
- - **Fast**: Quick detection for well-visible text
298
- - **Accurate**: Better for small or unclear text
299
- - **Hardcore**: Maximum detection (aggressive parameters)
300
- """
301
- )
302
-
303
- # API Status Check
304
- with gr.Row():
305
- with gr.Column(scale=2):
306
- status_btn = gr.Button("🔍 Check API Status", variant="secondary", size="sm")
307
- with gr.Column(scale=8):
308
- status_output = gr.Markdown(value="Click 'Check API Status' to verify server connection")
309
-
310
- gr.Markdown("---")
311
-
312
- with gr.Row():
313
- with gr.Column():
314
- image_input = gr.Image(
315
- type="pil",
316
- label="📤 1. Upload Technical Drawing",
317
- sources=["upload", "clipboard"],
318
- interactive=True
319
- )
320
-
321
- # OCR Options
322
- gr.Markdown("### ⚙️ OCR Settings")
323
- with gr.Row():
324
- mode_dropdown = gr.Dropdown(
325
- choices=["fast", "accurate", "hardcore"],
326
- value="fast",
327
- label="OCR Mode",
328
- info="Hardcore mode uses aggressive detection parameters"
329
- )
330
- rotation_slider = gr.Slider(
331
- minimum=0,
332
- maximum=270,
333
- value=0,
334
- step=90,
335
- label="Rotation (degrees)",
336
- info="Pre-rotate image before OCR (0, 90, 180, 270)"
337
- )
338
-
339
- process_btn = gr.Button("🚀 Process Image with OCR", variant="primary", size="lg")
340
-
341
- with gr.Column():
342
- # Output columns
343
- image_output = gr.Image(
344
- type="pil",
345
- label="📊 Result with Bounding Boxes",
346
- interactive=False
347
- )
348
-
349
- stats_output = gr.Markdown(
350
- label="Statistics",
351
- value="Process an image to see statistics"
352
- )
353
-
354
- # Text output below (full width)
355
- gr.Markdown("### 📝 Extracted Text Details")
356
- text_output = gr.Textbox(
357
- label="Detected Text with Confidence, Orientation & Tolerances",
358
- lines=15,
359
- interactive=False,
360
- show_copy_button=True
361
- )
362
-
363
- # Examples
364
- gr.Markdown("### 📋 Quick Start Tips")
365
- gr.Markdown("""
366
- 1. **Upload** a technical drawing or document image
367
- 2. **Select** OCR mode (try 'hardcore' for difficult images)
368
- 3. **Adjust** rotation if image is sideways
369
- 4. **Click** 'Process Image with OCR'
370
- 5. **View** results with color-coded bounding boxes
371
-
372
- **Note:** Dimensions (with Ø, ±, tolerances) are automatically highlighted in blue!
373
- """)
374
-
375
- # Event handlers
376
- status_btn.click(
377
- fn=check_api_status,
378
- outputs=status_output
379
- )
380
-
381
- process_btn.click(
382
- fn=client_process_ocr,
383
- inputs=[image_input, mode_dropdown, rotation_slider],
384
- outputs=[image_output, text_output, stats_output]
385
- )
386
-
387
- # Auto-check status on launch
388
- demo.load(
389
- fn=check_api_status,
390
- outputs=status_output
391
- )
392
 
393
- if __name__ == "__main__":
394
- # Launch the Gradio interface
395
- demo.launch(
396
- server_name="0.0.0.0",
397
- server_port=7860,
398
- share=False,
399
- show_error=True
400
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ Hugging Face Spaces deployment for SPaCial AI OCR Service
4
+ FastAPI HTTP Server for OCR Service using PaddleOCR
5
  """
6
 
7
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Body
8
+ from fastapi.responses import JSONResponse
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ import uvicorn
11
+ import cv2
12
+ import numpy as np
13
+ import os
14
+ import tempfile
15
+ import math
16
+ from pathlib import Path
17
+ import logging
18
+
19
+ # Set up logging first
20
+ logging.basicConfig(level=logging.INFO)
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Set environment variables BEFORE importing PaddleOCR
24
+ os.environ['PADDLE_PDX_MODEL_SOURCE'] = 'BOS'
25
+
26
+ # Determine base directory (works for both local and HF Spaces)
27
+ base_dir = os.getenv('HOME', os.getcwd())
28
+ if not os.path.exists(base_dir) or not os.access(base_dir, os.W_OK):
29
+ # Fallback to current directory if HOME is not writable
30
+ base_dir = os.getcwd()
31
+
32
+ # Set paths relative to base directory
33
+ paddle_home = os.path.join(base_dir, '.paddlex')
34
+ paddleocr_home = os.path.join(base_dir, '.paddleocr')
35
+ temp_dir = os.path.join(base_dir, 'temp')
36
+
37
+ os.environ['PADDLE_HOME'] = paddle_home
38
+ os.environ['PADDLEX_HOME'] = paddle_home
39
+ os.environ['PADDLEOCR_HOME'] = paddleocr_home
40
+ os.environ['TEMP'] = temp_dir
41
+ os.environ['TMP'] = temp_dir
42
+ os.environ['TMPDIR'] = temp_dir
43
+
44
+ logger.info(f"Using base directory: {base_dir}")
45
+ logger.info(f"PaddleOCR home: {paddleocr_home}")
46
+
47
+ # Let PaddleOCR auto-detect GPU/CPU (newer versions handle this automatically)
48
+
49
+ # Create directories with proper permissions for all PaddleOCR models
50
+ try:
51
+ os.makedirs(paddle_home, mode=0o777, exist_ok=True)
52
+ os.makedirs(paddleocr_home, mode=0o777, exist_ok=True)
53
+ os.makedirs(os.path.join(paddleocr_home, 'whl'), mode=0o777, exist_ok=True)
54
+
55
+ # Detection model
56
+ os.makedirs(os.path.join(paddleocr_home, 'whl', 'det', 'en', 'en_PP-OCRv3_det_infer'), mode=0o777, exist_ok=True)
57
+
58
+ # Recognition model
59
+ os.makedirs(os.path.join(paddleocr_home, 'whl', 'rec', 'en', 'en_PP-OCRv3_rec_infer'), mode=0o777, exist_ok=True)
60
+
61
+ # Classification model
62
+ os.makedirs(os.path.join(paddleocr_home, 'whl', 'cls', 'en_ppocr_mobile_v2.0_cls_infer'), mode=0o777, exist_ok=True)
63
+
64
+ os.makedirs(temp_dir, mode=0o777, exist_ok=True)
65
+ logger.info("Successfully created all PaddleOCR model directories with write permissions")
66
+ except Exception as e:
67
+ logger.error(f"Failed to create directories: {e}")
68
+ logger.warning("Continuing with default PaddleOCR paths...")
69
+
70
+ # Now import PaddleOCR
71
+ from paddleocr import PaddleOCR
72
+
73
+
74
+ # Initialize FastAPI app
75
+ app = FastAPI(
76
+ title="SPaCial AI OCR Service",
77
+ description="OCR service for dimension detection using PaddleOCR",
78
+ version="1.0.0"
79
+ )
80
+
81
+ # Add CORS middleware for HF Spaces
82
+ app.add_middleware(
83
+ CORSMiddleware,
84
+ allow_origins=["*"], # Allow all origins for HF Spaces
85
+ allow_credentials=True,
86
+ allow_methods=["*"],
87
+ allow_headers=["*"],
88
+ )
89
+
90
+ # Global OCR instance (initialize once at startup)
91
+ ocr = None
92
+
93
+ def is_dimension_text(text):
94
+ """Check if text looks like a dimension"""
95
+ import re
96
+ # Look for numbers, possibly with units or decimal points
97
+ if re.search(r'\d+\.?\d*\s*(mm|cm|m|in|inch)?', text.strip(), re.IGNORECASE):
98
+ return True
99
+ # Look for pure numbers
100
+ if re.match(r'^\d+\.?\d*$', text.strip()):
101
+ return True
102
+ return False
103
+
104
+ def parse_tolerance(text):
105
+ """Parse tolerance information from text with improved patterns"""
106
+ import re
107
+
108
+ if not text:
109
+ return None
110
+
111
+ # Clean the text and handle common OCR errors
112
+ clean_text = text.strip()
113
+
114
+ # Fix common OCR errors
115
+ clean_text = clean_text.replace('0', 'Ø') # Fix Ø being read as 0
116
+ clean_text = clean_text.replace('ØØ', 'Ø') # Fix double Ø
117
+ clean_text = clean_text.replace('O', 'Ø') # Fix O being read as Ø
118
+ clean_text = clean_text.replace('°', '') # Remove degree symbols that might interfere
119
+ clean_text = re.sub(r'\s+', ' ', clean_text) # Normalize spaces
120
+
121
+ # Pattern 1: ± tolerance (e.g., "28±0.03", "104±0.04", "Ø48±0.03", "28 ± 0.03")
122
+ plus_minus_match = re.search(r'(?:Ø|DIA|DIAMETER)?\s*(\d+\.?\d*)\s*±\s*(\d+\.?\d*)', clean_text, re.IGNORECASE)
123
+ if plus_minus_match:
124
+ value = float(plus_minus_match.group(1))
125
+ tolerance = float(plus_minus_match.group(2))
126
+ is_diameter = bool(re.search(r'[ØDIA]', clean_text, re.IGNORECASE))
127
+ return {
128
+ "value": value,
129
+ "tolerance_plus": tolerance,
130
+ "tolerance_minus": tolerance,
131
+ "tolerance_type": "±",
132
+ "is_diameter": is_diameter,
133
+ "original_text": text
134
+ }
135
+
136
+ # Pattern 2: +tolerance/-tolerance (e.g., "25.5+0.1/-0.05", "Ø48+0.1/-0.05", "25.5 +0.1 / -0.05")
137
+ asymmetric_match = re.search(r'(?:Ø|DIA|DIAMETER)?\s*(\d+\.?\d*)\s*\+\s*(\d+\.?\d*)\s*/\s*-\s*(\d+\.?\d*)', clean_text, re.IGNORECASE)
138
+ if asymmetric_match:
139
+ value = float(asymmetric_match.group(1))
140
+ plus_tolerance = float(asymmetric_match.group(2))
141
+ minus_tolerance = float(asymmetric_match.group(3))
142
+ is_diameter = bool(re.search(r'[ØDIA]', clean_text, re.IGNORECASE))
143
+ return {
144
+ "value": value,
145
+ "tolerance_plus": plus_tolerance,
146
+ "tolerance_minus": minus_tolerance,
147
+ "tolerance_type": "+/-",
148
+ "is_diameter": is_diameter,
149
+ "original_text": text
150
+ }
151
+
152
+ # Pattern 3: Value with negative tolerance only (e.g., "Ø48-0.03", "48 -0.03")
153
+ negative_only_match = re.search(r'(?:Ø|DIA|DIAMETER)?\s*(\d+\.?\d*)\s*-\s*(\d+\.?\d*)', clean_text, re.IGNORECASE)
154
+ if negative_only_match:
155
+ value = float(negative_only_match.group(1))
156
+ minus_tolerance = float(negative_only_match.group(2))
157
+ is_diameter = bool(re.search(r'[ØDIA]', clean_text, re.IGNORECASE))
158
+ return {
159
+ "value": value,
160
+ "tolerance_plus": 0.0,
161
+ "tolerance_minus": minus_tolerance,
162
+ "tolerance_type": "-only",
163
+ "is_diameter": is_diameter,
164
+ "original_text": text
165
+ }
166
+
167
+ # Pattern 4: Value with positive tolerance only (e.g., "Ø48+0.03", "48 +0.03")
168
+ positive_only_match = re.search(r'(?:Ø|DIA|DIAMETER)?\s*(\d+\.?\d*)\s*\+\s*(\d+\.?\d*)', clean_text, re.IGNORECASE)
169
+ if positive_only_match:
170
+ value = float(positive_only_match.group(1))
171
+ plus_tolerance = float(positive_only_match.group(2))
172
+ is_diameter = bool(re.search(r'[ØDIA]', clean_text, re.IGNORECASE))
173
+ return {
174
+ "value": value,
175
+ "tolerance_plus": plus_tolerance,
176
+ "tolerance_minus": 0.0,
177
+ "tolerance_type": "+only",
178
+ "is_diameter": is_diameter,
179
+ "original_text": text
180
+ }
181
+
182
+ # Pattern 5: ISO tolerance classes (e.g., "48H7", "25.5f6", "Ø48H7")
183
+ iso_tolerance_match = re.search(r'(?:Ø|DIA|DIAMETER)?\s*(\d+\.?\d*)\s*([A-Za-z]\d+)', clean_text, re.IGNORECASE)
184
+ if iso_tolerance_match:
185
+ value = float(iso_tolerance_match.group(1))
186
+ tolerance_class = iso_tolerance_match.group(2).upper()
187
+ is_diameter = bool(re.search(r'[ØDIA]', clean_text, re.IGNORECASE))
188
+
189
+ # Basic ISO tolerance lookup (simplified)
190
+ iso_tolerances = {
191
+ 'H7': {'plus': 0.025, 'minus': 0.0},
192
+ 'H8': {'plus': 0.039, 'minus': 0.0},
193
+ 'H9': {'plus': 0.062, 'minus': 0.0},
194
+ 'F6': {'plus': 0.0, 'minus': 0.013},
195
+ 'F7': {'plus': 0.0, 'minus': 0.025},
196
+ 'G6': {'plus': 0.0, 'minus': 0.009},
197
+ 'G7': {'plus': 0.0, 'minus': 0.020}
198
+ }
199
+
200
+ tolerance_info = iso_tolerances.get(tolerance_class, {'plus': 0.0, 'minus': 0.0})
201
+
202
+ return {
203
+ "value": value,
204
+ "tolerance_plus": tolerance_info['plus'],
205
+ "tolerance_minus": tolerance_info['minus'],
206
+ "tolerance_type": f"ISO {tolerance_class}",
207
+ "is_diameter": is_diameter,
208
+ "original_text": text
209
+ }
210
+
211
+ # Pattern 6: Single value with no tolerance (e.g., "48", "Ø48", "DIA 48")
212
+ single_value_match = re.search(r'(?:Ø|DIA|DIAMETER)?\s*(\d+\.?\d*)', clean_text, re.IGNORECASE)
213
+ if single_value_match:
214
+ value = float(single_value_match.group(1))
215
+ is_diameter = bool(re.search(r'[ØDIA]', clean_text, re.IGNORECASE))
216
+ return {
217
+ "value": value,
218
+ "tolerance_plus": 0.0,
219
+ "tolerance_minus": 0.0,
220
+ "tolerance_type": "none",
221
+ "is_diameter": is_diameter,
222
+ "original_text": text
223
+ }
224
+
225
+ return None
226
+
227
+ def merge_vertical_text(zones):
228
+ """Merge nearby vertical text that might have been split by OCR"""
229
+ if len(zones) < 2:
230
+ return zones
231
+
232
+ merged_zones = []
233
+ used_indices = set()
234
+
235
+ for i, zone1 in enumerate(zones):
236
+ if i in used_indices:
237
+ continue
238
+
239
+ bbox1 = zone1.get('bbox', [])
240
+ if len(bbox1) < 4:
241
+ merged_zones.append(zone1)
242
+ continue
243
+
244
+ x1_1, y1_1, x2_1, y2_1 = bbox1[0], bbox1[1], bbox1[2], bbox1[3]
245
+ text1 = zone1.get('text', '')
246
+
247
+ # Look for nearby zones to merge
248
+ merged_text = text1
249
+ merged_bbox = [x1_1, y1_1, x2_1, y2_1]
250
+
251
+ for j, zone2 in enumerate(zones[i+1:], i+1):
252
+ if j in used_indices:
253
+ continue
254
+
255
+ bbox2 = zone2.get('bbox', [])
256
+ if len(bbox2) < 4:
257
+ continue
258
+
259
+ x1_2, y1_2, x2_2, y2_2 = bbox2[0], bbox2[1], bbox2[2], bbox2[3]
260
+ text2 = zone2.get('text', '')
261
+
262
+ # Check if zones are vertically close and horizontally aligned
263
+ vertical_distance = abs((y1_1 + y2_1) / 2 - (y1_2 + y2_2) / 2)
264
+ horizontal_overlap = not (x2_1 < x1_2 or x2_2 < x1_1)
265
+
266
+ # If zones are close vertically and overlap horizontally, merge them
267
+ if vertical_distance < 30 and horizontal_overlap:
268
+ # Merge text
269
+ if y1_2 < y1_1: # zone2 is above zone1
270
+ merged_text = text2 + text1
271
+ merged_bbox = [min(x1_1, x1_2), min(y1_1, y1_2),
272
+ max(x2_1, x2_2), max(y2_1, y2_2)]
273
+ else: # zone1 is above zone2
274
+ merged_text = text1 + text2
275
+ merged_bbox = [min(x1_1, x1_2), min(y1_1, y1_2),
276
+ max(x2_1, x2_2), max(y2_1, y2_2)]
277
+
278
+ used_indices.add(j)
279
+ logger.info(f"Merged vertical text: '{text1}' + '{text2}' = '{merged_text}'")
280
+
281
+ # Create merged zone
282
+ merged_zone = {
283
+ "id": f"ocr_zone_{len(merged_zones)}",
284
+ "text": merged_text,
285
+ "confidence": zone1.get('confidence', 0.9),
286
+ "bbox": merged_bbox,
287
+ "x": float(merged_bbox[0]),
288
+ "y": float(merged_bbox[1]),
289
+ "width": float(merged_bbox[2] - merged_bbox[0]),
290
+ "height": float(merged_bbox[3] - merged_bbox[1]),
291
+ "orientation": zone1.get('orientation', 0),
292
+ "rotation": 0
293
+ }
294
+
295
+ merged_zones.append(merged_zone)
296
+ used_indices.add(i)
297
+
298
+ # Add any remaining zones that weren't merged
299
+ for i, zone in enumerate(zones):
300
+ if i not in used_indices:
301
+ merged_zones.append(zone)
302
+
303
+ logger.info(f"Merged {len(zones)} zones into {len(merged_zones)} zones")
304
+ return merged_zones
305
+
306
+ def create_overlay_image(image_path, zones, lines=None, dimension_lines=None):
307
+ """Create overlay image with bounding boxes and lines (using direct coordinates, no rotation)"""
308
+ try:
309
+ # Load original image
310
+ img = cv2.imread(str(image_path))
311
+ if img is None:
312
+ return None
313
+
314
+ # Create overlay with better visualization
315
+ overlay = img.copy()
316
+
317
+ # Create a separate overlay for semi-transparent rectangles
318
+ overlay_color = np.zeros_like(img)
319
+
320
+ # Draw lines first (so they appear behind text boxes)
321
+ if lines:
322
+ for line in lines:
323
+ x1, y1, x2, y2 = int(line['x1']), int(line['y1']), int(line['x2']), int(line['y2'])
324
+ cv2.line(overlay, (x1, y1), (x2, y2), (128, 128, 128), 1) # Gray lines
325
+
326
+ # Draw dimension lines in different color
327
+ if dimension_lines:
328
+ for dim_line in dimension_lines:
329
+ line = dim_line['closest_line']
330
+ x1, y1, x2, y2 = int(line['x1']), int(line['y1']), int(line['x2']), int(line['y2'])
331
+ cv2.line(overlay, (x1, y1), (x2, y2), (0, 255, 255), 2) # Yellow for dimension lines
332
+
333
+ # Draw clean bounding boxes for each zone
334
+ for i, zone in enumerate(zones):
335
+ bbox = zone.get("bbox", {})
336
+ if not bbox:
337
+ continue
338
+
339
+ # Extract coordinates with NaN and type safety
340
+ x1 = bbox.get("x1", 0)
341
+ y1 = bbox.get("y1", 0)
342
+ x2 = bbox.get("x2", 0)
343
+ y2 = bbox.get("y2", 0)
344
+
345
+ # Validate coordinates
346
+ if not all(isinstance(coord, (int, float)) and not math.isnan(coord) for coord in [x1, y1, x2, y2]):
347
+ logger.warning(f"Invalid coordinates in zone {i}: x1={x1}, y1={y1}, x2={x2}, y2={y2}")
348
+ continue
349
+
350
+ # Ensure coordinates are within image bounds
351
+ x1 = max(0, min(int(x1), img.shape[1] - 1))
352
+ y1 = max(0, min(int(y1), img.shape[0] - 1))
353
+ x2 = max(0, min(int(x2), img.shape[1]))
354
+ y2 = max(0, min(int(y2), img.shape[0]))
355
+
356
+ # Skip if box is too small
357
+ if x2 - x1 < 1 or y2 - y1 < 1:
358
+ continue
359
+
360
+ # Color for this zone (cycling through colors)
361
+ colors = [(0, 255, 0), (255, 0, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255), (0, 255, 255)]
362
+ color = colors[i % len(colors)]
363
+
364
+ # Draw semi-transparent rectangle
365
+ cv2.rectangle(overlay_color,
366
+ (x1, y1),
367
+ (x2, y2),
368
+ color, -1) # Filled rectangle
369
+
370
+ # Draw clean border
371
+ cv2.rectangle(overlay,
372
+ (x1, y1),
373
+ (x2, y2),
374
+ color, 2) # Border only
375
+
376
+ # Draw clean text label with background
377
+ label_text = f"{i+1}. {zone.get('text', '')} ({int(zone.get('confidence', 0)*100)}%)"
378
+ font_scale = 0.5
379
+ thickness = 1
380
+ (text_width, text_height), _ = cv2.getTextSize(label_text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
381
+
382
+ # Position label above the box
383
+ label_x = x1
384
+ label_y = max(y1 - 10, text_height + 5)
385
+
386
+ # Ensure label is within image bounds
387
+ if label_y > 0 and label_x >= 0:
388
+ # Draw text background
389
+ cv2.rectangle(overlay,
390
+ (label_x - 2, label_y - text_height - 2),
391
+ (label_x + text_width + 2, label_y + 2),
392
+ (255, 255, 255), -1) # White background
393
+
394
+ # Draw text border for better readability
395
+ cv2.putText(overlay, label_text,
396
+ (label_x, label_y),
397
+ cv2.FONT_HERSHEY_SIMPLEX, font_scale, (255, 255, 255), thickness + 1) # White border
398
+ cv2.putText(overlay, label_text,
399
+ (label_x, label_y),
400
+ cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), thickness) # Black text
401
+
402
+ # Blend the colored overlay with the original image
403
+ alpha = 0.3
404
+ overlay = cv2.addWeighted(overlay, 1, overlay_color, alpha, 0)
405
+
406
+ # Save overlay image
407
+ overlay_path = str(image_path).replace('.', '_overlay.')
408
+ cv2.imwrite(overlay_path, overlay)
409
+
410
+ return overlay_path
411
+
412
+ except Exception as e:
413
+ logger.error(f"Error creating overlay: {e}")
414
+ import traceback
415
+ logger.error(f"Traceback: {traceback.format_exc()}")
416
+ return None
417
+
418
+ def process_image(image_path, mode="fast", rotation=0):
419
+ """Process a single image and return OCR results with specified mode"""
420
+ try:
421
+ # Get image dimensions for coordinate transformation
422
+ img = cv2.imread(str(image_path))
423
+ if img is None:
424
+ logger.error(f"Failed to load image: {image_path}")
425
+ return {"zones": [], "metadata": {"error": "Failed to load image"}}
426
+
427
+ height, width = img.shape[:2]
428
+
429
+ # Apply rotation if specified
430
+ if rotation != 0:
431
+ center = (width // 2, height // 2)
432
+ rotation_matrix = cv2.getRotationMatrix2D(center, rotation, 1.0)
433
+ img = cv2.warpAffine(img, rotation_matrix, (width, height))
434
+
435
+ # Save rotated image temporarily
436
+ rotated_path = str(image_path).replace('.jpg', '_rotated.jpg')
437
+ cv2.imwrite(rotated_path, img)
438
+ image_path = rotated_path
439
+
440
+ # Use single OCR model
441
+ logger.info(f"Processing image with OCR mode: {mode}, rotation: {rotation}")
442
+
443
+ # HARDCORE OCR MODE - aggressive parameters for maximum detection
444
+ if mode == "accurate" or mode == "hardcore":
445
+ logger.info("Using HARDCORE OCR parameters for maximum detection")
446
+ try:
447
+ result = ocr.ocr(str(image_path),
448
+ det_db_thresh=0.3, # Lower threshold for detection
449
+ det_db_box_thresh=0.5, # Lower box threshold
450
+ det_db_unclip_ratio=1.8, # Higher unclip ratio
451
+ rec_batch_num=6, # More batch processing
452
+ use_angle_cls=True, # Enable angle classification
453
+ use_textline_orientation=True, # Enable text line orientation
454
+ lang='en')
455
+ except Exception as e:
456
+ logger.warning(f"Hardcore mode failed, falling back to predict: {e}")
457
+ result = ocr.predict(str(image_path))
458
  else:
459
+ # Use ocr.predict() method (PaddleOCR 2.8+ API)
460
+ result = ocr.predict(str(image_path))
461
+
462
+ zones = []
463
+
464
+ # Handle both result formats (predict vs ocr)
465
+ if result and len(result) > 0:
466
+ # Check if it's predict format (with .json attribute)
467
+ if hasattr(result[0], 'json'):
468
+ # Process predict() format
469
+ for res in result:
470
+ if hasattr(res, 'json') and 'res' in res.json:
471
+ res_data = res.json['res']
472
+ if 'rec_texts' in res_data and 'rec_scores' in res_data:
473
+ texts = res_data['rec_texts']
474
+ scores = res_data['rec_scores']
475
+ dt_polys = res_data.get('dt_polys', [])
476
+ text_orientations = res_data.get('textline_orientation_angles', [])
477
+
478
+ for i, (text, score) in enumerate(zip(texts, scores)):
479
+ if text.strip() and i < len(dt_polys):
480
+ poly = dt_polys[i]
481
+
482
+ # Convert polygon to bounding box
483
+ x_coords = [p[0] for p in poly]
484
+ y_coords = [p[1] for p in poly]
485
+ x1, x2 = min(x_coords), max(x_coords)
486
+ y1, y2 = min(y_coords), max(y_coords)
487
+
488
+ # Validate coordinates with NaN checks
489
+ if (math.isnan(x1) or math.isnan(x2) or math.isnan(y1) or math.isnan(y2) or
490
+ x1 >= x2 or y1 >= y2 or x2 - x1 < 1 or y2 - y1 < 1):
491
+ logger.warning(f"Invalid coordinates for text '{text}'")
492
+ continue
493
+
494
+ # Get text orientation
495
+ text_orientation = 0
496
+ if i < len(text_orientations):
497
+ orientation_angle = text_orientations[i]
498
+ if orientation_angle == 1:
499
+ text_orientation = 90
500
+ elif orientation_angle == 2:
501
+ text_orientation = 180
502
+ elif orientation_angle == 3:
503
+ text_orientation = 270
504
+
505
+ # Enhanced 45° orientation detection
506
+ if text_orientation == 0 and len(poly) >= 4:
507
+ # Calculate angles of polygon edges
508
+ edge1 = [poly[1][0] - poly[0][0], poly[1][1] - poly[0][1]]
509
+ edge2 = [poly[2][0] - poly[1][0], poly[2][1] - poly[1][1]]
510
+
511
+ angle1 = math.degrees(math.atan2(edge1[1], edge1[0]))
512
+ angle2 = math.degrees(math.atan2(edge2[1], edge2[0]))
513
+
514
+ # Normalize to 0-360
515
+ angle1 = (angle1 + 360) % 360
516
+ angle2 = (angle2 + 360) % 360
517
+
518
+ # Check for 45° patterns (±10° tolerance)
519
+ for angle in [angle1, angle2]:
520
+ if (35 <= angle <= 55):
521
+ text_orientation = 45
522
+ break
523
+ elif (125 <= angle <= 145):
524
+ text_orientation = 135
525
+ break
526
+ elif (215 <= angle <= 235):
527
+ text_orientation = 225
528
+ break
529
+ elif (305 <= angle <= 325):
530
+ text_orientation = 315
531
+ break
532
+
533
+ # Create zone with consistent bbox structure
534
+ zone = {
535
+ "id": f"ocr_zone_{i}",
536
+ "text": text,
537
+ "confidence": float(score),
538
+ "bbox": {
539
+ "x1": int(x1),
540
+ "y1": int(y1),
541
+ "x2": int(x2),
542
+ "y2": int(y2),
543
+ "width": int(x2 - x1),
544
+ "height": int(y2 - y1)
545
+ },
546
+ "polygon": poly,
547
+ "text_orientation": text_orientation,
548
+ "is_dimension": is_dimension_text(text),
549
+ "tolerance_info": parse_tolerance(text)
550
+ }
551
+ zones.append(zone)
552
+ else:
553
+ # Process ocr() format: [[[bbox, (text, confidence)], ...]]
554
+ for line in result:
555
+ if line:
556
+ for item in line:
557
+ if len(item) >= 2:
558
+ bbox = item[0]
559
+ text_info = item[1]
560
+
561
+ # Handle text that might be tuple or string
562
+ if isinstance(text_info, (tuple, list)) and len(text_info) >= 2:
563
+ text = text_info[0]
564
+ confidence = text_info[1]
565
+ elif isinstance(text_info, str):
566
+ text = text_info
567
+ confidence = 0.9
568
+ else:
569
+ continue
570
+
571
+ if isinstance(text, str) and text.strip():
572
+ # Extract coordinates from bbox
573
+ x_coords = [point[0] for point in bbox]
574
+ y_coords = [point[1] for point in bbox]
575
+ x1, x2 = min(x_coords), max(x_coords)
576
+ y1, y2 = min(y_coords), max(y_coords)
577
+
578
+ # Validate with NaN checks
579
+ if (math.isnan(x1) or math.isnan(x2) or math.isnan(y1) or math.isnan(y2) or
580
+ x1 >= x2 or y1 >= y2 or x2 - x1 < 1 or y2 - y1 < 1):
581
+ continue
582
+
583
+ # Basic orientation detection
584
+ text_orientation = 0
585
+ width_bbox = x2 - x1
586
+ height_bbox = y2 - y1
587
+ if height_bbox > width_bbox * 1.5:
588
+ text_orientation = 90
589
+
590
+ # Create zone
591
+ zone = {
592
+ "id": f"ocr_zone_{len(zones)}",
593
+ "text": text,
594
+ "confidence": float(confidence),
595
+ "bbox": {
596
+ "x1": int(x1),
597
+ "y1": int(y1),
598
+ "x2": int(x2),
599
+ "y2": int(y2),
600
+ "width": int(x2 - x1),
601
+ "height": int(y2 - y1)
602
+ },
603
+ "polygon": bbox,
604
+ "text_orientation": text_orientation,
605
+ "is_dimension": is_dimension_text(text),
606
+ "tolerance_info": parse_tolerance(text)
607
+ }
608
+ zones.append(zone)
609
+
610
+ # Create overlay image with bounding boxes
611
+ overlay_path = create_overlay_image(image_path, zones)
612
+
613
+ # Clean up rotated image if created
614
+ if rotation != 0 and os.path.exists(str(image_path)) and '_rotated' in str(image_path):
615
+ try:
616
+ os.unlink(str(image_path))
617
+ except:
618
+ pass
619
+
620
+ return {
621
+ "zones": zones,
622
+ "metadata": {
623
+ "total_zones": len(zones),
624
+ "overlay_path": overlay_path,
625
+ "detected_angle": 0
626
+ }
627
+ }
628
+
629
+ except Exception as e:
630
+ logger.error(f"Error processing image: {e}")
631
+ import traceback
632
+ logger.error(f"Traceback: {traceback.format_exc()}")
633
+ return {
634
+ "zones": [],
635
+ "metadata": {"error": str(e)}
636
+ }
637
 
638
+ @app.on_event("startup")
639
+ async def startup_event():
640
+ """Initialize PaddleOCR models on startup"""
641
+ global ocr
642
+ logger.info("Initializing PaddleOCR model...")
643
 
644
+ # Single OCR instance (auto GPU detection for newer PaddleOCR)
645
+ try:
646
+ logger.info("Initializing PaddleOCR with auto GPU detection...")
647
+ ocr = PaddleOCR(
648
+ use_doc_orientation_classify=False, # Disable for better performance
649
+ use_doc_unwarping=False,
650
+ use_textline_orientation=True, # Enable text line orientation
651
+ lang='en'
652
+ )
653
+ logger.info("PaddleOCR initialized successfully with auto GPU detection!")
654
+ except Exception as error:
655
+ logger.error(f"PaddleOCR initialization failed: {error}")
656
+ raise
657
 
658
+ logger.info("PaddleOCR initialized successfully!")
659
+
660
+ @app.get("/")
661
+ async def root():
662
+ """Health check endpoint"""
663
+ return {
664
+ "message": "SPaCial AI OCR Service",
665
+ "status": "running",
666
+ "ocr_initialized": ocr is not None,
667
+ "service": "SPaCial AI OCR Service",
668
+ "version": "1.0.0"
669
+ }
670
+
671
+ @app.post("/ocr/process")
672
+ async def process_ocr(
673
+ file: UploadFile = File(...),
674
+ mode: str = Query("fast", description="OCR mode: 'fast' for position detection, 'accurate' or 'hardcore' for maximum text detection"),
675
+ rotation: int = Query(0, description="Rotation angle in degrees (0, 90, 180, 270)")
676
+ ):
677
  """
678
+ Process uploaded image with OCR
679
+ Returns detected zones with bounding boxes and text
680
 
681
+ Parameters:
682
+ - file: Image file to process
683
+ - mode: 'fast' (default) or 'accurate'/'hardcore' for maximum detection
684
+ - rotation: Rotation angle in degrees (0, 90, 180, 270)
685
+ """
686
+ if ocr is None:
687
+ raise HTTPException(status_code=503, detail="OCR service not initialized")
688
 
689
+ # Validate file type
690
+ if not file.content_type or not file.content_type.startswith('image/'):
691
+ raise HTTPException(status_code=400, detail="File must be an image")
 
 
692
 
693
+ # Save uploaded file to temporary location
694
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
695
+ content = await file.read()
696
+ temp_file.write(content)
697
+ temp_path = temp_file.name
698
 
699
+ try:
700
+ logger.info(f"Processing image: {temp_path}, mode: {mode}, rotation: {rotation}")
701
+
702
+ # Process image with specified mode and rotation
703
+ result = process_image(temp_path, mode, rotation)
704
+
705
+ logger.info(f"OCR result: {len(result.get('zones', []))} zones found")
706
+
707
+ # Clean up temporary file
708
+ os.unlink(temp_path)
709
+
710
+ return JSONResponse(content=result)
711
+
712
+ except Exception as e:
713
+ # Clean up temporary file on error
714
+ if os.path.exists(temp_path):
715
+ os.unlink(temp_path)
716
+
717
+ logger.error(f"Error processing uploaded file: {e}")
718
+ import traceback
719
+ logger.error(f"Traceback: {traceback.format_exc()}")
720
+ raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
721
+
722
+ @app.post("/ocr/process-path")
723
+ async def process_ocr_path(image_path: str = Query(...), mode: str = Query("fast", description="OCR mode: 'fast' for position detection, 'accurate' for hard text search")):
724
+ """
725
+ Process image from file path
726
+ Returns detected zones with bounding boxes and text
727
+ """
728
+ if ocr is None:
729
+ raise HTTPException(status_code=503, detail="OCR service not initialized")
730
 
731
+ if not os.path.exists(image_path):
732
+ raise HTTPException(status_code=404, detail="Image file not found")
 
 
 
733
 
734
  try:
735
+ result = process_image(image_path, mode)
736
+ return JSONResponse(content=result)
737
 
738
+ except Exception as e:
739
+ logger.error(f"Error processing image path: {e}")
740
+ raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
741
+
742
+ @app.post("/ocr/process-center")
743
+ async def process_center_point(request: dict = Body(...)):
744
+ """Process center point to find dimension and fit box to text"""
745
+ try:
746
+ # Extract data from request
747
+ image_data = request.get('image', '')
748
+ center_point = request.get('center_point', {})
749
+ center_x = center_point.get('x', 0)
750
+ center_y = center_point.get('y', 0)
751
+
752
+ if not image_data:
753
+ raise HTTPException(status_code=400, detail="No image data provided")
754
+
755
+ # Decode base64 image
756
+ import base64
757
+ if image_data.startswith('data:image'):
758
+ image_data = image_data.split(',')[1]
759
+
760
+ image_bytes = base64.b64decode(image_data)
761
+
762
+ # Save temporary image
763
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file:
764
+ tmp_file.write(image_bytes)
765
+ tmp_path = tmp_file.name
766
 
767
+ try:
768
+ # Process image with OCR
769
+ result = process_image(tmp_path, mode="fast")
770
+
771
+ if not result or not result.get('zones'):
772
+ return {"zones": [], "message": "No text detected"}
773
+
774
+ # Find the closest zone to the center point
775
+ zones = result['zones']
776
+ closest_zone = None
777
+ min_distance = float('inf')
778
+
779
+ for zone in zones:
780
+ bbox = zone.get('bbox', {})
781
+ if not bbox:
782
+ continue
783
+
784
+ # Calculate zone center
785
+ zone_center_x = (bbox.get('x1', 0) + bbox.get('x2', 0)) / 2
786
+ zone_center_y = (bbox.get('y1', 0) + bbox.get('y2', 0)) / 2
787
+
788
+ # Calculate distance to clicked point
789
+ distance = ((center_x - zone_center_x) ** 2 + (center_y - zone_center_y) ** 2) ** 0.5
790
+
791
+ if distance < min_distance:
792
+ min_distance = distance
793
+ closest_zone = zone
794
+
795
+ # If we found a close zone, return it
796
+ if closest_zone and min_distance < 100: # Within 100 pixels
797
+ # Ensure we return the zone with proper bbox coordinates
798
+ zone_bbox = closest_zone.get('bbox', {})
799
+ if isinstance(zone_bbox, dict):
800
+ # Return zone with image coordinates
801
+ return {
802
+ "zone": {
803
+ "text": closest_zone.get('text', ''),
804
+ "confidence": closest_zone.get('confidence', 0),
805
+ "bbox": zone_bbox,
806
+ "x": zone_bbox.get('x1', 0),
807
+ "y": zone_bbox.get('y1', 0),
808
+ "width": zone_bbox.get('width', 0),
809
+ "height": zone_bbox.get('height', 0),
810
+ "tolerance_info": closest_zone.get('tolerance_info', {})
811
+ },
812
+ "message": f"Found dimension at center point (distance: {min_distance:.1f}px)",
813
+ "center_point": {"x": center_x, "y": center_y},
814
+ "found_zone": closest_zone.get('text', ''),
815
+ "fitted": True
816
+ }
817
+ else:
818
+ return {
819
+ "zone": closest_zone,
820
+ "message": f"Found dimension at center point (distance: {min_distance:.1f}px)",
821
+ "center_point": {"x": center_x, "y": center_y},
822
+ "found_zone": closest_zone.get('text', ''),
823
+ "fitted": True
824
+ }
825
+ else:
826
+ return {
827
+ "zones": [],
828
+ "message": f"No dimension found near center point (closest: {min_distance:.1f}px)",
829
+ "center_point": {"x": center_x, "y": center_y}
830
+ }
831
+
832
+ finally:
833
+ # Clean up temporary file
834
+ if os.path.exists(tmp_path):
835
+ os.unlink(tmp_path)
836
+
837
+ except Exception as e:
838
+ logger.error(f"Error processing center point: {e}")
839
+ raise HTTPException(status_code=500, detail=str(e))
840
 
841
+ @app.post("/ocr/process-with-lines")
842
+ async def process_with_lines(request: dict = Body(...)):
843
+ """Process image with OCR and detect dimension lines"""
844
+ try:
845
+ # Extract data from request
846
+ image_data = request.get('image', '')
847
 
848
+ if not image_data:
849
+ raise HTTPException(status_code=400, detail="No image data provided")
 
850
 
851
+ # Decode base64 image
852
+ import base64
853
+ if image_data.startswith('data:image'):
854
+ image_data = image_data.split(',')[1]
855
 
856
+ image_bytes = base64.b64decode(image_data)
 
 
857
 
858
+ # Save temporary image
859
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file:
860
+ tmp_file.write(image_bytes)
861
+ tmp_path = tmp_file.name
 
 
 
 
 
862
 
863
+ try:
864
+ # Process image with OCR
865
+ result = process_image(tmp_path, mode="fast")
866
+
867
+ if not result or not result.get('zones'):
868
+ return {"zones": [], "lines": [], "dimension_lines": [], "message": "No text detected"}
869
+
870
+ # Detect lines in the image
871
+ detected_lines = detect_dimension_lines(tmp_path)
872
+
873
+ # Find lines near dimensions
874
+ dimension_lines = find_lines_near_dimensions(result['zones'], detected_lines)
875
+
876
+ # Create overlay image with lines
877
+ overlay_image = create_overlay_image(tmp_path, result['zones'], detected_lines, dimension_lines)
878
+
879
+ # Convert overlay to base64
880
+ overlay_base64 = None
881
+ if overlay_image is not None:
882
+ import base64
883
+ _, buffer = cv2.imencode('.jpg', overlay_image)
884
+ overlay_base64 = base64.b64encode(buffer).decode('utf-8')
885
+
886
+ return {
887
+ "zones": result['zones'],
888
+ "lines": detected_lines,
889
+ "dimension_lines": dimension_lines,
890
+ "overlay_image": overlay_base64,
891
+ "message": f"Found {len(result['zones'])} text zones, {len(detected_lines)} lines, {len(dimension_lines)} dimension-line pairs"
892
+ }
893
+
894
+ finally:
895
+ # Clean up temporary file
896
+ if os.path.exists(tmp_path):
897
+ os.unlink(tmp_path)
898
+
899
+ except Exception as e:
900
+ logger.error(f"Error processing image with lines: {e}")
901
+ raise HTTPException(status_code=500, detail=str(e))
902
+
903
+ @app.post("/ocr/get-text-properties")
904
+ async def get_text_properties(request: dict = Body(...)):
905
+ """Get original text properties for precise box fitting"""
906
+ try:
907
+ # Extract data from request
908
+ image_data = request.get('image', '')
909
+ bbox = request.get('bbox', {})
910
 
911
+ if not image_data or not bbox:
912
+ raise HTTPException(status_code=400, detail="Image data and bbox required")
913
+
914
+ # Decode base64 image
915
+ import base64
916
+ if image_data.startswith('data:image'):
917
+ image_data = image_data.split(',')[1]
918
+
919
+ image_bytes = base64.b64decode(image_data)
920
+
921
+ # Save temporary image
922
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file:
923
+ tmp_file.write(image_bytes)
924
+ tmp_path = tmp_file.name
925
+
926
+ try:
927
+ # Process image with OCR
928
+ result = process_image(tmp_path, mode="fast")
929
+
930
+ if not result or not result.get('zones'):
931
+ return {"error": "No text detected"}
932
+
933
+ # Find the zone that matches the bbox
934
+ target_zone = None
935
+ for zone in result['zones']:
936
+ zone_bbox = zone.get('bbox', {})
937
+ if (isinstance(zone_bbox, dict) and
938
+ abs(zone_bbox.get('x1', 0) - bbox.get('x1', 0)) < 10 and
939
+ abs(zone_bbox.get('y1', 0) - bbox.get('y1', 0)) < 10):
940
+ target_zone = zone
941
+ break
942
+
943
+ if not target_zone:
944
+ return {"error": "Matching zone not found"}
945
+
946
+ # Extract text properties
947
+ text_properties = {
948
+ "text": target_zone.get('text', ''),
949
+ "confidence": target_zone.get('confidence', 0),
950
+ "bbox": target_zone.get('bbox', {}),
951
+ "orientation": target_zone.get('orientation', 0),
952
+ "rotation": target_zone.get('rotation', 0),
953
+ "width": target_zone.get('width', 0),
954
+ "height": target_zone.get('height', 0)
955
+ }
956
+
957
+ return text_properties
958
 
959
+ finally:
960
+ # Clean up temporary file
961
+ if os.path.exists(tmp_path):
962
+ os.unlink(tmp_path)
963
 
964
+ except Exception as e:
965
+ logger.error(f"Error getting text properties: {e}")
966
+ raise HTTPException(status_code=500, detail=str(e))
967
+
968
+ @app.post("/ocr/process-baseline")
969
+ async def process_baseline(request: dict = Body(...)):
970
+ """Process baseline to find text and fit precise box"""
971
+ try:
972
+ # Extract data from request
973
+ image_data = request.get('image', '')
974
+ baseline = request.get('baseline', {})
975
+ x1 = baseline.get('x1', 0)
976
+ y1 = baseline.get('y1', 0)
977
+ x2 = baseline.get('x2', 0)
978
+ y2 = baseline.get('y2', 0)
979
+
980
+ if not image_data:
981
+ raise HTTPException(status_code=400, detail="No image data provided")
982
+
983
+ # Decode base64 image
984
+ import base64
985
+ if image_data.startswith('data:image'):
986
+ image_data = image_data.split(',')[1]
987
+
988
+ image_bytes = base64.b64decode(image_data)
989
+
990
+ # Save temporary image
991
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file:
992
+ tmp_file.write(image_bytes)
993
+ tmp_path = tmp_file.name
994
+
995
+ try:
996
+ # Process full image with OCR
997
+ result = process_image(tmp_path, mode="fast")
998
+
999
+ if not result or not result.get('zones'):
1000
+ return {"zones": [], "message": "No text detected"}
1001
+
1002
+ # Find text zones that intersect with the baseline
1003
+ zones = result['zones']
1004
+ intersecting_zones = []
1005
+
1006
+ for zone in zones:
1007
+ bbox = zone.get('bbox', {})
1008
+ if not bbox:
1009
+ continue
1010
 
1011
+ # Check if zone intersects with baseline
1012
+ if intersects_with_line(bbox, x1, y1, x2, y2):
1013
+ # Calculate distance from zone center to baseline
1014
+ zone_center_x = (bbox.get('x1', 0) + bbox.get('x2', 0)) / 2
1015
+ zone_center_y = (bbox.get('y1', 0) + bbox.get('y2', 0)) / 2
1016
+ distance = distance_point_to_line(zone_center_x, zone_center_y, x1, y1, x2, y2)
1017
+
1018
+ intersecting_zones.append({
1019
+ 'zone': zone,
1020
+ 'distance': distance
1021
+ })
1022
+
1023
+ # Sort by distance to baseline (closest first)
1024
+ intersecting_zones.sort(key=lambda x: x['distance'])
1025
+
1026
+ if intersecting_zones:
1027
+ # Return the closest zone
1028
+ closest = intersecting_zones[0]
1029
+ zone = closest['zone']
1030
+ zone_bbox = zone.get('bbox', {})
1031
 
1032
+ # Ensure we return the zone with proper bbox coordinates
1033
+ if isinstance(zone_bbox, dict):
1034
+ return {
1035
+ "zone": {
1036
+ "text": zone.get('text', ''),
1037
+ "confidence": zone.get('confidence', 0),
1038
+ "bbox": zone_bbox,
1039
+ "x": zone_bbox.get('x1', 0),
1040
+ "y": zone_bbox.get('y1', 0),
1041
+ "width": zone_bbox.get('width', 0),
1042
+ "height": zone_bbox.get('height', 0),
1043
+ "tolerance_info": zone.get('tolerance_info', {})
1044
+ },
1045
+ "message": f"Found text along baseline (distance: {closest['distance']:.1f}px)",
1046
+ "baseline": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
1047
+ "found_zone": zone.get('text', ''),
1048
+ "fitted": True
1049
+ }
1050
+ else:
1051
+ return {
1052
+ "zone": zone,
1053
+ "message": f"Found text along baseline (distance: {closest['distance']:.1f}px)",
1054
+ "baseline": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
1055
+ "found_zone": zone.get('text', ''),
1056
+ "fitted": True
1057
+ }
1058
+ else:
1059
+ return {
1060
+ "zones": [],
1061
+ "message": "No text found along baseline",
1062
+ "baseline": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
1063
+ }
1064
+
1065
+ finally:
1066
+ # Clean up temporary file
1067
+ if os.path.exists(tmp_path):
1068
+ os.unlink(tmp_path)
1069
+
1070
+ except Exception as e:
1071
+ logger.error(f"Error processing baseline: {e}")
1072
+ raise HTTPException(status_code=500, detail=str(e))
1073
+
1074
+ def intersects_with_line(bbox, x1, y1, x2, y2):
1075
+ """Check if bounding box intersects with line"""
1076
+ bx1, by1 = bbox.get('x1', 0), bbox.get('y1', 0)
1077
+ bx2, by2 = bbox.get('x2', 0), bbox.get('y2', 0)
1078
+
1079
+ # Check if any corner of the box is close to the line
1080
+ corners = [(bx1, by1), (bx2, by1), (bx2, by2), (bx1, by2)]
1081
+
1082
+ for cx, cy in corners:
1083
+ distance = distance_point_to_line(cx, cy, x1, y1, x2, y2)
1084
+ if distance < 20: # Within 20 pixels
1085
+ return True
1086
+
1087
+ return False
1088
+
1089
+ def distance_point_to_line(px, py, x1, y1, x2, y2):
1090
+ """Calculate distance from point to line"""
1091
+ # Line vector
1092
+ line_dx = x2 - x1
1093
+ line_dy = y2 - y1
1094
+
1095
+ # Point vector
1096
+ point_dx = px - x1
1097
+ point_dy = py - y1
1098
+
1099
+ # Calculate distance using cross product
1100
+ if line_dx == 0 and line_dy == 0:
1101
+ # Line is a point
1102
+ return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5
1103
+
1104
+ # Distance = |cross_product| / |line_vector|
1105
+ cross_product = abs(point_dx * line_dy - point_dy * line_dx)
1106
+ line_length = (line_dx ** 2 + line_dy ** 2) ** 0.5
1107
+
1108
+ return cross_product / line_length
1109
+
1110
+ def detect_dimension_lines(image_path):
1111
+ """Detect lines in the image that could be dimension lines"""
1112
+ import cv2
1113
+ import numpy as np
1114
+
1115
+ # Read image
1116
+ img = cv2.imread(image_path)
1117
+ if img is None:
1118
+ return []
1119
+
1120
+ # Convert to grayscale
1121
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
1122
+
1123
+ # Apply edge detection
1124
+ edges = cv2.Canny(gray, 50, 150, apertureSize=3)
1125
+
1126
+ # Detect lines using HoughLinesP
1127
+ lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=50, minLineLength=30, maxLineGap=10)
1128
+
1129
+ detected_lines = []
1130
+ if lines is not None:
1131
+ for line in lines:
1132
+ x1, y1, x2, y2 = line[0]
1133
+ length = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
1134
+
1135
+ # Filter out very short lines
1136
+ if length > 20:
1137
+ # Calculate angle
1138
+ angle = math.atan2(y2 - y1, x2 - x1) * 180 / math.pi
1139
+ if angle < 0:
1140
+ angle += 180
1141
+
1142
+ detected_lines.append({
1143
+ 'x1': float(x1),
1144
+ 'y1': float(y1),
1145
+ 'x2': float(x2),
1146
+ 'y2': float(y2),
1147
+ 'length': float(length),
1148
+ 'angle': float(angle)
1149
+ })
1150
+
1151
+ return detected_lines
1152
+
1153
+ def find_lines_near_dimensions(zones, lines, max_distance=50):
1154
+ """Find lines that are close to dimension text"""
1155
+ dimension_lines = []
1156
+
1157
+ for zone in zones:
1158
+ bbox = zone.get('bbox', {})
1159
+ if not bbox:
1160
+ continue
1161
+
1162
+ # Get zone center
1163
+ zone_center_x = (bbox.get('x1', 0) + bbox.get('x2', 0)) / 2
1164
+ zone_center_y = (bbox.get('y1', 0) + bbox.get('y2', 0)) / 2
1165
+
1166
+ # Find closest lines
1167
+ nearby_lines = []
1168
+ for line in lines:
1169
+ distance = distance_point_to_line(zone_center_x, zone_center_y,
1170
+ line['x1'], line['y1'], line['x2'], line['y2'])
1171
+
1172
+ if distance < max_distance:
1173
+ nearby_lines.append({
1174
+ 'line': line,
1175
+ 'distance': distance
1176
+ })
1177
 
1178
+ # Sort by distance
1179
+ nearby_lines.sort(key=lambda x: x['distance'])
1180
+
1181
+ if nearby_lines:
1182
+ dimension_lines.append({
1183
+ 'zone': zone,
1184
+ 'closest_line': nearby_lines[0]['line'],
1185
+ 'distance': nearby_lines[0]['distance'],
1186
+ 'all_nearby_lines': [nl['line'] for nl in nearby_lines[:3]] # Top 3 closest
1187
+ })
1188
+
1189
+ return dimension_lines
1190
+
1191
+ # Telegram Bot Integration
1192
+ import requests
1193
+ import json
1194
+ from fastapi import Request
1195
+ from fastapi.responses import FileResponse
1196
+ from fastapi.staticfiles import StaticFiles
1197
+
1198
+ # Mount static files
1199
+ app.mount("/static", StaticFiles(directory="static"), name="static")
1200
 
1201
+ def send_telegram_message(message, chat_id=None):
1202
+ """Send message to Telegram"""
1203
+ bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
1204
+ if not bot_token:
1205
+ return False
1206
+
1207
+ target_chat_id = chat_id or os.getenv('TELEGRAM_CHAT_ID')
1208
+ if not target_chat_id:
1209
+ return False
1210
+
1211
+ # Try multiple Telegram API endpoints for better connectivity
1212
+ api_urls = [
1213
+ "https://api.telegram.org",
1214
+ "https://api.telegram.org:443"
1215
+ ]
1216
 
1217
+ for api_url in api_urls:
1218
+ try:
1219
+ url = f"{api_url}/bot{bot_token}/sendMessage"
1220
+ data = {
1221
+ 'chat_id': target_chat_id,
1222
+ 'text': message,
1223
+ 'parse_mode': 'Markdown'
1224
+ }
1225
+
1226
+ # Add timeout and retry logic
1227
+ session = requests.Session()
1228
+ session.timeout = 10
1229
+
1230
+ response = session.post(url, data=data, timeout=10)
1231
+ if response.status_code == 200:
1232
+ logger.info(f"Successfully sent Telegram message via {api_url}")
1233
+ return True
1234
+
1235
+ except Exception as e:
1236
+ logger.warning(f"Failed to send via {api_url}: {e}")
1237
+ continue
1238
+
1239
+ logger.error("Failed to send Telegram message via all endpoints")
1240
+ return False
1241
+
1242
+ def send_telegram_photo(image_path, caption="", chat_id=None):
1243
+ """Send photo to Telegram"""
1244
+ bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
1245
+ if not bot_token:
1246
+ return False
1247
+
1248
+ target_chat_id = chat_id or os.getenv('TELEGRAM_CHAT_ID')
1249
+ if not target_chat_id:
1250
+ return False
1251
+
1252
+ url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
1253
+
1254
+ try:
1255
+ with open(image_path, 'rb') as photo:
1256
+ files = {'photo': photo}
1257
+ data = {
1258
+ 'chat_id': target_chat_id,
1259
+ 'caption': caption
1260
+ }
1261
+ response = requests.post(url, files=files, data=data)
1262
+ return response.status_code == 200
1263
+ except Exception as e:
1264
+ logger.error(f"Failed to send Telegram photo: {e}")
1265
+ return False
1266
+
1267
+ def send_telegram_document(file_path, caption="", chat_id=None):
1268
+ """Send document to Telegram"""
1269
+ bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
1270
+ if not bot_token:
1271
+ return False
1272
+
1273
+ target_chat_id = chat_id or os.getenv('TELEGRAM_CHAT_ID')
1274
+ if not target_chat_id:
1275
+ return False
1276
+
1277
+ url = f"https://api.telegram.org/bot{bot_token}/sendDocument"
1278
+
1279
+ try:
1280
+ with open(file_path, 'rb') as document:
1281
+ files = {'document': document}
1282
+ data = {
1283
+ 'chat_id': target_chat_id,
1284
+ 'caption': caption
1285
+ }
1286
+ response = requests.post(url, files=files, data=data)
1287
+ return response.status_code == 200
1288
+ except Exception as e:
1289
+ logger.error(f"Failed to send Telegram document: {e}")
1290
+ return False
1291
+
1292
+ def send_correction_to_telegram(correction_data):
1293
+ """Send correction data to Telegram channel"""
1294
+ try:
1295
+ # Create message
1296
+ message = f"""📊 New Training Data Received
1297
 
1298
+ 🆔 Image ID: {correction_data.get('image_id', 'unknown')}
1299
+ 👤 User: {correction_data.get('user_id', 'unknown')}
1300
+ ⏰ Time: {correction_data.get('timestamp', 'unknown')}
1301
 
1302
+ 📈 Statistics:
1303
+ • Original zones: {len(correction_data.get('original_zones', []))}
1304
+ • Corrected zones: {len(correction_data.get('corrected_zones', []))}
1305
+ • Text fixed: {sum(1 for zone in correction_data.get('corrected_zones', []) if zone.get('correction_type') == 'text_fixed')}
1306
+ • Boxes moved: {sum(1 for zone in correction_data.get('corrected_zones', []) if zone.get('correction_type') == 'box_moved')}
1307
+ • New zones added: {sum(1 for zone in correction_data.get('corrected_zones', []) if zone.get('correction_type') == 'new_zone')}
1308
+ • Zones deleted: {sum(1 for zone in correction_data.get('corrected_zones', []) if zone.get('correction_type') == 'deleted')}
1309
+ • Validated (OK): {sum(1 for zone in correction_data.get('corrected_zones', []) if zone.get('correction_type') == 'validated')}
1310
 
1311
+ ✅ Data saved for model training"""
1312
+
1313
+ # Send message
1314
+ send_telegram_message(message)
1315
+
1316
+ # Create JSON file with training data
1317
+ import tempfile
1318
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
1319
+ json.dump(correction_data, f, indent=2)
1320
+ json_path = f.name
1321
+
1322
+ # Send JSON file
1323
+ send_telegram_document(json_path, "Training data JSON")
1324
+
1325
+ # Clean up
1326
+ os.unlink(json_path)
1327
+
1328
+ return True
1329
  except Exception as e:
1330
+ logger.error(f"Failed to send correction to Telegram: {e}")
1331
+ return False
1332
+
1333
+ @app.get("/telegram/status")
1334
+ async def telegram_status():
1335
+ """Check Telegram bot configuration"""
1336
+ bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
1337
+ chat_id = os.getenv('TELEGRAM_CHAT_ID')
1338
+
1339
+ return {
1340
+ "configured": bool(bot_token and chat_id),
1341
+ "bot_token_set": bool(bot_token),
1342
+ "chat_id_set": bool(chat_id),
1343
+ "connection": "ok" if (bot_token and chat_id) else "missing_config",
1344
+ "bot_username": "unknown" # Would need to fetch from Telegram API
1345
+ }
1346
 
1347
+ @app.post("/telegram/set-webhook")
1348
+ async def set_telegram_webhook(webhook_url: str = Query(...)):
1349
+ """Set Telegram webhook URL"""
1350
+ bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
1351
+ if not bot_token:
1352
+ return {"success": False, "message": "Bot token not configured"}
1353
+
1354
+ url = f"https://api.telegram.org/bot{bot_token}/setWebhook"
1355
+ data = {"url": webhook_url}
1356
+
1357
  try:
1358
+ response = requests.post(url, data=data)
1359
+ result = response.json()
1360
+ return {
1361
+ "success": result.get("ok", False),
1362
+ "message": result.get("description", "Unknown error"),
1363
+ "webhook_url": webhook_url,
1364
+ "telegram_response": result
1365
+ }
 
 
 
 
 
 
 
 
1366
  except Exception as e:
1367
+ return {"success": False, "message": f"Error setting webhook: {str(e)}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1368
 
1369
+ @app.post("/telegram/webhook")
1370
+ async def telegram_webhook(request: Request):
1371
+ """Handle incoming Telegram messages"""
1372
+ try:
1373
+ data = await request.json()
1374
+ logger.info(f"Received Telegram webhook: {data}")
1375
+
1376
+ # Extract message info
1377
+ message = data.get('message', {})
1378
+ chat_id = message.get('chat', {}).get('id')
1379
+ user_id = message.get('from', {}).get('id')
1380
+ text = message.get('text', '')
1381
+ photo = message.get('photo', [])
1382
+
1383
+ # Handle text commands
1384
+ if text:
1385
+ if text.startswith('/start'):
1386
+ # Log the command but don't send response due to DNS issues
1387
+ logger.info(f"User {user_id} sent /start command")
1388
+ # Just return success - user can access mini app directly
1389
+ pass
1390
+
1391
+ elif text.startswith('/help'):
1392
+ logger.info(f"User {user_id} requested help")
1393
+ # Just return success
1394
+ pass
1395
+
1396
+ elif text.startswith('/status'):
1397
+ logger.info(f"User {user_id} checked status")
1398
+ # Just return success
1399
+ pass
1400
+
1401
+ # Handle photo uploads
1402
+ elif photo:
1403
+ # Get the largest photo
1404
+ largest_photo = max(photo, key=lambda x: x.get('file_size', 0))
1405
+ file_id = largest_photo.get('file_id')
1406
+
1407
+ # Download photo
1408
+ photo_path = await download_telegram_photo(file_id)
1409
+ if photo_path:
1410
+ try:
1411
+ # Process with OCR
1412
+ result = process_image(photo_path, mode="fast")
1413
+
1414
+ if result and result.get('zones'):
1415
+ zones = result['zones']
1416
+ logger.info(f"Processed photo for user {user_id}: Found {len(zones)} zones")
1417
+
1418
+ # Log the results instead of sending message
1419
+ for i, zone in enumerate(zones[:5]):
1420
+ logger.info(f"Zone {i+1}: {zone.get('text', '')} ({int(zone.get('confidence', 0)*100)}%)")
1421
+
1422
+ if len(zones) > 5:
1423
+ logger.info(f"... and {len(zones)-5} more zones")
1424
+ else:
1425
+ logger.info(f"No text detected in photo from user {user_id}")
1426
+
1427
+ except Exception as ocr_error:
1428
+ logger.error(f"OCR processing error for user {user_id}: {ocr_error}")
1429
+ finally:
1430
+ # Clean up
1431
+ if os.path.exists(photo_path):
1432
+ os.unlink(photo_path)
1433
+
1434
+ return {"status": "ok"}
1435
+
1436
+ except Exception as e:
1437
+ logger.error(f"Error handling Telegram webhook: {e}")
1438
+ return {"status": "error", "message": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1439
 
1440
+ def send_telegram_message_with_keyboard(message, chat_id, keyboard):
1441
+ """Send message with inline keyboard"""
1442
+ bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
1443
+ if not bot_token:
1444
+ return False
1445
+
1446
+ # Try multiple Telegram API endpoints for better connectivity
1447
+ api_urls = [
1448
+ "https://api.telegram.org",
1449
+ "https://api.telegram.org:443"
1450
+ ]
1451
+
1452
+ for api_url in api_urls:
1453
+ try:
1454
+ url = f"{api_url}/bot{bot_token}/sendMessage"
1455
+ data = {
1456
+ 'chat_id': chat_id,
1457
+ 'text': message,
1458
+ 'parse_mode': 'Markdown',
1459
+ 'reply_markup': json.dumps(keyboard)
1460
+ }
1461
+
1462
+ # Add timeout and retry logic
1463
+ session = requests.Session()
1464
+ session.timeout = 10
1465
+
1466
+ response = session.post(url, data=data, timeout=10)
1467
+ if response.status_code == 200:
1468
+ logger.info(f"Successfully sent Telegram message with keyboard via {api_url}")
1469
+ return True
1470
+
1471
+ except Exception as e:
1472
+ logger.warning(f"Failed to send keyboard message via {api_url}: {e}")
1473
+ continue
1474
+
1475
+ logger.error("Failed to send Telegram message with keyboard via all endpoints")
1476
+ return False
1477
 
1478
+ async def download_telegram_photo(file_id):
1479
+ """Download photo from Telegram"""
1480
+ bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
1481
+ if not bot_token:
1482
+ return None
1483
+
1484
+ try:
1485
+ # Get file info
1486
+ url = f"https://api.telegram.org/bot{bot_token}/getFile"
1487
+ response = requests.get(url, params={'file_id': file_id})
1488
+ file_info = response.json()
1489
+
1490
+ if not file_info.get('ok'):
1491
+ return None
1492
+
1493
+ file_path = file_info['result']['file_path']
1494
+
1495
+ # Download file
1496
+ download_url = f"https://api.telegram.org/file/bot{bot_token}/{file_path}"
1497
+ response = requests.get(download_url)
1498
+
1499
+ # Save to temporary file
1500
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file:
1501
+ tmp_file.write(response.content)
1502
+ return tmp_file.name
1503
+
1504
+ except Exception as e:
1505
+ logger.error(f"Failed to download Telegram photo: {e}")
1506
+ return None
1507
+
1508
+ @app.get("/camera")
1509
+ async def camera_mini_app():
1510
+ """Serve the camera mini app"""
1511
+ return FileResponse("static/camera.html")
1512
+
1513
+ @app.post("/corrections/submit")
1514
+ async def submit_corrections(request: Request):
1515
+ """Submit corrections for training data"""
1516
+ try:
1517
+ data = await request.json()
1518
+
1519
+ # Send to Telegram channel
1520
+ success = send_correction_to_telegram(data)
1521
+
1522
+ return {
1523
+ "status": "success" if success else "error",
1524
+ "message": "Corrections submitted successfully" if success else "Failed to submit corrections",
1525
+ "total_zones": len(data.get('corrected_zones', []))
1526
+ }
1527
+
1528
+ except Exception as e:
1529
+ logger.error(f"Error submitting corrections: {e}")
1530
+ return {"status": "error", "message": str(e)}
1531
+
1532
+ if __name__ == "__main__":
1533
+ import uvicorn
1534
+ uvicorn.run(app, host="0.0.0.0", port=8000)