m9jaex commited on
Commit
115023b
·
1 Parent(s): d037fc6

Add progress tracking to all image processing endpoints

Browse files

Introduce 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

Files changed (2) hide show
  1. app.py +447 -14
  2. 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
- try:
272
- contents = await file.read()
 
 
 
 
 
 
 
 
 
 
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
- _lock = threading.Lock()
30
 
31
  def __new__(cls):
32
  if cls._instance is None:
33
- with cls._lock:
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._jobs[job_id] = Job(
43
- job_id=job_id,
44
- status=JobStatus.PENDING,
45
- message=message
46
- )
47
- self._cleanup_old_jobs()
 
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
- if job_id in self._jobs:
53
- job = self._jobs[job_id]
54
- job.progress = min(progress, 100.0)
55
- job.current_step = current_step
56
- job.total_steps = total_steps
57
- if message:
58
- job.message = message
59
- job.status = JobStatus.PROCESSING
60
- job.updated_at = time.time()
 
61
 
62
  def complete_job(self, job_id: str, result: Any = None, message: str = "Completed"):
63
- if job_id in self._jobs:
64
- job = self._jobs[job_id]
65
- job.status = JobStatus.COMPLETED
66
- job.progress = 100.0
67
- job.message = message
68
- job.result = result
69
- job.updated_at = time.time()
 
70
 
71
  def fail_job(self, job_id: str, error: str):
72
- if job_id in self._jobs:
73
- job = self._jobs[job_id]
74
- job.status = JobStatus.FAILED
75
- job.error = error
76
- job.message = f"Failed: {error}"
77
- job.updated_at = time.time()
 
78
 
79
  def get_job(self, job_id: str) -> Optional[Job]:
80
- return self._jobs.get(job_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  def get_progress(self, job_id: str) -> Optional[dict]:
83
- job = self.get_job(job_id)
84
- if job is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _cleanup_old_jobs(self):
 
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
- del self._jobs[job_id]
 
 
 
 
 
 
 
 
 
 
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()