algorithm_routes.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. """独立算法的 HTTP 适配。"""
  2. from typing import Literal
  3. from contextlib import asynccontextmanager
  4. from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
  5. from fastapi.responses import Response, JSONResponse, StreamingResponse
  6. from starlette.background import BackgroundTask
  7. from starlette.concurrency import run_in_threadpool
  8. import config
  9. from algorithms.characteristic_curve_recognition.batch import BatchManager, MAX_BATCH_FILES, MAX_BATCH_BYTES
  10. from algorithms.characteristic_curve_recognition.service import AlgorithmError, MAX_UPLOAD_BYTES, analyze_image
  11. @asynccontextmanager
  12. async def lifespan(app):
  13. manager = await run_in_threadpool(BatchManager, config.resolve_path(config.ALGORITHM_JOB_DIR))
  14. app.state.characteristic_batches = manager
  15. try:
  16. yield
  17. finally:
  18. await run_in_threadpool(manager.close)
  19. router = APIRouter(prefix="/algorithms", tags=["algorithms"], lifespan=lifespan)
  20. _BATCH_PATH = "/characteristic-curve-recognition/batches"
  21. async def _call(func, *args, **kwargs):
  22. try:
  23. return await run_in_threadpool(func, *args, **kwargs)
  24. except AlgorithmError as exc:
  25. raise HTTPException(status_code=exc.status_code,
  26. detail={"code": exc.code, "message": str(exc)},
  27. headers={"Retry-After": "5"} if exc.status_code == 429 else None) from exc
  28. def _links(job: dict) -> dict:
  29. base = f"/api/algorithms{_BATCH_PATH}/{job['id']}"
  30. job["status_url"] = base
  31. job["download_url"] = base + "/download"
  32. for item in job["items"]:
  33. item["result_url"] = f"{base}/items/{item['id']}/result" if item["status"] == "completed" else None
  34. return job
  35. @router.post("/characteristic-curve-recognition", response_model=None, summary="特性曲线识别",
  36. responses={200: {"content": {"application/json": {}, "application/zip": {}}}})
  37. async def characteristic_curve_recognition(
  38. file: UploadFile = File(..., description="PNG/JPEG,最多 20 MiB、1600 万像素"),
  39. detailed: bool = Query(False, description="生成各阶段检查图片,仅影响 ZIP 内可视化"),
  40. output: Literal["json", "zip"] = Query("json", description="JSON 结果或完整 ZIP 结果包"),
  41. ):
  42. try:
  43. content = await file.read(MAX_UPLOAD_BYTES + 1)
  44. finally:
  45. await file.close()
  46. result = await _call(analyze_image, content, detailed=detailed, output=output)
  47. if output == "zip":
  48. return Response(result, media_type="application/zip",
  49. headers={"Content-Disposition": 'attachment; filename="characteristic-curve-recognition.zip"'})
  50. return result
  51. @router.post(_BATCH_PATH, status_code=202, summary="提交特性曲线识别批量任务")
  52. async def submit_batch(request: Request, files: list[UploadFile] = File(..., description="1~20 张图片,合计不超过 100 MiB"),
  53. detailed: bool = Query(False)):
  54. uploads = []
  55. total = 0
  56. try:
  57. if not 1 <= len(files) <= MAX_BATCH_FILES:
  58. raise HTTPException(422, detail={"code": "invalid_batch_size", "message": "每批需上传 1~20 张图片"})
  59. for file in files:
  60. content = await file.read(MAX_UPLOAD_BYTES + 1)
  61. if len(content) > MAX_UPLOAD_BYTES:
  62. raise HTTPException(413, detail={"code": "image_too_large", "message": "单张图片不能超过 20 MiB"})
  63. total += len(content)
  64. if total > MAX_BATCH_BYTES:
  65. raise HTTPException(413, detail={"code": "batch_too_large", "message": "每批图片合计不能超过 100 MiB"})
  66. uploads.append((file.filename or "image", content))
  67. finally:
  68. for file in files:
  69. await file.close()
  70. job = _links(await _call(request.app.state.characteristic_batches.submit, uploads, detailed=detailed))
  71. return JSONResponse(job, status_code=202, headers={"Location": job["status_url"]})
  72. @router.get(_BATCH_PATH + "/{job_id}", summary="查询批量任务和各图片进度")
  73. async def batch_status(request: Request, job_id: str):
  74. return _links(await _call(request.app.state.characteristic_batches.status, job_id))
  75. @router.get(_BATCH_PATH + "/{job_id}/items/{item_id}/result", summary="读取单张图片的 JSON 结果")
  76. async def batch_item_result(request: Request, job_id: str, item_id: str):
  77. return await _call(request.app.state.characteristic_batches.result, job_id, item_id)
  78. @router.get(_BATCH_PATH + "/{job_id}/download", summary="下载批量结果 ZIP")
  79. async def batch_download(request: Request, job_id: str):
  80. archive = await _call(request.app.state.characteristic_batches.open_archive, job_id)
  81. def chunks():
  82. try:
  83. while chunk := archive.read(1024 * 1024):
  84. yield chunk
  85. finally:
  86. archive.close()
  87. return StreamingResponse(chunks(), media_type="application/zip", background=BackgroundTask(archive.close),
  88. headers={"Content-Disposition": f'attachment; filename="characteristic-curves-{job_id}.zip"'})