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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +359 -1492
app.py CHANGED
@@ -1,1534 +1,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)
 
 
 
 
 
 
 
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
+