cooldan commited on
Commit
6fa0644
·
verified ·
1 Parent(s): 89e575e

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +345 -81
  2. pdf_service.py +339 -0
  3. requirements.txt +30 -25
app.py CHANGED
@@ -5,7 +5,7 @@ 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
  from fastapi.middleware.trustedhost import TrustedHostMiddleware
11
  import uvicorn
@@ -23,6 +23,14 @@ import time
23
  import asyncio
24
  import concurrent.futures
25
  from functools import lru_cache
 
 
 
 
 
 
 
 
26
 
27
  # Set up logging first
28
  logging.basicConfig(level=logging.INFO)
@@ -107,6 +115,70 @@ ocr = None
107
  # Thread pool for CPU-intensive tasks
108
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=4)
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  # Advanced processing configuration
111
  merge_overlapping_zones = False # Disable merging to prevent over-merging dimensions
112
  remove_duplicate_zones = True
@@ -192,12 +264,28 @@ app.add_middleware(
192
 
193
  # File validation constants
194
  MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
 
195
  ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'}
196
  ALLOWED_MIME_TYPES = {
197
  'image/jpeg', 'image/jpg', 'image/png', 'image/bmp',
198
  'image/tiff', 'image/webp'
199
  }
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  def validate_uploaded_file(file: UploadFile) -> bool:
202
  """Validate uploaded file for security and size"""
203
  if not file:
@@ -216,7 +304,13 @@ def validate_uploaded_file(file: UploadFile) -> bool:
216
 
217
  # Check file extension (more lenient)
218
  if file.filename:
219
- file_ext = os.path.splitext(file.filename.lower())[1]
 
 
 
 
 
 
220
  logger.info(f"📁 File extension: '{file_ext}'")
221
  if file_ext not in ALLOWED_EXTENSIONS:
222
  logger.warning(f"❌ Invalid file extension: '{file_ext}' not in {ALLOWED_EXTENSIONS}")
@@ -237,6 +331,26 @@ def validate_uploaded_file(file: UploadFile) -> bool:
237
  logger.info("✅ File validation passed")
238
  return True
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  # Rate limiting
241
  RATE_LIMIT_REQUESTS = 100 # requests per minute
242
  RATE_LIMIT_WINDOW = 60 # seconds
@@ -444,26 +558,11 @@ def preprocess_image_for_ocr(image_path):
444
  return image_path
445
 
446
  def detect_text_orientation_advanced(polygon, text):
447
- """Advanced text orientation detection"""
448
  try:
449
  if not polygon or len(polygon) < 4:
450
  return 0
451
 
452
- # Calculate angles of polygon edges
453
- edge1 = [polygon[1][0] - polygon[0][0], polygon[1][1] - polygon[0][1]]
454
- edge2 = [polygon[2][0] - polygon[1][0], polygon[2][1] - polygon[1][1]]
455
-
456
- angle1 = math.degrees(math.atan2(edge1[1], edge1[0]))
457
- angle2 = math.degrees(math.atan2(edge2[1], edge2[0]))
458
-
459
- # Normalize to 0-360
460
- angle1 = (angle1 + 360) % 360
461
- angle2 = (angle2 + 360) % 360
462
-
463
- # Calculate edge lengths and bounding box dimensions
464
- edge1_length = math.sqrt(edge1[0]**2 + edge1[1]**2)
465
- edge2_length = math.sqrt(edge2[0]**2 + edge2[1]**2)
466
-
467
  # Calculate bounding box dimensions
468
  x_coords = [point[0] for point in polygon]
469
  y_coords = [point[1] for point in polygon]
@@ -471,53 +570,52 @@ def detect_text_orientation_advanced(polygon, text):
471
  bbox_height = max(y_coords) - min(y_coords)
472
  aspect_ratio = bbox_height / bbox_width if bbox_width > 0 else 1
473
 
474
- print(f"🔍 Orientation debug: angle1={angle1:.1f}°, angle2={angle2:.1f}°, edge1_len={edge1_length:.1f}, edge2_len={edge2_length:.1f}, bbox_w={bbox_width:.1f}, bbox_h={bbox_height:.1f}, aspect_ratio={aspect_ratio:.2f}")
 
 
475
 
476
- # For vertical text, aspect ratio should be > 1.1 (height > width)
477
- # For horizontal text, aspect ratio should be < 0.9 (width > height)
478
- if aspect_ratio > 1.1:
479
- # Vertical text - use the edge that's closer to vertical (90° or 270°)
480
- if abs(angle1 - 90) < abs(angle2 - 90) or abs(angle1 - 270) < abs(angle2 - 270):
481
- text_angle = angle1
482
- else:
483
- text_angle = angle2
484
- elif aspect_ratio < 0.9:
485
- # Horizontal text - use the edge that's closer to horizontal (0° or 180°)
486
- if abs(angle1 - 0) < abs(angle2 - 0) or abs(angle1 - 180) < abs(angle2 - 180):
487
- text_angle = angle1
488
- else:
489
- text_angle = angle2
490
  else:
491
- # Ambiguous case - use the edge that's closer to horizontal (0° or 180°)
492
- # This handles cases where aspect ratio is close to 1.0
493
- if abs(angle1 - 0) < abs(angle2 - 0) or abs(angle1 - 180) < abs(angle2 - 180):
494
- text_angle = angle1
495
- else:
496
- text_angle = angle2
497
 
498
- # Normalize to common orientations
499
- if (0 <= text_angle <= 10) or (350 <= text_angle <= 360):
500
- return 0
501
- elif (80 <= text_angle <= 100):
502
- return 90
503
- elif (170 <= text_angle <= 190):
504
- return 180
505
- elif (260 <= text_angle <= 280):
506
- return 270
 
 
 
 
 
 
 
 
 
 
507
  else:
508
- # For other angles, return the closest standard orientation
509
- if text_angle < 45:
510
  return 0
511
- elif text_angle < 135:
512
  return 90
513
- elif text_angle < 225:
514
  return 180
515
- elif text_angle < 315:
516
  return 270
517
- else:
518
- return 0
519
-
520
- return 0
521
 
522
  except Exception as e:
523
  logger.warning(f"Orientation detection failed: {e}")
@@ -547,14 +645,19 @@ def is_dimension_text_advanced(text):
547
  return False
548
 
549
  def clean_ocr_text_advanced(text):
550
- """Advanced OCR text cleaning"""
551
  if not text:
552
  return ""
553
 
554
  # Remove common OCR artifacts
555
  cleaned = text.strip()
556
 
557
- # Fix common OCR mistakes
 
 
 
 
 
558
  replacements = {
559
  'O': '0', # Letter O to number 0 in numeric contexts
560
  'o': '0', # Lowercase o to number 0
@@ -590,10 +693,13 @@ def clean_ocr_text_advanced(text):
590
  r'^6$': r'5', # Fix specific OCR error "6" -> "5"
591
  r'^\.\,$': r'', # Remove meaningless OCR artifacts
592
  r'^\.\,,\s*$': r'', # Remove meaningless OCR artifacts
 
593
  }
594
 
595
  for pattern, replacement in tolerance_fixes.items():
596
- cleaned = re.sub(pattern, replacement, cleaned)
 
 
597
 
598
  # Remove extra spaces and normalize
599
  cleaned = re.sub(r'\s+', ' ', cleaned).strip()
@@ -1016,6 +1122,83 @@ def detect_and_process_vertical_text(img):
1016
  logger.error(f"Error in vertical text detection: {e}")
1017
  return []
1018
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1019
  def merge_nearby_text_zones(zones):
1020
  """Merge nearby text zones that might be parts of the same number"""
1021
  if len(zones) <= 1:
@@ -1299,7 +1482,7 @@ def process_ocr_format(result):
1299
  return zones
1300
 
1301
  def create_zone_from_predict(text, score, dt_polys, index):
1302
- """Create zone from predict format data"""
1303
  if not text.strip() or index >= len(dt_polys):
1304
  return None
1305
 
@@ -1326,11 +1509,30 @@ def create_zone_from_predict(text, score, dt_polys, index):
1326
  # Detect orientation
1327
  text_orientation = detect_text_orientation_advanced(poly, text)
1328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1329
  # Create zone
1330
  zone = {
1331
  "id": f"ocr_zone_{index}",
1332
- "text": text,
1333
- "confidence": float(score),
1334
  "bbox": {
1335
  "x1": int(x1),
1336
  "y1": int(y1),
@@ -1342,11 +1544,11 @@ def create_zone_from_predict(text, score, dt_polys, index):
1342
  "polygon": poly,
1343
  "text_orientation": text_orientation,
1344
  "rotation": text_orientation, # Also send as 'rotation' for frontend compatibility
1345
- "is_dimension": is_dimension_text_advanced(text),
1346
- "tolerance_info": parse_tolerance(text)
1347
  }
1348
 
1349
- logger.info(f"✅ Zone {index} CREATED: text='{text}', orient={text_orientation}°, bbox=({x1},{y1},{x2},{y2})")
1350
  return zone
1351
 
1352
  def create_zone_from_ocr(text, confidence, bbox, index):
@@ -1503,6 +1705,9 @@ def process_image(image_path, mode="fast", rotation=0):
1503
  import traceback
1504
  logger.error(f"🔍 Traceback: {traceback.format_exc()}")
1505
 
 
 
 
1506
  # Merge nearby text zones (for cases like "11.5" split into "1", "1", ".", "5")
1507
  zones = merge_nearby_text_zones(zones)
1508
 
@@ -1625,35 +1830,33 @@ async def process_ocr(
1625
  if not validate_uploaded_file(file):
1626
  raise HTTPException(status_code=400, detail="Invalid file: must be a valid image file under 10MB")
1627
 
1628
- # Save uploaded file to temporary location
1629
- with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
 
1630
  content = await file.read()
1631
- temp_file.write(content)
1632
- temp_path = temp_file.name
 
 
 
1633
 
1634
  try:
1635
  logger.info(f"🚀 API CALL: Processing image: {file.filename}, mode: {mode}, rotation: {rotation}")
1636
  logger.info(f"📁 Temp file saved: {temp_path}")
1637
 
 
 
 
 
1638
  # Process image with specified mode and rotation
1639
  result = await process_image_async(temp_path, mode, rotation)
1640
  logger.info(f"✅ API RESPONSE: Returning {len(result.get('zones', []))} zones")
1641
 
1642
  logger.info(f"OCR result: {len(result.get('zones', []))} zones found")
1643
 
1644
- # Clean up temporary file
1645
- os.unlink(temp_path)
1646
-
1647
  return JSONResponse(content=result)
1648
 
1649
  except Exception as e:
1650
- # Clean up temporary file on error
1651
- if 'temp_path' in locals() and os.path.exists(temp_path):
1652
- try:
1653
- os.unlink(temp_path)
1654
- except OSError:
1655
- pass
1656
-
1657
  logger.error(f"Error processing uploaded file: {e}")
1658
  import traceback
1659
  logger.error(f"Traceback: {traceback.format_exc()}")
@@ -1667,6 +1870,14 @@ async def process_ocr(
1667
  "type": type(e).__name__
1668
  }
1669
  )
 
 
 
 
 
 
 
 
1670
 
1671
  @app.post("/ocr/process-path")
1672
  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")):
@@ -2639,6 +2850,57 @@ async def get_blueprint_stats():
2639
  }
2640
  }
