Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- app.py +345 -81
- pdf_service.py +339 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
|
|
|
|
|
|
|
| 475 |
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 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 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
text_angle = angle2
|
| 497 |
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
else:
|
| 508 |
-
#
|
| 509 |
-
if
|
| 510 |
return 0
|
| 511 |
-
elif
|
| 512 |
return 90
|
| 513 |
-
elif
|
| 514 |
return 180
|
| 515 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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":
|
| 1333 |
-
"confidence":
|
| 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(
|
| 1346 |
-
"tolerance_info": parse_tolerance(
|
| 1347 |
}
|
| 1348 |
|
| 1349 |
-
logger.info(f"✅ Zone {index} CREATED: text='{
|
| 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 |
-
|
|
|
|
| 1630 |
content = await file.read()
|
| 1631 |
-
|
| 1632 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
-
uvicorn[standard]
|
| 4 |
-
|
| 5 |
-
# OCR and Image Processing
|
| 6 |
-
paddleocr
|
| 7 |
-
opencv-python
|
| 8 |
-
numpy
|
| 9 |
-
|
| 10 |
-
# HTTP and API
|
| 11 |
-
requests
|
| 12 |
-
httpx
|
| 13 |
-
|
| 14 |
-
# Image processing utilities
|
| 15 |
-
Pillow
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|