Spaces:
Sleeping
Sleeping
m9jaex commited on
Commit ·
115023b
1
Parent(s): d037fc6
Add progress tracking to all image processing endpoints
Browse filesIntroduce asynchronous processing with job IDs for progress monitoring, add new endpoints to retrieve job status and results, and enhance thread safety in the progress tracking mechanism.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2cb6c3f4-ce08-4bde-a268-54e2cdd3f036
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: d018bb57-0207-4b49-8f4d-b373d0afb827
- app.py +447 -14
- progress_tracker.py +88 -45
app.py
CHANGED
|
@@ -1,19 +1,24 @@
|
|
| 1 |
import os
|
| 2 |
import io
|
| 3 |
import uuid
|
|
|
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
-
from fastapi import FastAPI, File, UploadFile, HTTPException, Query
|
| 6 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 7 |
from fastapi.staticfiles import StaticFiles
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from PIL import Image
|
| 10 |
import numpy as np
|
|
|
|
| 11 |
|
| 12 |
UPLOAD_DIR = Path("uploads")
|
| 13 |
OUTPUT_DIR = Path("outputs")
|
| 14 |
UPLOAD_DIR.mkdir(exist_ok=True)
|
| 15 |
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 16 |
|
|
|
|
|
|
|
| 17 |
app = FastAPI(
|
| 18 |
title="AI Image Processing API",
|
| 19 |
description="""
|
|
@@ -89,9 +94,59 @@ async def health_check():
|
|
| 89 |
return {
|
| 90 |
"status": "healthy",
|
| 91 |
"version": "2.0.0",
|
| 92 |
-
"features": ["enhance", "remove-background", "denoise", "docscan"]
|
| 93 |
}
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
@app.get("/model-info")
|
| 96 |
async def model_info():
|
| 97 |
"""Get information about the loaded AI models."""
|
|
@@ -124,18 +179,97 @@ async def model_info():
|
|
| 124 |
"max_input_size": "512x512 for fast processing (images auto-resized)"
|
| 125 |
}
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
@app.post("/enhance")
|
| 128 |
async def enhance_image(
|
| 129 |
file: UploadFile = File(..., description="Image file to enhance (PNG, JPG, JPEG, WebP, BMP)"),
|
| 130 |
-
scale: int = Query(default=2, ge=2, le=4, description="Upscale factor (2 or 4)")
|
|
|
|
| 131 |
):
|
| 132 |
"""
|
| 133 |
Enhance an image using Real-ESRGAN AI model.
|
| 134 |
|
| 135 |
- **file**: Upload an image file (PNG, JPG, JPEG, WebP, BMP)
|
| 136 |
- **scale**: Upscaling factor - 2 for 2x resolution, 4 for 4x resolution
|
|
|
|
| 137 |
|
| 138 |
-
Returns the enhanced image as a PNG file.
|
| 139 |
"""
|
| 140 |
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp"]
|
| 141 |
if file.content_type not in allowed_types:
|
|
@@ -144,8 +278,28 @@ async def enhance_image(
|
|
| 144 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 145 |
)
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
try:
|
| 148 |
-
contents = await file.read()
|
| 149 |
input_image = Image.open(io.BytesIO(contents))
|
| 150 |
|
| 151 |
if input_image.mode != "RGB":
|
|
@@ -244,10 +398,84 @@ async def enhance_image_base64(
|
|
| 244 |
except Exception as e:
|
| 245 |
raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
@app.post("/remove-background")
|
| 248 |
async def remove_background(
|
| 249 |
file: UploadFile = File(..., description="Image file to remove background from"),
|
| 250 |
-
bgcolor: str = Query(default="transparent", description="Background color: 'transparent', 'white', 'black', or hex color like '#FF0000'")
|
|
|
|
| 251 |
):
|
| 252 |
"""
|
| 253 |
Remove background from an image using AI.
|
|
@@ -258,6 +486,7 @@ async def remove_background(
|
|
| 258 |
- 'white' - White background
|
| 259 |
- 'black' - Black background
|
| 260 |
- Hex color like '#FF0000' - Custom color
|
|
|
|
| 261 |
|
| 262 |
Returns the image with background removed as PNG.
|
| 263 |
"""
|
|
@@ -268,9 +497,28 @@ async def remove_background(
|
|
| 268 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 269 |
)
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
bg_color = None
|
| 275 |
if bgcolor != "transparent":
|
| 276 |
if bgcolor == "white":
|
|
@@ -367,10 +615,86 @@ async def remove_background_base64(
|
|
| 367 |
except Exception as e:
|
| 368 |
raise HTTPException(status_code=500, detail=f"Error removing background: {str(e)}")
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
@app.post("/denoise")
|
| 371 |
async def denoise_image(
|
| 372 |
file: UploadFile = File(..., description="Image file to denoise"),
|
| 373 |
-
strength: int = Query(default=10, ge=1, le=30, description="Denoising strength (1-30, higher = more smoothing)")
|
|
|
|
| 374 |
):
|
| 375 |
"""
|
| 376 |
Reduce noise in an image using Non-Local Means Denoising.
|
|
@@ -380,6 +704,7 @@ async def denoise_image(
|
|
| 380 |
- Low (1-5): Light noise reduction, preserves details
|
| 381 |
- Medium (6-15): Balanced noise reduction
|
| 382 |
- High (16-30): Strong noise reduction, may lose some details
|
|
|
|
| 383 |
|
| 384 |
Returns the denoised image as PNG.
|
| 385 |
"""
|
|
@@ -390,8 +715,28 @@ async def denoise_image(
|
|
| 390 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 391 |
)
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
try:
|
| 394 |
-
contents = await file.read()
|
| 395 |
input_image = Image.open(io.BytesIO(contents))
|
| 396 |
|
| 397 |
if input_image.mode != "RGB":
|
|
@@ -500,12 +845,81 @@ def get_doc_scanner():
|
|
| 500 |
doc_scanner = get_document_scanner()
|
| 501 |
return doc_scanner
|
| 502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
@app.post("/docscan")
|
| 505 |
async def scan_document(
|
| 506 |
file: UploadFile = File(..., description="Document image to scan (PNG, JPG, JPEG, WebP, BMP)"),
|
| 507 |
enhance_hd: bool = Query(default=True, description="Apply HD enhancement using AI (Real-ESRGAN)"),
|
| 508 |
-
scale: int = Query(default=2, ge=1, le=4, description="Upscale factor for HD enhancement (1-4)")
|
|
|
|
| 509 |
):
|
| 510 |
"""
|
| 511 |
Scan and enhance a document image with AI-powered processing.
|
|
@@ -524,6 +938,7 @@ async def scan_document(
|
|
| 524 |
- **file**: Upload a photo of a document (supports various angles and lighting)
|
| 525 |
- **enhance_hd**: Enable AI-powered HD enhancement (default: True)
|
| 526 |
- **scale**: Upscaling factor 1-4 (default: 2 for balanced quality/size)
|
|
|
|
| 527 |
|
| 528 |
Returns the scanned document as a high-quality PNG file.
|
| 529 |
"""
|
|
@@ -534,8 +949,28 @@ async def scan_document(
|
|
| 534 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 535 |
)
|
| 536 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
try:
|
| 538 |
-
contents = await file.read()
|
| 539 |
input_image = Image.open(io.BytesIO(contents))
|
| 540 |
|
| 541 |
if input_image.mode != "RGB":
|
|
@@ -547,8 +982,6 @@ async def scan_document(
|
|
| 547 |
new_size = (int(input_image.width * ratio), int(input_image.height * ratio))
|
| 548 |
input_image = input_image.resize(new_size, Image.LANCZOS)
|
| 549 |
|
| 550 |
-
original_size = {"width": input_image.width, "height": input_image.height}
|
| 551 |
-
|
| 552 |
scanner = get_doc_scanner()
|
| 553 |
scanned_image = scanner.process_document(input_image, enhance_hd=enhance_hd, scale=scale)
|
| 554 |
|
|
|
|
| 1 |
import os
|
| 2 |
import io
|
| 3 |
import uuid
|
| 4 |
+
import threading
|
| 5 |
+
import base64
|
| 6 |
from pathlib import Path
|
| 7 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException, Query, BackgroundTasks
|
| 8 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from PIL import Image
|
| 12 |
import numpy as np
|
| 13 |
+
from progress_tracker import get_tracker, JobStatus
|
| 14 |
|
| 15 |
UPLOAD_DIR = Path("uploads")
|
| 16 |
OUTPUT_DIR = Path("outputs")
|
| 17 |
UPLOAD_DIR.mkdir(exist_ok=True)
|
| 18 |
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 19 |
|
| 20 |
+
tracker = get_tracker()
|
| 21 |
+
|
| 22 |
app = FastAPI(
|
| 23 |
title="AI Image Processing API",
|
| 24 |
description="""
|
|
|
|
| 94 |
return {
|
| 95 |
"status": "healthy",
|
| 96 |
"version": "2.0.0",
|
| 97 |
+
"features": ["enhance", "remove-background", "denoise", "docscan", "progress-tracking"]
|
| 98 |
}
|
| 99 |
|
| 100 |
+
@app.get("/progress/{job_id}")
|
| 101 |
+
async def get_progress(job_id: str):
|
| 102 |
+
"""
|
| 103 |
+
Get the progress of an async image processing job.
|
| 104 |
+
|
| 105 |
+
- **job_id**: The job ID returned when starting an async processing request
|
| 106 |
+
|
| 107 |
+
Returns the current progress, status, and message for the job.
|
| 108 |
+
"""
|
| 109 |
+
progress = tracker.get_progress(job_id)
|
| 110 |
+
if progress is None:
|
| 111 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 112 |
+
return progress
|
| 113 |
+
|
| 114 |
+
@app.get("/result/{job_id}")
|
| 115 |
+
async def get_result(job_id: str):
|
| 116 |
+
"""
|
| 117 |
+
Get the result of a completed async job.
|
| 118 |
+
|
| 119 |
+
- **job_id**: The job ID returned when starting an async processing request
|
| 120 |
+
|
| 121 |
+
Returns the processed image as a file download if the job is complete.
|
| 122 |
+
"""
|
| 123 |
+
job = tracker.get_job(job_id)
|
| 124 |
+
if job is None:
|
| 125 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 126 |
+
|
| 127 |
+
if job.status == JobStatus.PROCESSING or job.status == JobStatus.PENDING:
|
| 128 |
+
return JSONResponse({
|
| 129 |
+
"status": job.status.value,
|
| 130 |
+
"progress": job.progress,
|
| 131 |
+
"message": "Job still processing. Check /progress/{job_id} for updates."
|
| 132 |
+
}, status_code=202)
|
| 133 |
+
|
| 134 |
+
if job.status == JobStatus.FAILED:
|
| 135 |
+
raise HTTPException(status_code=500, detail=job.error)
|
| 136 |
+
|
| 137 |
+
if job.result is None:
|
| 138 |
+
raise HTTPException(status_code=500, detail="Job completed but no result available")
|
| 139 |
+
|
| 140 |
+
result_path = Path(job.result)
|
| 141 |
+
if not result_path.exists():
|
| 142 |
+
raise HTTPException(status_code=404, detail="Result file not found")
|
| 143 |
+
|
| 144 |
+
return FileResponse(
|
| 145 |
+
result_path,
|
| 146 |
+
media_type="image/png",
|
| 147 |
+
filename=result_path.name
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
@app.get("/model-info")
|
| 151 |
async def model_info():
|
| 152 |
"""Get information about the loaded AI models."""
|
|
|
|
| 179 |
"max_input_size": "512x512 for fast processing (images auto-resized)"
|
| 180 |
}
|
| 181 |
|
| 182 |
+
def process_enhance_job(job_id: str, image_bytes: bytes, scale: int, output_path: Path, filename: str):
|
| 183 |
+
"""Background task to process image enhancement with progress tracking."""
|
| 184 |
+
try:
|
| 185 |
+
input_image = Image.open(io.BytesIO(image_bytes))
|
| 186 |
+
|
| 187 |
+
if input_image.mode != "RGB":
|
| 188 |
+
input_image = input_image.convert("RGB")
|
| 189 |
+
|
| 190 |
+
max_size = 512
|
| 191 |
+
if input_image.width > max_size or input_image.height > max_size:
|
| 192 |
+
ratio = min(max_size / input_image.width, max_size / input_image.height)
|
| 193 |
+
new_size = (int(input_image.width * ratio), int(input_image.height * ratio))
|
| 194 |
+
input_image = input_image.resize(new_size, Image.LANCZOS)
|
| 195 |
+
|
| 196 |
+
tracker.update_progress(job_id, 5.0, "Image loaded and preprocessed")
|
| 197 |
+
|
| 198 |
+
def progress_callback(progress, message, current_step, total_steps):
|
| 199 |
+
tracker.update_progress(job_id, progress, message, current_step, total_steps)
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
enhancer_instance = get_enhancer()
|
| 203 |
+
enhanced_image = enhancer_instance.enhance(input_image, scale, progress_callback)
|
| 204 |
+
enhanced_image.save(output_path, "PNG")
|
| 205 |
+
except ImportError:
|
| 206 |
+
tracker.update_progress(job_id, 50.0, "Using fallback enhancer...")
|
| 207 |
+
enhanced_image = input_image.resize(
|
| 208 |
+
(input_image.width * scale, input_image.height * scale),
|
| 209 |
+
Image.LANCZOS
|
| 210 |
+
)
|
| 211 |
+
enhanced_image.save(output_path, "PNG")
|
| 212 |
+
|
| 213 |
+
tracker.complete_job(job_id, str(output_path), f"Enhanced to {enhanced_image.width}x{enhanced_image.height}")
|
| 214 |
+
|
| 215 |
+
except Exception as e:
|
| 216 |
+
tracker.fail_job(job_id, str(e))
|
| 217 |
+
|
| 218 |
+
@app.post("/enhance/async")
|
| 219 |
+
async def enhance_image_async(
|
| 220 |
+
background_tasks: BackgroundTasks,
|
| 221 |
+
file: UploadFile = File(..., description="Image file to enhance (PNG, JPG, JPEG, WebP, BMP)"),
|
| 222 |
+
scale: int = Query(default=2, ge=2, le=4, description="Upscale factor (2 or 4)")
|
| 223 |
+
):
|
| 224 |
+
"""
|
| 225 |
+
Start async image enhancement with progress tracking.
|
| 226 |
+
|
| 227 |
+
- **file**: Upload an image file (PNG, JPG, JPEG, WebP, BMP)
|
| 228 |
+
- **scale**: Upscaling factor - 2 for 2x resolution, 4 for 4x resolution
|
| 229 |
+
|
| 230 |
+
Returns a job_id that can be used to track progress via /progress/{job_id}
|
| 231 |
+
and retrieve the result via /result/{job_id}
|
| 232 |
+
"""
|
| 233 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp"]
|
| 234 |
+
if file.content_type not in allowed_types:
|
| 235 |
+
raise HTTPException(
|
| 236 |
+
status_code=400,
|
| 237 |
+
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
contents = await file.read()
|
| 241 |
+
job_id = tracker.create_job("Starting image enhancement...")
|
| 242 |
+
file_id = str(uuid.uuid4())
|
| 243 |
+
output_path = OUTPUT_DIR / f"{file_id}_enhanced.png"
|
| 244 |
+
|
| 245 |
+
thread = threading.Thread(
|
| 246 |
+
target=process_enhance_job,
|
| 247 |
+
args=(job_id, contents, scale, output_path, file.filename)
|
| 248 |
+
)
|
| 249 |
+
thread.start()
|
| 250 |
+
|
| 251 |
+
return JSONResponse({
|
| 252 |
+
"job_id": job_id,
|
| 253 |
+
"status": "processing",
|
| 254 |
+
"message": "Enhancement started. Poll /progress/{job_id} for updates.",
|
| 255 |
+
"progress_url": f"/progress/{job_id}",
|
| 256 |
+
"result_url": f"/result/{job_id}"
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
@app.post("/enhance")
|
| 260 |
async def enhance_image(
|
| 261 |
file: UploadFile = File(..., description="Image file to enhance (PNG, JPG, JPEG, WebP, BMP)"),
|
| 262 |
+
scale: int = Query(default=2, ge=2, le=4, description="Upscale factor (2 or 4)"),
|
| 263 |
+
async_mode: bool = Query(default=False, description="Use async mode with progress tracking")
|
| 264 |
):
|
| 265 |
"""
|
| 266 |
Enhance an image using Real-ESRGAN AI model.
|
| 267 |
|
| 268 |
- **file**: Upload an image file (PNG, JPG, JPEG, WebP, BMP)
|
| 269 |
- **scale**: Upscaling factor - 2 for 2x resolution, 4 for 4x resolution
|
| 270 |
+
- **async_mode**: If true, returns job_id for progress tracking instead of waiting
|
| 271 |
|
| 272 |
+
Returns the enhanced image as a PNG file (or job_id if async_mode=true).
|
| 273 |
"""
|
| 274 |
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp"]
|
| 275 |
if file.content_type not in allowed_types:
|
|
|
|
| 278 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 279 |
)
|
| 280 |
|
| 281 |
+
contents = await file.read()
|
| 282 |
+
|
| 283 |
+
if async_mode:
|
| 284 |
+
job_id = tracker.create_job("Starting image enhancement...")
|
| 285 |
+
file_id = str(uuid.uuid4())
|
| 286 |
+
output_path = OUTPUT_DIR / f"{file_id}_enhanced.png"
|
| 287 |
+
|
| 288 |
+
thread = threading.Thread(
|
| 289 |
+
target=process_enhance_job,
|
| 290 |
+
args=(job_id, contents, scale, output_path, file.filename)
|
| 291 |
+
)
|
| 292 |
+
thread.start()
|
| 293 |
+
|
| 294 |
+
return JSONResponse({
|
| 295 |
+
"job_id": job_id,
|
| 296 |
+
"status": "processing",
|
| 297 |
+
"message": "Enhancement started. Poll /progress/{job_id} for updates.",
|
| 298 |
+
"progress_url": f"/progress/{job_id}",
|
| 299 |
+
"result_url": f"/result/{job_id}"
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
try:
|
|
|
|
| 303 |
input_image = Image.open(io.BytesIO(contents))
|
| 304 |
|
| 305 |
if input_image.mode != "RGB":
|
|
|
|
| 398 |
except Exception as e:
|
| 399 |
raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
|
| 400 |
|
| 401 |
+
def process_remove_bg_job(job_id: str, image_bytes: bytes, bgcolor: str, output_path: Path):
|
| 402 |
+
"""Background task for removing background with progress tracking."""
|
| 403 |
+
try:
|
| 404 |
+
tracker.update_progress(job_id, 10.0, "Loading image...")
|
| 405 |
+
|
| 406 |
+
bg_color = None
|
| 407 |
+
if bgcolor != "transparent":
|
| 408 |
+
if bgcolor == "white":
|
| 409 |
+
bg_color = (255, 255, 255, 255)
|
| 410 |
+
elif bgcolor == "black":
|
| 411 |
+
bg_color = (0, 0, 0, 255)
|
| 412 |
+
elif bgcolor.startswith("#"):
|
| 413 |
+
hex_color = bgcolor.lstrip("#")
|
| 414 |
+
if len(hex_color) == 6:
|
| 415 |
+
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
| 416 |
+
bg_color = (r, g, b, 255)
|
| 417 |
+
|
| 418 |
+
tracker.update_progress(job_id, 20.0, "Initializing AI model...")
|
| 419 |
+
|
| 420 |
+
try:
|
| 421 |
+
from rembg import remove
|
| 422 |
+
tracker.update_progress(job_id, 40.0, "Removing background...")
|
| 423 |
+
session = get_bg_remover()
|
| 424 |
+
tracker.update_progress(job_id, 60.0, "Processing...")
|
| 425 |
+
output_data = remove(image_bytes, session=session, bgcolor=bg_color)
|
| 426 |
+
output_image = Image.open(io.BytesIO(output_data))
|
| 427 |
+
except ImportError:
|
| 428 |
+
tracker.update_progress(job_id, 50.0, "Using fallback (no rembg)...")
|
| 429 |
+
input_image = Image.open(io.BytesIO(image_bytes))
|
| 430 |
+
if input_image.mode != "RGBA":
|
| 431 |
+
input_image = input_image.convert("RGBA")
|
| 432 |
+
output_image = input_image
|
| 433 |
+
|
| 434 |
+
tracker.update_progress(job_id, 90.0, "Saving result...")
|
| 435 |
+
output_image.save(output_path, "PNG")
|
| 436 |
+
tracker.complete_job(job_id, str(output_path), "Background removed successfully")
|
| 437 |
+
|
| 438 |
+
except Exception as e:
|
| 439 |
+
tracker.fail_job(job_id, str(e))
|
| 440 |
+
|
| 441 |
+
@app.post("/remove-background/async")
|
| 442 |
+
async def remove_background_async(
|
| 443 |
+
file: UploadFile = File(..., description="Image file to remove background from"),
|
| 444 |
+
bgcolor: str = Query(default="transparent", description="Background color")
|
| 445 |
+
):
|
| 446 |
+
"""
|
| 447 |
+
Start async background removal with progress tracking.
|
| 448 |
+
|
| 449 |
+
Returns a job_id for progress tracking via /progress/{job_id}
|
| 450 |
+
"""
|
| 451 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp"]
|
| 452 |
+
if file.content_type not in allowed_types:
|
| 453 |
+
raise HTTPException(status_code=400, detail=f"Invalid file type")
|
| 454 |
+
|
| 455 |
+
contents = await file.read()
|
| 456 |
+
job_id = tracker.create_job("Starting background removal...")
|
| 457 |
+
file_id = str(uuid.uuid4())
|
| 458 |
+
output_path = OUTPUT_DIR / f"{file_id}_nobg.png"
|
| 459 |
+
|
| 460 |
+
thread = threading.Thread(
|
| 461 |
+
target=process_remove_bg_job,
|
| 462 |
+
args=(job_id, contents, bgcolor, output_path)
|
| 463 |
+
)
|
| 464 |
+
thread.start()
|
| 465 |
+
|
| 466 |
+
return JSONResponse({
|
| 467 |
+
"job_id": job_id,
|
| 468 |
+
"status": "processing",
|
| 469 |
+
"message": "Background removal started. Poll /progress/{job_id} for updates.",
|
| 470 |
+
"progress_url": f"/progress/{job_id}",
|
| 471 |
+
"result_url": f"/result/{job_id}"
|
| 472 |
+
})
|
| 473 |
+
|
| 474 |
@app.post("/remove-background")
|
| 475 |
async def remove_background(
|
| 476 |
file: UploadFile = File(..., description="Image file to remove background from"),
|
| 477 |
+
bgcolor: str = Query(default="transparent", description="Background color: 'transparent', 'white', 'black', or hex color like '#FF0000'"),
|
| 478 |
+
async_mode: bool = Query(default=False, description="Use async mode with progress tracking")
|
| 479 |
):
|
| 480 |
"""
|
| 481 |
Remove background from an image using AI.
|
|
|
|
| 486 |
- 'white' - White background
|
| 487 |
- 'black' - Black background
|
| 488 |
- Hex color like '#FF0000' - Custom color
|
| 489 |
+
- **async_mode**: If true, returns job_id for progress tracking
|
| 490 |
|
| 491 |
Returns the image with background removed as PNG.
|
| 492 |
"""
|
|
|
|
| 497 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 498 |
)
|
| 499 |
|
| 500 |
+
contents = await file.read()
|
| 501 |
+
|
| 502 |
+
if async_mode:
|
| 503 |
+
job_id = tracker.create_job("Starting background removal...")
|
| 504 |
+
file_id = str(uuid.uuid4())
|
| 505 |
+
output_path = OUTPUT_DIR / f"{file_id}_nobg.png"
|
| 506 |
+
|
| 507 |
+
thread = threading.Thread(
|
| 508 |
+
target=process_remove_bg_job,
|
| 509 |
+
args=(job_id, contents, bgcolor, output_path)
|
| 510 |
+
)
|
| 511 |
+
thread.start()
|
| 512 |
|
| 513 |
+
return JSONResponse({
|
| 514 |
+
"job_id": job_id,
|
| 515 |
+
"status": "processing",
|
| 516 |
+
"message": "Background removal started. Poll /progress/{job_id} for updates.",
|
| 517 |
+
"progress_url": f"/progress/{job_id}",
|
| 518 |
+
"result_url": f"/result/{job_id}"
|
| 519 |
+
})
|
| 520 |
+
|
| 521 |
+
try:
|
| 522 |
bg_color = None
|
| 523 |
if bgcolor != "transparent":
|
| 524 |
if bgcolor == "white":
|
|
|
|
| 615 |
except Exception as e:
|
| 616 |
raise HTTPException(status_code=500, detail=f"Error removing background: {str(e)}")
|
| 617 |
|
| 618 |
+
def process_denoise_job(job_id: str, image_bytes: bytes, strength: int, output_path: Path):
|
| 619 |
+
"""Background task for denoising with progress tracking."""
|
| 620 |
+
try:
|
| 621 |
+
tracker.update_progress(job_id, 10.0, "Loading image...")
|
| 622 |
+
input_image = Image.open(io.BytesIO(image_bytes))
|
| 623 |
+
|
| 624 |
+
if input_image.mode != "RGB":
|
| 625 |
+
input_image = input_image.convert("RGB")
|
| 626 |
+
|
| 627 |
+
tracker.update_progress(job_id, 20.0, "Applying denoising filter...")
|
| 628 |
+
|
| 629 |
+
try:
|
| 630 |
+
import cv2
|
| 631 |
+
tracker.update_progress(job_id, 30.0, "Using OpenCV Non-Local Means...")
|
| 632 |
+
img_array = np.array(input_image)
|
| 633 |
+
img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
|
| 634 |
+
|
| 635 |
+
tracker.update_progress(job_id, 50.0, "Processing...")
|
| 636 |
+
denoised_bgr = cv2.fastNlMeansDenoisingColored(
|
| 637 |
+
img_bgr,
|
| 638 |
+
None,
|
| 639 |
+
h=strength,
|
| 640 |
+
hForColorComponents=strength,
|
| 641 |
+
templateWindowSize=7,
|
| 642 |
+
searchWindowSize=21
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
tracker.update_progress(job_id, 80.0, "Converting result...")
|
| 646 |
+
denoised_rgb = cv2.cvtColor(denoised_bgr, cv2.COLOR_BGR2RGB)
|
| 647 |
+
output_image = Image.fromarray(denoised_rgb)
|
| 648 |
+
except ImportError:
|
| 649 |
+
tracker.update_progress(job_id, 50.0, "Using PIL fallback...")
|
| 650 |
+
from PIL import ImageFilter
|
| 651 |
+
output_image = input_image.filter(ImageFilter.SMOOTH_MORE)
|
| 652 |
+
|
| 653 |
+
tracker.update_progress(job_id, 90.0, "Saving result...")
|
| 654 |
+
output_image.save(output_path, "PNG")
|
| 655 |
+
tracker.complete_job(job_id, str(output_path), "Denoising complete")
|
| 656 |
+
|
| 657 |
+
except Exception as e:
|
| 658 |
+
tracker.fail_job(job_id, str(e))
|
| 659 |
+
|
| 660 |
+
@app.post("/denoise/async")
|
| 661 |
+
async def denoise_image_async(
|
| 662 |
+
file: UploadFile = File(..., description="Image file to denoise"),
|
| 663 |
+
strength: int = Query(default=10, ge=1, le=30, description="Denoising strength (1-30)")
|
| 664 |
+
):
|
| 665 |
+
"""
|
| 666 |
+
Start async denoising with progress tracking.
|
| 667 |
+
|
| 668 |
+
Returns a job_id for progress tracking via /progress/{job_id}
|
| 669 |
+
"""
|
| 670 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp"]
|
| 671 |
+
if file.content_type not in allowed_types:
|
| 672 |
+
raise HTTPException(status_code=400, detail=f"Invalid file type")
|
| 673 |
+
|
| 674 |
+
contents = await file.read()
|
| 675 |
+
job_id = tracker.create_job("Starting denoising...")
|
| 676 |
+
file_id = str(uuid.uuid4())
|
| 677 |
+
output_path = OUTPUT_DIR / f"{file_id}_denoised.png"
|
| 678 |
+
|
| 679 |
+
thread = threading.Thread(
|
| 680 |
+
target=process_denoise_job,
|
| 681 |
+
args=(job_id, contents, strength, output_path)
|
| 682 |
+
)
|
| 683 |
+
thread.start()
|
| 684 |
+
|
| 685 |
+
return JSONResponse({
|
| 686 |
+
"job_id": job_id,
|
| 687 |
+
"status": "processing",
|
| 688 |
+
"message": "Denoising started. Poll /progress/{job_id} for updates.",
|
| 689 |
+
"progress_url": f"/progress/{job_id}",
|
| 690 |
+
"result_url": f"/result/{job_id}"
|
| 691 |
+
})
|
| 692 |
+
|
| 693 |
@app.post("/denoise")
|
| 694 |
async def denoise_image(
|
| 695 |
file: UploadFile = File(..., description="Image file to denoise"),
|
| 696 |
+
strength: int = Query(default=10, ge=1, le=30, description="Denoising strength (1-30, higher = more smoothing)"),
|
| 697 |
+
async_mode: bool = Query(default=False, description="Use async mode with progress tracking")
|
| 698 |
):
|
| 699 |
"""
|
| 700 |
Reduce noise in an image using Non-Local Means Denoising.
|
|
|
|
| 704 |
- Low (1-5): Light noise reduction, preserves details
|
| 705 |
- Medium (6-15): Balanced noise reduction
|
| 706 |
- High (16-30): Strong noise reduction, may lose some details
|
| 707 |
+
- **async_mode**: If true, returns job_id for progress tracking
|
| 708 |
|
| 709 |
Returns the denoised image as PNG.
|
| 710 |
"""
|
|
|
|
| 715 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 716 |
)
|
| 717 |
|
| 718 |
+
contents = await file.read()
|
| 719 |
+
|
| 720 |
+
if async_mode:
|
| 721 |
+
job_id = tracker.create_job("Starting denoising...")
|
| 722 |
+
file_id = str(uuid.uuid4())
|
| 723 |
+
output_path = OUTPUT_DIR / f"{file_id}_denoised.png"
|
| 724 |
+
|
| 725 |
+
thread = threading.Thread(
|
| 726 |
+
target=process_denoise_job,
|
| 727 |
+
args=(job_id, contents, strength, output_path)
|
| 728 |
+
)
|
| 729 |
+
thread.start()
|
| 730 |
+
|
| 731 |
+
return JSONResponse({
|
| 732 |
+
"job_id": job_id,
|
| 733 |
+
"status": "processing",
|
| 734 |
+
"message": "Denoising started. Poll /progress/{job_id} for updates.",
|
| 735 |
+
"progress_url": f"/progress/{job_id}",
|
| 736 |
+
"result_url": f"/result/{job_id}"
|
| 737 |
+
})
|
| 738 |
+
|
| 739 |
try:
|
|
|
|
| 740 |
input_image = Image.open(io.BytesIO(contents))
|
| 741 |
|
| 742 |
if input_image.mode != "RGB":
|
|
|
|
| 845 |
doc_scanner = get_document_scanner()
|
| 846 |
return doc_scanner
|
| 847 |
|
| 848 |
+
def process_docscan_job(job_id: str, image_bytes: bytes, enhance_hd: bool, scale: int, output_path: Path):
|
| 849 |
+
"""Background task for document scanning with progress tracking."""
|
| 850 |
+
try:
|
| 851 |
+
tracker.update_progress(job_id, 5.0, "Loading document image...")
|
| 852 |
+
input_image = Image.open(io.BytesIO(image_bytes))
|
| 853 |
+
|
| 854 |
+
if input_image.mode != "RGB":
|
| 855 |
+
input_image = input_image.convert("RGB")
|
| 856 |
+
|
| 857 |
+
max_size = 2048
|
| 858 |
+
if input_image.width > max_size or input_image.height > max_size:
|
| 859 |
+
ratio = min(max_size / input_image.width, max_size / input_image.height)
|
| 860 |
+
new_size = (int(input_image.width * ratio), int(input_image.height * ratio))
|
| 861 |
+
input_image = input_image.resize(new_size, Image.LANCZOS)
|
| 862 |
+
|
| 863 |
+
tracker.update_progress(job_id, 15.0, "Detecting document edges...")
|
| 864 |
+
tracker.update_progress(job_id, 30.0, "Applying perspective correction...")
|
| 865 |
+
tracker.update_progress(job_id, 45.0, "Enhancing contrast...")
|
| 866 |
+
|
| 867 |
+
scanner = get_doc_scanner()
|
| 868 |
+
|
| 869 |
+
if enhance_hd:
|
| 870 |
+
tracker.update_progress(job_id, 60.0, "Applying HD enhancement (AI upscaling)...")
|
| 871 |
+
else:
|
| 872 |
+
tracker.update_progress(job_id, 60.0, "Finalizing document...")
|
| 873 |
+
|
| 874 |
+
scanned_image = scanner.process_document(input_image, enhance_hd=enhance_hd, scale=scale)
|
| 875 |
+
|
| 876 |
+
tracker.update_progress(job_id, 90.0, "Saving result...")
|
| 877 |
+
scanned_image.save(output_path, "PNG", optimize=True)
|
| 878 |
+
tracker.complete_job(job_id, str(output_path), f"Document scanned: {scanned_image.width}x{scanned_image.height}")
|
| 879 |
+
|
| 880 |
+
except Exception as e:
|
| 881 |
+
tracker.fail_job(job_id, str(e))
|
| 882 |
+
|
| 883 |
+
@app.post("/docscan/async")
|
| 884 |
+
async def scan_document_async(
|
| 885 |
+
file: UploadFile = File(..., description="Document image to scan"),
|
| 886 |
+
enhance_hd: bool = Query(default=True, description="Apply HD enhancement using AI"),
|
| 887 |
+
scale: int = Query(default=2, ge=1, le=4, description="Upscale factor for HD enhancement (1-4)")
|
| 888 |
+
):
|
| 889 |
+
"""
|
| 890 |
+
Start async document scanning with progress tracking.
|
| 891 |
+
|
| 892 |
+
Returns a job_id for progress tracking via /progress/{job_id}
|
| 893 |
+
"""
|
| 894 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp"]
|
| 895 |
+
if file.content_type not in allowed_types:
|
| 896 |
+
raise HTTPException(status_code=400, detail=f"Invalid file type")
|
| 897 |
+
|
| 898 |
+
contents = await file.read()
|
| 899 |
+
job_id = tracker.create_job("Starting document scan...")
|
| 900 |
+
file_id = str(uuid.uuid4())
|
| 901 |
+
output_path = OUTPUT_DIR / f"{file_id}_scanned.png"
|
| 902 |
+
|
| 903 |
+
thread = threading.Thread(
|
| 904 |
+
target=process_docscan_job,
|
| 905 |
+
args=(job_id, contents, enhance_hd, scale, output_path)
|
| 906 |
+
)
|
| 907 |
+
thread.start()
|
| 908 |
+
|
| 909 |
+
return JSONResponse({
|
| 910 |
+
"job_id": job_id,
|
| 911 |
+
"status": "processing",
|
| 912 |
+
"message": "Document scanning started. Poll /progress/{job_id} for updates.",
|
| 913 |
+
"progress_url": f"/progress/{job_id}",
|
| 914 |
+
"result_url": f"/result/{job_id}"
|
| 915 |
+
})
|
| 916 |
|
| 917 |
@app.post("/docscan")
|
| 918 |
async def scan_document(
|
| 919 |
file: UploadFile = File(..., description="Document image to scan (PNG, JPG, JPEG, WebP, BMP)"),
|
| 920 |
enhance_hd: bool = Query(default=True, description="Apply HD enhancement using AI (Real-ESRGAN)"),
|
| 921 |
+
scale: int = Query(default=2, ge=1, le=4, description="Upscale factor for HD enhancement (1-4)"),
|
| 922 |
+
async_mode: bool = Query(default=False, description="Use async mode with progress tracking")
|
| 923 |
):
|
| 924 |
"""
|
| 925 |
Scan and enhance a document image with AI-powered processing.
|
|
|
|
| 938 |
- **file**: Upload a photo of a document (supports various angles and lighting)
|
| 939 |
- **enhance_hd**: Enable AI-powered HD enhancement (default: True)
|
| 940 |
- **scale**: Upscaling factor 1-4 (default: 2 for balanced quality/size)
|
| 941 |
+
- **async_mode**: If true, returns job_id for progress tracking
|
| 942 |
|
| 943 |
Returns the scanned document as a high-quality PNG file.
|
| 944 |
"""
|
|
|
|
| 949 |
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
| 950 |
)
|
| 951 |
|
| 952 |
+
contents = await file.read()
|
| 953 |
+
|
| 954 |
+
if async_mode:
|
| 955 |
+
job_id = tracker.create_job("Starting document scan...")
|
| 956 |
+
file_id = str(uuid.uuid4())
|
| 957 |
+
output_path = OUTPUT_DIR / f"{file_id}_scanned.png"
|
| 958 |
+
|
| 959 |
+
thread = threading.Thread(
|
| 960 |
+
target=process_docscan_job,
|
| 961 |
+
args=(job_id, contents, enhance_hd, scale, output_path)
|
| 962 |
+
)
|
| 963 |
+
thread.start()
|
| 964 |
+
|
| 965 |
+
return JSONResponse({
|
| 966 |
+
"job_id": job_id,
|
| 967 |
+
"status": "processing",
|
| 968 |
+
"message": "Document scanning started. Poll /progress/{job_id} for updates.",
|
| 969 |
+
"progress_url": f"/progress/{job_id}",
|
| 970 |
+
"result_url": f"/result/{job_id}"
|
| 971 |
+
})
|
| 972 |
+
|
| 973 |
try:
|
|
|
|
| 974 |
input_image = Image.open(io.BytesIO(contents))
|
| 975 |
|
| 976 |
if input_image.mode != "RGB":
|
|
|
|
| 982 |
new_size = (int(input_image.width * ratio), int(input_image.height * ratio))
|
| 983 |
input_image = input_image.resize(new_size, Image.LANCZOS)
|
| 984 |
|
|
|
|
|
|
|
| 985 |
scanner = get_doc_scanner()
|
| 986 |
scanned_image = scanner.process_document(input_image, enhance_hd=enhance_hd, scale=scale)
|
| 987 |
|
progress_tracker.py
CHANGED
|
@@ -4,6 +4,7 @@ from typing import Dict, Optional, Any
|
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
from enum import Enum
|
| 6 |
import threading
|
|
|
|
| 7 |
|
| 8 |
class JobStatus(str, Enum):
|
| 9 |
PENDING = "pending"
|
|
@@ -26,83 +27,125 @@ class Job:
|
|
| 26 |
|
| 27 |
class ProgressTracker:
|
| 28 |
_instance = None
|
| 29 |
-
|
| 30 |
|
| 31 |
def __new__(cls):
|
| 32 |
if cls._instance is None:
|
| 33 |
-
with cls.
|
| 34 |
if cls._instance is None:
|
| 35 |
cls._instance = super().__new__(cls)
|
| 36 |
cls._instance._jobs: Dict[str, Job] = {}
|
|
|
|
| 37 |
cls._instance._cleanup_interval = 300
|
|
|
|
| 38 |
return cls._instance
|
| 39 |
|
| 40 |
def create_job(self, message: str = "Starting...") -> str:
|
| 41 |
job_id = str(uuid.uuid4())
|
| 42 |
-
self.
|
| 43 |
-
job_id=
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
return job_id
|
| 49 |
|
| 50 |
def update_progress(self, job_id: str, progress: float, message: str = "",
|
| 51 |
current_step: int = 0, total_steps: int = 100):
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
|
| 62 |
def complete_job(self, job_id: str, result: Any = None, message: str = "Completed"):
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
def fail_job(self, job_id: str, error: str):
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
| 78 |
|
| 79 |
def get_job(self, job_id: str) -> Optional[Job]:
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
def get_progress(self, job_id: str) -> Optional[dict]:
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
return None
|
| 86 |
-
return {
|
| 87 |
-
"job_id": job.job_id,
|
| 88 |
-
"status": job.status.value,
|
| 89 |
-
"progress": round(job.progress, 1),
|
| 90 |
-
"message": job.message,
|
| 91 |
-
"current_step": job.current_step,
|
| 92 |
-
"total_steps": job.total_steps,
|
| 93 |
-
"has_result": job.result is not None,
|
| 94 |
-
"error": job.error
|
| 95 |
-
}
|
| 96 |
|
| 97 |
-
def
|
|
|
|
| 98 |
current_time = time.time()
|
| 99 |
expired_jobs = [
|
| 100 |
job_id for job_id, job in self._jobs.items()
|
| 101 |
if current_time - job.updated_at > self._cleanup_interval
|
| 102 |
and job.status in (JobStatus.COMPLETED, JobStatus.FAILED)
|
| 103 |
]
|
|
|
|
| 104 |
for job_id in expired_jobs:
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
def get_tracker() -> ProgressTracker:
|
| 108 |
return ProgressTracker()
|
|
|
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
from enum import Enum
|
| 6 |
import threading
|
| 7 |
+
from pathlib import Path
|
| 8 |
|
| 9 |
class JobStatus(str, Enum):
|
| 10 |
PENDING = "pending"
|
|
|
|
| 27 |
|
| 28 |
class ProgressTracker:
|
| 29 |
_instance = None
|
| 30 |
+
_init_lock = threading.Lock()
|
| 31 |
|
| 32 |
def __new__(cls):
|
| 33 |
if cls._instance is None:
|
| 34 |
+
with cls._init_lock:
|
| 35 |
if cls._instance is None:
|
| 36 |
cls._instance = super().__new__(cls)
|
| 37 |
cls._instance._jobs: Dict[str, Job] = {}
|
| 38 |
+
cls._instance._lock = threading.RLock()
|
| 39 |
cls._instance._cleanup_interval = 300
|
| 40 |
+
cls._instance._file_cleanup_interval = 600
|
| 41 |
return cls._instance
|
| 42 |
|
| 43 |
def create_job(self, message: str = "Starting...") -> str:
|
| 44 |
job_id = str(uuid.uuid4())
|
| 45 |
+
with self._lock:
|
| 46 |
+
self._jobs[job_id] = Job(
|
| 47 |
+
job_id=job_id,
|
| 48 |
+
status=JobStatus.PENDING,
|
| 49 |
+
message=message
|
| 50 |
+
)
|
| 51 |
+
self._cleanup_old_jobs_locked()
|
| 52 |
return job_id
|
| 53 |
|
| 54 |
def update_progress(self, job_id: str, progress: float, message: str = "",
|
| 55 |
current_step: int = 0, total_steps: int = 100):
|
| 56 |
+
with self._lock:
|
| 57 |
+
if job_id in self._jobs:
|
| 58 |
+
job = self._jobs[job_id]
|
| 59 |
+
job.progress = min(progress, 100.0)
|
| 60 |
+
job.current_step = current_step
|
| 61 |
+
job.total_steps = total_steps
|
| 62 |
+
if message:
|
| 63 |
+
job.message = message
|
| 64 |
+
job.status = JobStatus.PROCESSING
|
| 65 |
+
job.updated_at = time.time()
|
| 66 |
|
| 67 |
def complete_job(self, job_id: str, result: Any = None, message: str = "Completed"):
|
| 68 |
+
with self._lock:
|
| 69 |
+
if job_id in self._jobs:
|
| 70 |
+
job = self._jobs[job_id]
|
| 71 |
+
job.status = JobStatus.COMPLETED
|
| 72 |
+
job.progress = 100.0
|
| 73 |
+
job.message = message
|
| 74 |
+
job.result = result
|
| 75 |
+
job.updated_at = time.time()
|
| 76 |
|
| 77 |
def fail_job(self, job_id: str, error: str):
|
| 78 |
+
with self._lock:
|
| 79 |
+
if job_id in self._jobs:
|
| 80 |
+
job = self._jobs[job_id]
|
| 81 |
+
job.status = JobStatus.FAILED
|
| 82 |
+
job.error = error
|
| 83 |
+
job.message = f"Failed: {error}"
|
| 84 |
+
job.updated_at = time.time()
|
| 85 |
|
| 86 |
def get_job(self, job_id: str) -> Optional[Job]:
|
| 87 |
+
with self._lock:
|
| 88 |
+
job = self._jobs.get(job_id)
|
| 89 |
+
if job:
|
| 90 |
+
return Job(
|
| 91 |
+
job_id=job.job_id,
|
| 92 |
+
status=job.status,
|
| 93 |
+
progress=job.progress,
|
| 94 |
+
message=job.message,
|
| 95 |
+
result=job.result,
|
| 96 |
+
error=job.error,
|
| 97 |
+
created_at=job.created_at,
|
| 98 |
+
updated_at=job.updated_at,
|
| 99 |
+
total_steps=job.total_steps,
|
| 100 |
+
current_step=job.current_step
|
| 101 |
+
)
|
| 102 |
+
return None
|
| 103 |
|
| 104 |
def get_progress(self, job_id: str) -> Optional[dict]:
|
| 105 |
+
with self._lock:
|
| 106 |
+
job = self._jobs.get(job_id)
|
| 107 |
+
if job is None:
|
| 108 |
+
return None
|
| 109 |
+
return {
|
| 110 |
+
"job_id": job.job_id,
|
| 111 |
+
"status": job.status.value,
|
| 112 |
+
"progress": round(job.progress, 1),
|
| 113 |
+
"message": job.message,
|
| 114 |
+
"current_step": job.current_step,
|
| 115 |
+
"total_steps": job.total_steps,
|
| 116 |
+
"has_result": job.result is not None,
|
| 117 |
+
"error": job.error
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
def remove_job_and_cleanup(self, job_id: str) -> Optional[str]:
|
| 121 |
+
"""Remove a job and return the result file path for deletion."""
|
| 122 |
+
with self._lock:
|
| 123 |
+
job = self._jobs.pop(job_id, None)
|
| 124 |
+
if job and job.result:
|
| 125 |
+
return str(job.result)
|
| 126 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
+
def _cleanup_old_jobs_locked(self):
|
| 129 |
+
"""Must be called with self._lock held."""
|
| 130 |
current_time = time.time()
|
| 131 |
expired_jobs = [
|
| 132 |
job_id for job_id, job in self._jobs.items()
|
| 133 |
if current_time - job.updated_at > self._cleanup_interval
|
| 134 |
and job.status in (JobStatus.COMPLETED, JobStatus.FAILED)
|
| 135 |
]
|
| 136 |
+
files_to_delete = []
|
| 137 |
for job_id in expired_jobs:
|
| 138 |
+
job = self._jobs.pop(job_id, None)
|
| 139 |
+
if job and job.result:
|
| 140 |
+
files_to_delete.append(str(job.result))
|
| 141 |
+
|
| 142 |
+
for file_path in files_to_delete:
|
| 143 |
+
try:
|
| 144 |
+
path = Path(file_path)
|
| 145 |
+
if path.exists():
|
| 146 |
+
path.unlink()
|
| 147 |
+
except Exception:
|
| 148 |
+
pass
|
| 149 |
|
| 150 |
def get_tracker() -> ProgressTracker:
|
| 151 |
return ProgressTracker()
|