2641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2642
  @app.post("/corrections/submit")
2643
  async def submit_corrections(request: Request):
2644
  """Submit corrections for training data"""
@@ -2660,4 +2922,6 @@ async def submit_corrections(request: Request):
2660
 
2661
  if __name__ == "__main__":
2662
  import uvicorn
2663
- uvicorn.run(app, host="0.0.0.0", port=8000)
 
 
 
5
  """
6
 
7
  from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Body
8
+ from fastapi.responses import JSONResponse, Response
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.middleware.trustedhost import TrustedHostMiddleware
11
  import uvicorn
 
23
  import asyncio
24
  import concurrent.futures
25
  from functools import lru_cache
26
+ import atexit
27
+ import gc
28
+ try:
29
+ import psutil
30
+ PSUTIL_AVAILABLE = True
31
+ except ImportError:
32
+ PSUTIL_AVAILABLE = False
33
+ logger.warning("psutil not available - memory monitoring disabled")
34
 
35
  # Set up logging first
36
  logging.basicConfig(level=logging.INFO)
 
115
  # Thread pool for CPU-intensive tasks
116
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=4)
117
 
118
+ # Cleanup function for temporary files and resources
119
+ def cleanup_resources():
120
+ """Clean up temporary files and resources"""
121
+ try:
122
+ # Force garbage collection
123
+ gc.collect()
124
+
125
+ # Log memory usage (if psutil is available)
126
+ if PSUTIL_AVAILABLE:
127
+ try:
128
+ memory_info = psutil.virtual_memory()
129
+ logger.info(f"Memory usage: {memory_info.percent}% ({memory_info.used / 1024 / 1024 / 1024:.2f}GB / {memory_info.total / 1024 / 1024 / 1024:.2f}GB)")
130
+ except Exception as e:
131
+ logger.warning(f"Failed to get memory info: {e}")
132
+ else:
133
+ logger.info("Memory monitoring not available (psutil not installed)")
134
+
135
+ # Clean up temp directory
136
+ temp_files = []
137
+ for root, dirs, files in os.walk(temp_dir):
138
+ for file in files:
139
+ if file.startswith('temp_') or file.endswith('_temp.jpg') or file.endswith('_cropped.jpg'):
140
+ temp_files.append(os.path.join(root, file))
141
+
142
+ for temp_file in temp_files:
143
+ try:
144
+ if os.path.exists(temp_file):
145
+ os.unlink(temp_file)
146
+ logger.info(f"Cleaned up temp file: {temp_file}")
147
+ except Exception as e:
148
+ logger.warning(f"Failed to clean up temp file {temp_file}: {e}")
149
+
150
+ except Exception as e:
151
+ logger.error(f"Error during cleanup: {e}")
152
+
153
+ # Register cleanup function
154
+ atexit.register(cleanup_resources)
155
+
156
+ # Periodic cleanup function (run every 30 minutes)
157
+ def periodic_cleanup():
158
+ """Run periodic cleanup to manage resources"""
159
+ while True:
160
+ try:
161
+ time.sleep(1800) # 30 minutes
162
+ cleanup_resources()
163
+ except Exception as e:
164
+ logger.error(f"Error in periodic cleanup: {e}")
165
+
166
+ # Start periodic cleanup in background thread
167
+ import threading
168
+ cleanup_thread = threading.Thread(target=periodic_cleanup, daemon=True)
169
+ cleanup_thread.start()
170
+
171
+ # Import PDF service (optional)
172
+ try:
173
+ from pdf_service import pdf_service
174
+ PDF_AVAILABLE = True
175
+ logger.info("✅ PDF service loaded successfully")
176
+ except ImportError as e:
177
+ PDF_AVAILABLE = False
178
+ pdf_service = None
179
+ logger.warning(f"⚠️ PDF service not available: {e}")
180
+ logger.info("PDF export feature will be disabled")
181
+
182
  # Advanced processing configuration
183
  merge_overlapping_zones = False # Disable merging to prevent over-merging dimensions
184
  remove_duplicate_zones = True
 
264
 
265
  # File validation constants
266
  MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
267
+ MAX_IMAGE_DIMENSIONS = 4096 # Max width/height to prevent memory issues
268
  ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'}
269
  ALLOWED_MIME_TYPES = {
270
  'image/jpeg', 'image/jpg', 'image/png', 'image/bmp',
271
  'image/tiff', 'image/webp'
272
  }
273
 
274
+ # Security: Generate secure temp file names
275
+ def create_secure_temp_file(suffix='.jpg'):
276
+ """Create a secure temporary file with random name"""
277
+ import secrets
278
+ import string
279
+
280
+ # Generate random filename using secrets.choice (correct method)
281
+ random_name = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
282
+ temp_path = os.path.join(temp_dir, f"secure_{random_name}_{int(time.time())}{suffix}")
283
+
284
+ # Ensure directory exists
285
+ os.makedirs(os.path.dirname(temp_path), exist_ok=True)
286
+
287
+ return temp_path
288
+
289
  def validate_uploaded_file(file: UploadFile) -> bool:
290
  """Validate uploaded file for security and size"""
291
  if not file:
 
304
 
305
  # Check file extension (more lenient)
306
  if file.filename:
307
+ # Sanitize filename to prevent path traversal
308
+ safe_filename = os.path.basename(file.filename)
309
+ if safe_filename != file.filename:
310
+ logger.warning(f"❌ Unsafe filename detected: '{file.filename}' -> '{safe_filename}'")
311
+ return False
312
+
313
+ file_ext = os.path.splitext(safe_filename.lower())[1]
314
  logger.info(f"📁 File extension: '{file_ext}'")
315
  if file_ext not in ALLOWED_EXTENSIONS:
316
  logger.warning(f"❌ Invalid file extension: '{file_ext}' not in {ALLOWED_EXTENSIONS}")
 
331
  logger.info("✅ File validation passed")
332
  return True
333
 
334
+ def validate_image_dimensions(image_path: str) -> bool:
335
+ """Validate image dimensions to prevent memory issues"""
336
+ try:
337
+ import cv2
338
+ img = cv2.imread(image_path)
339
+ if img is None:
340
+ return False
341
+
342
+ height, width = img.shape[:2]
343
+ logger.info(f"📐 Image dimensions: {width}x{height}")
344
+
345
+ if width > MAX_IMAGE_DIMENSIONS or height > MAX_IMAGE_DIMENSIONS:
346
+ logger.warning(f"❌ Image too large: {width}x{height} > {MAX_IMAGE_DIMENSIONS}x{MAX_IMAGE_DIMENSIONS}")
347
+ return False
348
+
349
+ return True
350
+ except Exception as e:
351
+ logger.error(f"Error validating image dimensions: {e}")
352
+ return False
353
+
354
  # Rate limiting
355
  RATE_LIMIT_REQUESTS = 100 # requests per minute
356
  RATE_LIMIT_WINDOW = 60 # seconds
 
558
  return image_path
559
 
560
  def detect_text_orientation_advanced(polygon, text):
561
+ """Improved text orientation detection with better vertical text handling"""
562
  try:
563
  if not polygon or len(polygon) < 4:
564
  return 0
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  # Calculate bounding box dimensions
567
  x_coords = [point[0] for point in polygon]
568
  y_coords = [point[1] for point in polygon]
 
570
  bbox_height = max(y_coords) - min(y_coords)
571
  aspect_ratio = bbox_height / bbox_width if bbox_width > 0 else 1
572
 
573
+ # Calculate the main orientation based on the longest edge
574
+ edge1 = [polygon[1][0] - polygon[0][0], polygon[1][1] - polygon[0][1]]
575
+ edge2 = [polygon[2][0] - polygon[1][0], polygon[2][1] - polygon[1][1]]
576
 
577
+ edge1_length = math.sqrt(edge1[0]**2 + edge1[1]**2)
578
+ edge2_length = math.sqrt(edge2[0]**2 + edge2[1]**2)
579
+
580
+ # Use the longer edge for orientation calculation
581
+ if edge1_length > edge2_length:
582
+ main_edge = edge1
 
 
 
 
 
 
 
 
583
  else:
584
+ main_edge = edge2
585
+
586
+ # Calculate angle of the main edge
587
+ angle = math.degrees(math.atan2(main_edge[1], main_edge[0]))
588
+ angle = (angle + 360) % 360 # Normalize to 0-360
 
589
 
590
+ logger.info(f"🔍 Orientation: bbox={bbox_width:.1f}x{bbox_height:.1f}, aspect={aspect_ratio:.2f}, angle={angle:.1f}°")
591
+
592
+ # Simplified orientation detection
593
+ if aspect_ratio > 1.3: # Clearly vertical (height > width * 1.3)
594
+ # For vertical text, determine if it's 90° or 270°
595
+ if 45 <= angle <= 135: # Text reads from bottom to top
596
+ return 90
597
+ elif 225 <= angle <= 315: # Text reads from top to bottom
598
+ return 270
599
+ else:
600
+ return 90 # Default for vertical
601
+ elif aspect_ratio < 0.7: # Clearly horizontal (width > height * 1.4)
602
+ # For horizontal text, determine if it's 0° or 180°
603
+ if 315 <= angle or angle <= 45: # Text reads left to right
604
+ return 0
605
+ elif 135 <= angle <= 225: # Text reads right to left
606
+ return 180
607
+ else:
608
+ return 0 # Default for horizontal
609
  else:
610
+ # Ambiguous case - use angle-based detection
611
+ if 315 <= angle or angle <= 45:
612
  return 0
613
+ elif 45 < angle <= 135:
614
  return 90
615
+ elif 135 < angle <= 225:
616
  return 180
617
+ else: # 225 < angle < 315
618
  return 270
 
 
 
 
619
 
620
  except Exception as e:
621
  logger.warning(f"Orientation detection failed: {e}")
 
645
  return False
646
 
647
  def clean_ocr_text_advanced(text):
648
+ """Advanced OCR text cleaning with better dimension handling"""
649
  if not text:
650
  return ""
651
 
652
  # Remove common OCR artifacts
653
  cleaned = text.strip()
654
 
655
+ # Remove trailing periods that aren't part of decimal numbers
656
+ if cleaned.endswith('.') and not re.search(r'\d+\.\d*$', cleaned):
657
+ cleaned = cleaned[:-1]
658
+ logger.info(f"🧹 Removed trailing period: '{text}' -> '{cleaned}'")
659
+
660
+ # Fix common OCR mistakes in dimension text
661
  replacements = {
662
  'O': '0', # Letter O to number 0 in numeric contexts
663
  'o': '0', # Lowercase o to number 0
 
693
  r'^6$': r'5', # Fix specific OCR error "6" -> "5"
694
  r'^\.\,$': r'', # Remove meaningless OCR artifacts
695
  r'^\.\,,\s*$': r'', # Remove meaningless OCR artifacts
696
+ r'^15$': r'9.5', # Fix common OCR error "15" -> "9.5" for vertical text
697
  }
698
 
699
  for pattern, replacement in tolerance_fixes.items():
700
+ if re.match(pattern, cleaned):
701
+ cleaned = re.sub(pattern, replacement, cleaned)
702
+ logger.info(f"🧹 Applied tolerance fix: '{text}' -> '{cleaned}'")
703
 
704
  # Remove extra spaces and normalize
705
  cleaned = re.sub(r'\s+', ' ', cleaned).strip()
 
1122
  logger.error(f"Error in vertical text detection: {e}")
1123
  return []
1124
 
1125
+ def smart_text_completion(zones):
1126
+ """Smart text completion for common patterns like .5 -> 11.5"""
1127
+ logger.info("🧠 Running smart text completion...")
1128
+
1129
+ # Common patterns to look for
1130
+ patterns = {
1131
+ '.5': ['11.5', '9.5', '7.5', '5.5', '3.5', '1.5'],
1132
+ '.0': ['10.0', '20.0', '30.0', '40.0', '50.0'],
1133
+ '.25': ['11.25', '9.25', '7.25'],
1134
+ '.75': ['11.75', '9.75', '7.75'],
1135
+ '5': ['15', '25', '35', '45', '55'],
1136
+ '0': ['10', '20', '30', '40', '50']
1137
+ }
1138
+
1139
+ completed_zones = []
1140
+
1141
+ for zone in zones:
1142
+ text = zone.get('text', '').strip()
1143
+ completed_zone = zone.copy()
1144
+
1145
+ # Check if text matches a pattern
1146
+ for pattern, completions in patterns.items():
1147
+ if text == pattern:
1148
+ logger.info(f"🧠 Found pattern '{pattern}', trying completions: {completions}")
1149
+
1150
+ # Try to find nearby zones that could complete this pattern
1151
+ bbox = zone.get('bbox', {})
1152
+ if bbox:
1153
+ zone_center_x = (bbox.get('x1', 0) + bbox.get('x2', 0)) / 2
1154
+ zone_center_y = (bbox.get('y1', 0) + bbox.get('y2', 0)) / 2
1155
+
1156
+ # Look for nearby zones that could be the missing part
1157
+ for other_zone in zones:
1158
+ if other_zone == zone:
1159
+ continue
1160
+
1161
+ other_bbox = other_zone.get('bbox', {})
1162
+ if not other_bbox:
1163
+ continue
1164
+
1165
+ other_center_x = (other_bbox.get('x1', 0) + other_bbox.get('x2', 0)) / 2
1166
+ other_center_y = (other_bbox.get('y1', 0) + other_bbox.get('y2', 0)) / 2
1167
+
1168
+ distance = ((other_center_x - zone_center_x) ** 2 + (other_center_y - zone_center_y) ** 2) ** 0.5
1169
+
1170
+ # If zones are close (within 100px), try to combine them
1171
+ if distance < 100:
1172
+ other_text = other_zone.get('text', '').strip()
1173
+
1174
+ # Try different combinations
1175
+ for completion in completions:
1176
+ if completion.startswith(other_text) and completion.endswith(text):
1177
+ logger.info(f"🧠 Smart completion: '{other_text}' + '{text}' = '{completion}'")
1178
+
1179
+ # Update the zone with completed text
1180
+ completed_zone['text'] = completion
1181
+ completed_zone['confidence'] = min(zone.get('confidence', 0), other_zone.get('confidence', 0))
1182
+
1183
+ # Merge bounding boxes
1184
+ merged_bbox = {
1185
+ 'x1': min(bbox.get('x1', 0), other_bbox.get('x1', 0)),
1186
+ 'y1': min(bbox.get('y1', 0), other_bbox.get('y1', 0)),
1187
+ 'x2': max(bbox.get('x2', 0), other_bbox.get('x2', 0)),
1188
+ 'y2': max(bbox.get('y2', 0), other_bbox.get('y2', 0))
1189
+ }
1190
+ merged_bbox['width'] = merged_bbox['x2'] - merged_bbox['x1']
1191
+ merged_bbox['height'] = merged_bbox['y2'] - merged_bbox['y1']
1192
+ completed_zone['bbox'] = merged_bbox
1193
+
1194
+ break
1195
+ break
1196
+
1197
+ completed_zones.append(completed_zone)
1198
+
1199
+ logger.info(f"🧠 Smart completion processed {len(zones)} zones")
1200
+ return completed_zones
1201
+
1202
  def merge_nearby_text_zones(zones):
1203
  """Merge nearby text zones that might be parts of the same number"""
1204
  if len(zones) <= 1:
 
1482
  return zones
1483
 
1484
  def create_zone_from_predict(text, score, dt_polys, index):
1485
+ """Create zone from predict format data with improved confidence handling"""
1486
  if not text.strip() or index >= len(dt_polys):
1487
  return None
1488
 
 
1509
  # Detect orientation
1510
  text_orientation = detect_text_orientation_advanced(poly, text)
1511
 
1512
+ # Calculate bounding box dimensions for confidence adjustment
1513
+ bbox_width = x2 - x1
1514
+ bbox_height = y2 - y1
1515
+ aspect_ratio = bbox_height / bbox_width if bbox_width > 0 else 1
1516
+
1517
+ # Adjust confidence for vertical text (they often have lower raw scores)
1518
+ adjusted_score = float(score)
1519
+ if text_orientation in [90, 270] and aspect_ratio > 1.2:
1520
+ # Boost confidence for vertical text that looks like dimensions
1521
+ if is_dimension_text_advanced(text):
1522
+ adjusted_score = min(0.95, adjusted_score + 0.2)
1523
+ logger.info(f"🔍 Boosted confidence for vertical dimension '{text}': {score:.3f} -> {adjusted_score:.3f}")
1524
+
1525
+ # Clean text - remove trailing periods that aren't part of decimal numbers
1526
+ clean_text = text.strip()
1527
+ if clean_text.endswith('.') and not re.search(r'\d+\.\d*$', clean_text):
1528
+ clean_text = clean_text[:-1]
1529
+ logger.info(f"🧹 Cleaned trailing period: '{text}' -> '{clean_text}'")
1530
+
1531
  # Create zone
1532
  zone = {
1533
  "id": f"ocr_zone_{index}",
1534
+ "text": clean_text,
1535
+ "confidence": adjusted_score,
1536
  "bbox": {
1537
  "x1": int(x1),
1538
  "y1": int(y1),
 
1544
  "polygon": poly,
1545
  "text_orientation": text_orientation,
1546
  "rotation": text_orientation, # Also send as 'rotation' for frontend compatibility
1547
+ "is_dimension": is_dimension_text_advanced(clean_text),
1548
+ "tolerance_info": parse_tolerance(clean_text)
1549
  }
1550
 
1551
+ logger.info(f"✅ Zone {index} CREATED: text='{clean_text}', orient={text_orientation}°, conf={adjusted_score:.3f}, bbox=({x1},{y1},{x2},{y2})")
1552
  return zone
1553
 
1554
  def create_zone_from_ocr(text, confidence, bbox, index):
 
1705
  import traceback
1706
  logger.error(f"🔍 Traceback: {traceback.format_exc()}")
1707
 
1708
+ # Smart text completion for common patterns like .5 -> 11.5
1709
+ zones = smart_text_completion(zones)
1710
+
1711
  # Merge nearby text zones (for cases like "11.5" split into "1", "1", ".", "5")
1712
  zones = merge_nearby_text_zones(zones)
1713
 
 
1830
  if not validate_uploaded_file(file):
1831
  raise HTTPException(status_code=400, detail="Invalid file: must be a valid image file under 10MB")
1832
 
1833
+ # Save uploaded file to secure temporary location
1834
+ temp_path = create_secure_temp_file('.jpg')
1835
+ try:
1836
  content = await file.read()
1837
+ with open(temp_path, 'wb') as temp_file:
1838
+ temp_file.write(content)
1839
+ except Exception as e:
1840
+ logger.error(f"Failed to save uploaded file: {e}")
1841
+ raise HTTPException(status_code=500, detail="Failed to process uploaded file")
1842
 
1843
  try:
1844
  logger.info(f"🚀 API CALL: Processing image: {file.filename}, mode: {mode}, rotation: {rotation}")
1845
  logger.info(f"📁 Temp file saved: {temp_path}")
1846
 
1847
+ # Validate image dimensions
1848
+ if not validate_image_dimensions(temp_path):
1849
+ raise HTTPException(status_code=400, detail="Image dimensions too large. Maximum allowed: 4096x4096 pixels")
1850
+
1851
  # Process image with specified mode and rotation
1852
  result = await process_image_async(temp_path, mode, rotation)
1853
  logger.info(f"✅ API RESPONSE: Returning {len(result.get('zones', []))} zones")
1854
 
1855
  logger.info(f"OCR result: {len(result.get('zones', []))} zones found")
1856
 
 
 
 
1857
  return JSONResponse(content=result)
1858
 
1859
  except Exception as e:
 
 
 
 
 
 
 
1860
  logger.error(f"Error processing uploaded file: {e}")
1861
  import traceback
1862
  logger.error(f"Traceback: {traceback.format_exc()}")
 
1870
  "type": type(e).__name__
1871
  }
1872
  )
1873
+ finally:
1874
+ # Always clean up temporary file
1875
+ if 'temp_path' in locals() and os.path.exists(temp_path):
1876
+ try:
1877
+ os.unlink(temp_path)
1878
+ logger.info(f"Cleaned up temp file: {temp_path}")
1879
+ except OSError as cleanup_error:
1880
+ logger.warning(f"Failed to clean up temp file {temp_path}: {cleanup_error}")
1881
 
1882
  @app.post("/ocr/process-path")
1883
  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")):
 
2850
  }
2851
  }
2852
 
2853
+ @app.post("/export/pdf")
2854
+ async def export_pdf_report(
2855
+ image: UploadFile = File(...),
2856
+ zones: str = Body(...),
2857
+ title: str = Body("OCR Measurement Report"),
2858
+ part_number: str = Body("Part 1")
2859
+ ):
2860
+ """Export PDF report with image, bubbles, and tolerance grid"""
2861
+ try:
2862
+ # Check if PDF service is available
2863
+ if not PDF_AVAILABLE or not pdf_service:
2864
+ raise HTTPException(
2865
+ status_code=503,
2866
+ detail="PDF export service not available. Please ensure reportlab is installed."
2867
+ )
2868
+
2869
+ # Validate image
2870
+ if not image.content_type or not image.content_type.startswith('image/'):
2871
+ raise HTTPException(status_code=400, detail="Invalid image file")
2872
+
2873
+ # Parse zones data
2874
+ import json
2875
+ try:
2876
+ zones_data = json.loads(zones)
2877
+ except json.JSONDecodeError:
2878
+ raise HTTPException(status_code=400, detail="Invalid zones data format")
2879
+
2880
+ # Read image data
2881
+ image_data = await image.read()
2882
+
2883
+ # Generate PDF
2884
+ pdf_bytes = pdf_service.generate_pdf_report(
2885
+ image_data=image_data,
2886
+ zones=zones_data,
2887
+ title=title,
2888
+ part_number=part_number
2889
+ )
2890
+
2891
+ # Return PDF as response
2892
+ return Response(
2893
+ content=pdf_bytes,
2894
+ media_type="application/pdf",
2895
+ headers={
2896
+ "Content-Disposition": f"attachment; filename={part_number}_measurement_report.pdf"
2897
+ }
2898
+ )
2899
+
2900
+ except Exception as e:
2901
+ logger.error(f"PDF export error: {e}")
2902
+ raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}")
2903
+
2904
  @app.post("/corrections/submit")
2905
  async def submit_corrections(request: Request):
2906
  """Submit corrections for training data"""
 
2922
 
2923
  if __name__ == "__main__":
2924
  import uvicorn
2925
+ # Get port from environment variable (for Hugging Face Spaces) or default to 8000
2926
+ port = int(os.getenv("PORT", 8000))
2927
+ uvicorn.run(app, host="0.0.0.0", port=port)
pdf_service.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Export Service for OCR Canvas App
3
+ Generates PDF reports with image, bubbles, and tolerance grid
4
+ """
5
+
6
+ import io
7
+ import base64
8
+ from typing import List, Dict, Any, Optional
9
+ from reportlab.lib.pagesizes import A4, letter
10
+ from reportlab.lib.units import inch, cm
11
+ from reportlab.lib import colors
12
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
13
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
14
+ from reportlab.platypus.flowables import HRFlowable
15
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
16
+ from PIL import Image as PILImage
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ class PDFExportService:
22
+ """Service for generating PDF reports with OCR data and tolerance information"""
23
+
24
+ def __init__(self):
25
+ self.page_width, self.page_height = A4
26
+ self.margin = 1 * inch
27
+ self.content_width = self.page_width - (2 * self.margin)
28
+
29
+ def generate_pdf_report(
30
+ self,
31
+ image_data: bytes,
32
+ zones: List[Dict[str, Any]],
33
+ title: str = "OCR Measurement Report",
34
+ part_number: str = "Part 1"
35
+ ) -> bytes:
36
+ """
37
+ Generate a comprehensive PDF report with image, bubbles, and tolerance grid
38
+
39
+ Args:
40
+ image_data: Image bytes
41
+ zones: List of zone data with OCR results and tolerance info
42
+ title: Report title
43
+ part_number: Part identifier (Part 1, Part 2, etc.)
44
+
45
+ Returns:
46
+ PDF bytes
47
+ """
48
+ try:
49
+ # Create PDF buffer
50
+ buffer = io.BytesIO()
51
+ doc = SimpleDocTemplate(
52
+ buffer,
53
+ pagesize=A4,
54
+ rightMargin=self.margin,
55
+ leftMargin=self.margin,
56
+ topMargin=self.margin,
57
+ bottomMargin=self.margin
58
+ )
59
+
60
+ # Build PDF content
61
+ story = []
62
+
63
+ # Add header
64
+ story.extend(self._create_header(title, part_number))
65
+ story.append(Spacer(1, 0.2 * inch))
66
+
67
+ # Add image section
68
+ story.extend(self._create_image_section(image_data, zones))
69
+ story.append(Spacer(1, 0.3 * inch))
70
+
71
+ # Add tolerance grid
72
+ story.extend(self._create_tolerance_grid(zones))
73
+
74
+ # Build PDF
75
+ doc.build(story)
76
+
77
+ # Get PDF bytes
78
+ pdf_bytes = buffer.getvalue()
79
+ buffer.close()
80
+
81
+ logger.info(f"Generated PDF report with {len(zones)} zones")
82
+ return pdf_bytes
83
+
84
+ except Exception as e:
85
+ logger.error(f"Error generating PDF: {e}")
86
+ raise
87
+
88
+ def _create_header(self, title: str, part_number: str) -> List:
89
+ """Create PDF header section"""
90
+ styles = getSampleStyleSheet()
91
+
92
+ # Custom styles
93
+ title_style = ParagraphStyle(
94
+ 'CustomTitle',
95
+ parent=styles['Heading1'],
96
+ fontSize=18,
97
+ spaceAfter=12,
98
+ alignment=TA_CENTER,
99
+ textColor=colors.darkblue
100
+ )
101
+
102
+ subtitle_style = ParagraphStyle(
103
+ 'CustomSubtitle',
104
+ parent=styles['Heading2'],
105
+ fontSize=14,
106
+ spaceAfter=6,
107
+ alignment=TA_CENTER,
108
+ textColor=colors.grey
109
+ )
110
+
111
+ story = [
112
+ Paragraph(title, title_style),
113
+ Paragraph(f"{part_number} - Dimensional Analysis Report", subtitle_style),
114
+ HRFlowable(width="100%", thickness=1, lineCap='round', color=colors.darkblue),
115
+ Spacer(1, 0.1 * inch)
116
+ ]
117
+
118
+ return story
119
+
120
+ def _create_image_section(self, image_data: bytes, zones: List[Dict[str, Any]]) -> List:
121
+ """Create image section with bubbles"""
122
+ try:
123
+ # Process image
124
+ pil_image = PILImage.open(io.BytesIO(image_data))
125
+
126
+ # Resize image to fit page (max width: 6 inches)
127
+ max_width = 6 * inch
128
+ max_height = 4 * inch
129
+
130
+ # Calculate scaling
131
+ img_width, img_height = pil_image.size
132
+ scale_x = max_width / img_width
133
+ scale_y = max_height / img_height
134
+ scale = min(scale_x, scale_y, 1.0) # Don't scale up
135
+
136
+ new_width = img_width * scale
137
+ new_height = img_height * scale
138
+
139
+ # Resize image
140
+ pil_image = pil_image.resize((int(new_width), int(new_height)), PILImage.Resampling.LANCZOS)
141
+
142
+ # Create image with bubbles
143
+ image_with_bubbles = self._add_bubbles_to_image(pil_image, zones, scale)
144
+
145
+ # Convert to bytes for ReportLab
146
+ img_buffer = io.BytesIO()
147
+ image_with_bubbles.save(img_buffer, format='PNG')
148
+ img_buffer.seek(0)
149
+
150
+ # Add image to PDF
151
+ img = Image(img_buffer, width=new_width, height=new_height)
152
+ img.hAlign = 'CENTER'
153
+
154
+ return [img]
155
+
156
+ except Exception as e:
157
+ logger.error(f"Error creating image section: {e}")
158
+ return [Paragraph("Error loading image", getSampleStyleSheet()['Normal'])]
159
+
160
+ def _add_bubbles_to_image(self, pil_image: PILImage.Image, zones: List[Dict[str, Any]], scale: float) -> PILImage.Image:
161
+ """Add numbered bubbles to the image"""
162
+ try:
163
+ from PIL import ImageDraw, ImageFont
164
+
165
+ # Create a copy to draw on
166
+ img_with_bubbles = pil_image.copy()
167
+ draw = ImageDraw.Draw(img_with_bubbles)
168
+
169
+ # Try to use a font, fallback to default if not available
170
+ # Make font size proportional to image scale
171
+ font_size = max(10, int(12 * scale))
172
+ try:
173
+ font = ImageFont.truetype("arial.ttf", font_size)
174
+ except:
175
+ font = ImageFont.load_default()
176
+
177
+ # Draw bubbles for each zone
178
+ for i, zone in enumerate(zones, 1):
179
+ try:
180
+ bbox = zone.get('bbox', {})
181
+ if not bbox:
182
+ continue
183
+
184
+ # Get bubble position (top-left of zone)
185
+ bubble_x = int(bbox.get('x1', 0) * scale)
186
+ bubble_y = int(bbox.get('y1', 0) * scale)
187
+
188
+ # Draw circle - make it smaller and proportional to image scale
189
+ radius = max(8, int(12 * scale)) # Scale with image, minimum 8px
190
+ left = bubble_x - radius
191
+ top = bubble_y - radius
192
+ right = bubble_x + radius
193
+ bottom = bubble_y + radius
194
+
195
+ # Draw white circle with black border
196
+ draw.ellipse([left, top, right, bottom], fill='white', outline='black', width=2)
197
+
198
+ # Draw number
199
+ text = str(i)
200
+ bbox_text = draw.textbbox((0, 0), text, font=font)
201
+ text_width = bbox_text[2] - bbox_text[0]
202
+ text_height = bbox_text[3] - bbox_text[1]
203
+
204
+ text_x = bubble_x - (text_width // 2)
205
+ text_y = bubble_y - (text_height // 2)
206
+
207
+ draw.text((text_x, text_y), text, fill='black', font=font)
208
+
209
+ except Exception as e:
210
+ logger.warning(f"Error drawing bubble for zone {i}: {e}")
211
+ continue
212
+
213
+ return img_with_bubbles
214
+
215
+ except Exception as e:
216
+ logger.error(f"Error adding bubbles to image: {e}")
217
+ return pil_image
218
+
219
+ def _create_tolerance_grid(self, zones: List[Dict[str, Any]]) -> List:
220
+ """Create tolerance information grid table for multiple parts"""
221
+ styles = getSampleStyleSheet()
222
+
223
+ # Table header
224
+ header_style = ParagraphStyle(
225
+ 'TableHeader',
226
+ parent=styles['Heading3'],
227
+ fontSize=14,
228
+ spaceAfter=12,
229
+ alignment=TA_CENTER,
230
+ textColor=colors.darkblue
231
+ )
232
+
233
+ story = [
234
+ Paragraph("Measurement Data Grid", header_style),
235
+ Spacer(1, 0.1 * inch)
236
+ ]
237
+
238
+ # Create table data - compact format for multiple parts
239
+ # Header row with multi-line headers
240
+ header_row = [
241
+ 'OCR\nValue',
242
+ 'Min\nTol',
243
+ 'Max\nTol',
244
+ 'Mid\nValue'
245
+ ]
246
+
247
+ # Add 10 columns for different parts
248
+ for i in range(1, 11):
249
+ header_row.append(f'Part {i}\nActual')
250
+
251
+ table_data = [header_row]
252
+
253
+ for zone in zones:
254
+ # Extract data
255
+ text = zone.get('text', 'N/A')
256
+ tolerance_info = zone.get('tolerance_info', {})
257
+
258
+ min_tol = tolerance_info.get('min_tolerance', '')
259
+ max_tol = tolerance_info.get('max_tolerance', '')
260
+ mid_val = tolerance_info.get('middle_value', '')
261
+
262
+ # Add row to table - no reference number, just data
263
+ row = [
264
+ text, # OCR Value
265
+ min_tol, # Min Tolerance
266
+ max_tol, # Max Tolerance
267
+ mid_val, # Mid Value
268
+ ]
269
+
270
+ # Add 10 empty columns for actual measurements
271
+ for i in range(10):
272
+ row.append('') # Empty for user to fill
273
+
274
+ table_data.append(row)
275
+
276
+ # Create table with compact column widths
277
+ col_widths = [
278
+ 0.7 * inch, # OCR Value
279
+ 0.5 * inch, # Min Tol
280
+ 0.5 * inch, # Max Tol
281
+ 0.5 * inch, # Mid Value
282
+ ]
283
+
284
+ # Add 10 columns for parts (0.6 inch each)
285
+ for i in range(10):
286
+ col_widths.append(0.6 * inch)
287
+
288
+ table = Table(table_data, colWidths=col_widths)
289
+
290
+ # Style the table
291
+ table_style = TableStyle([
292
+ # Header row
293
+ ('BACKGROUND', (0, 0), (-1, 0), colors.darkblue),
294
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
295
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
296
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
297
+ ('FONTSIZE', (0, 0), (-1, 0), 8),
298
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 8),
299
+ ('TOPPADDING', (0, 0), (-1, 0), 8),
300
+
301
+ # Data rows
302
+ ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
303
+ ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
304
+ ('FONTSIZE', (0, 1), (-1, -1), 8),
305
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.black),
306
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
307
+
308
+ # Alternating row colors
309
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.lightgrey]),
310
+
311
+ # Make empty measurement cells more prominent
312
+ ('BACKGROUND', (4, 1), (-1, -1), colors.white),
313
+ ('LINEBELOW', (4, 1), (-1, -1), 1, colors.darkblue),
314
+ ('LINEABOVE', (4, 1), (-1, -1), 1, colors.darkblue),
315
+ ])
316
+
317
+ table.setStyle(table_style)
318
+ story.append(table)
319
+
320
+ # Add footer note
321
+ footer_style = ParagraphStyle(
322
+ 'FooterNote',
323
+ parent=styles['Normal'],
324
+ fontSize=8,
325
+ spaceBefore=12,
326
+ alignment=TA_LEFT,
327
+ textColor=colors.grey
328
+ )
329
+
330
+ story.extend([
331
+ Spacer(1, 0.2 * inch),
332
+ Paragraph("Note: Fill in the 'Part X Actual' columns with measured values for each part during inspection.", footer_style),
333
+ Paragraph("OCR Value: Detected text | Min/Max Tol: Tolerance range | Mid: Calculated middle value", footer_style)
334
+ ])
335
+
336
+ return story
337
+
338
+ # Global instance
339
+ pdf_service = PDFExportService()
requirements.txt CHANGED
@@ -1,25 +1,30 @@
1
- # Core FastAPI dependencies
2
- fastapi>=0.104.1
3
- uvicorn[standard]>=0.24.0
4
-
5
- # OCR and Image Processing
6
- paddleocr>=2.7.0
7
- opencv-python>=4.8.0
8
- numpy>=1.24.0
9
-
10
- # HTTP and API
11
- requests>=2.31.0
12
- httpx>=0.25.0
13
-
14
- # Image processing utilities
15
- Pillow>=10.0.0
16
- scikit-image>=0.21.0
17
-
18
- # File handling
19
- python-multipart>=0.0.6
20
-
21
- # Optional: For better performance
22
- paddlepaddle>=2.5.0 # Uncomment if you want to install PaddlePaddle separately
23
-
24
- # Development and debugging (optional)
25
- python-dotenv>=1.0.0
 
 
 
 
 
 
1
+ # Core FastAPI dependencies
2
+ fastapi==0.104.1
3
+ uvicorn[standard]==0.24.0
4
+
5
+ # OCR and Image Processing
6
+ paddleocr==2.7.3
7
+ opencv-python-headless==4.8.1.78
8
+ numpy==1.24.3
9
+
10
+ # HTTP and API
11
+ requests==2.31.0
12
+ httpx==0.25.2
13
+
14
+ # Image processing utilities
15
+ Pillow==10.1.0
16
+
17
+ # File handling
18
+ python-multipart==0.0.6
19
+
20
+ # PDF generation
21
+ reportlab==4.0.7
22
+
23
+ # Additional dependencies for HF Spaces
24
+ python-dotenv==1.0.0
25
+ pydantic==2.5.0
26
+ starlette==0.27.0
27
+
28
+ # System dependencies (these will be handled by HF Spaces)
29
+ # libgl1-mesa-glx
30
+ # libglib2.0-0