diff --git a/CLAUDE.md b/CLAUDE.md index 5e3fa49..3710241 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -440,11 +440,19 @@ uv run python cli.py serve --port 8080 ## HTTP 服务 (server.py,可选) +纯推理服务,不依赖 torch / 训练代码,仅需 onnxruntime + FastAPI。 + ```python -# FastAPI 服务,提供 REST API -# POST /solve - 上传图片,返回识别结果 -# 请求: multipart/form-data,字段名 image -# 响应: {"type": "normal", "result": "A3B8", "confidence": 0.95, "time_ms": 45} +# POST /solve - JSON base64 图片识别 +# 请求: {"image": "", "type": "normal"} (type 可选) +# 响应: {"type": "normal", "result": "A3B8", "raw": "A3B8", "time_ms": 12.3} +# +# POST /solve/upload - multipart 文件上传识别 +# 请求: multipart/form-data, 字段名 image, 可选 query param type +# 响应: 同上 +# +# GET /health - 健康检查 +# 响应: {"status": "ok", "models_loaded": true} ``` ## 关键约束和注意事项 diff --git a/server.py b/server.py index 03b5270..f2e57f0 100644 --- a/server.py +++ b/server.py @@ -1,21 +1,30 @@ """ -FastAPI HTTP 推理服务 +FastAPI HTTP 推理服务 (纯推理,不依赖 torch/训练代码) -提供 REST API 识别验证码: - POST /solve - 上传图片,返回识别结果 - GET /health - 健康检查 +仅依赖: fastapi, uvicorn, python-multipart, onnxruntime, pillow, numpy -启动方式: - uv run python cli.py serve --port 8080 +API: + POST /solve JSON base64 图片识别 + POST /solve/upload multipart 文件上传识别 + GET /health 健康检查 -请求示例: - curl -X POST http://localhost:8080/solve -F "image=@captcha.png" +启动: + uv sync --extra server + python cli.py serve --port 8080 -响应示例: - {"type": "normal", "result": "A3B8", "confidence": 0.95, "time_ms": 45} +请求示例 (base64): + curl -X POST http://localhost:8080/solve \ + -H "Content-Type: application/json" \ + -d '{"image": "", "type": "normal"}' + +请求示例 (文件上传): + curl -X POST http://localhost:8080/solve/upload -F "image=@captcha.png" + +响应: + {"type": "normal", "result": "A3B8", "raw": "A3B8", "time_ms": 12.3} """ -from __future__ import annotations +import base64 def create_app(): @@ -23,10 +32,12 @@ def create_app(): 创建 FastAPI 应用实例(工厂函数)。 cli.py 的 cmd_serve 依赖此签名。 - 需要安装 server 可选依赖: uv sync --extra server """ + from typing import Optional + from fastapi import FastAPI, File, Query, UploadFile from fastapi.responses import JSONResponse + from pydantic import BaseModel from inference.pipeline import CaptchaPipeline @@ -36,47 +47,38 @@ def create_app(): version="0.1.0", ) - pipeline: CaptchaPipeline | None = None + pipeline: Optional[CaptchaPipeline] = None + # ---- 启动时加载模型 ---- @app.on_event("startup") def _load_models(): nonlocal pipeline try: pipeline = CaptchaPipeline() except FileNotFoundError: - # 模型未导出时允许启动,但 /solve 会返回 503 pipeline = None - @app.get("/health") - def health(): - models_loaded = pipeline is not None - return {"status": "ok" if models_loaded else "no_models", "models_loaded": models_loaded} + # ---- 请求体定义 ---- + class SolveRequest(BaseModel): + image: str # base64 编码的图片 + type: Optional[str] = None # 指定类型可跳过分类 - @app.post("/solve") - async def solve( - image: UploadFile = File(...), - type: str | None = Query(None, description="指定类型跳过分类"), - ): + # ---- 通用推理逻辑 ---- + def _solve(image_bytes: bytes, captcha_type: Optional[str]) -> dict: if pipeline is None: return JSONResponse( status_code=503, content={"error": "模型未加载,请先训练并导出 ONNX 模型"}, ) - - data = await image.read() - if not data: - return JSONResponse( - status_code=400, - content={"error": "空文件"}, - ) + if not image_bytes: + return JSONResponse(status_code=400, content={"error": "空图片"}) try: - result = pipeline.solve(data, captcha_type=type) - except RuntimeError as e: - return JSONResponse( - status_code=400, - content={"error": str(e)}, - ) + result = pipeline.solve(image_bytes, captcha_type=captcha_type) + except (RuntimeError, TypeError) as e: + return JSONResponse(status_code=400, content={"error": str(e)}) + except Exception as e: + return JSONResponse(status_code=400, content={"error": f"图片解析失败: {e}"}) return { "type": result["type"], @@ -85,4 +87,34 @@ def create_app(): "time_ms": result["time_ms"], } + # ---- 路由 ---- + @app.get("/health") + def health(): + models_loaded = pipeline is not None + return { + "status": "ok" if models_loaded else "no_models", + "models_loaded": models_loaded, + } + + @app.post("/solve") + async def solve_base64(req: SolveRequest): + """JSON 请求,图片通过 base64 传输。""" + try: + image_bytes = base64.b64decode(req.image) + except Exception: + return JSONResponse( + status_code=400, + content={"error": "base64 解码失败"}, + ) + return _solve(image_bytes, req.type) + + @app.post("/solve/upload") + async def solve_upload( + image: UploadFile = File(...), + type: Optional[str] = Query(None, description="指定类型跳过分类"), + ): + """multipart 文件上传。""" + data = await image.read() + return _solve(data, type) + return app