Spaces:
Running
Running
| import os | |
| import io | |
| import math | |
| import uuid | |
| import shutil | |
| import asyncio | |
| import threading | |
| from datetime import datetime, timedelta | |
| from functools import partial | |
| from pathlib import Path | |
| from typing import List, Dict, Any, Optional | |
| import cv2 | |
| import numpy as np | |
| import torch | |
| import uvicorn | |
| import httpx | |
| from huggingface_hub import hf_hub_download | |
| from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, StreamingResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from MyModel import PollutionDifferenceModel | |
| from my_Segmenter import Segmenter | |
| import json | |
| from collections import defaultdict | |
| # ========================= | |
| # 全局进度存储 | |
| # ========================= | |
| batch_progress = defaultdict(dict) | |
| progress_lock = threading.Lock() | |
| # ========================= | |
| # 基础目录初始化 | |
| # ========================= | |
| BASE_DIR = Path(".") | |
| STATIC_DIR = BASE_DIR / "static" | |
| STATIC_RESULTS_DIR = STATIC_DIR / "results" | |
| RUNS_DIR = BASE_DIR / "runs" | |
| AIR_STATION_DIR = BASE_DIR / "AirStationImage" | |
| AIR_POLLUTION_MAPPING_DIR = BASE_DIR / "air pollution mapping" | |
| FRONTEND_DIR = BASE_DIR / "frontend" | |
| MAPILLARY_ACCESS_TOKEN = os.getenv("MAPILLARY_ACCESS_TOKEN", "").strip() | |
| MAPILLARY_API = "https://graph.mapillary.com" | |
| for d in [STATIC_DIR, STATIC_RESULTS_DIR, RUNS_DIR, AIR_STATION_DIR, AIR_POLLUTION_MAPPING_DIR]: | |
| d.mkdir(parents=True, exist_ok=True) | |
| # ========================= | |
| # Hugging Face private model repo 配置 | |
| # ========================= | |
| HF_MODEL_REPO = os.getenv("HF_MODEL_REPO", "").strip() | |
| HF_TOKEN = os.getenv("HF_TOKEN", "").strip() or None | |
| MODEL_FILENAMES = { | |
| "CO": "best_CO_model_multiscale20251110.pth", | |
| "NO2": "best_NO2_model_multiscale20251110.pth", | |
| "PM25": "best_PM25_model_multiscale20251110.pth", | |
| "PM10": "best_PM10_model_multiscale20251110.pth", | |
| "O3": "best_O3_model_multiscale20251110.pth", | |
| } | |
| loaded_models: Dict[str, PollutionDifferenceModel] = {} | |
| model_lock = threading.Lock() | |
| # ========================= | |
| # 文件上传限制 | |
| # ========================= | |
| MAX_FILE_SIZE = 10 * 1024 * 1024 | |
| ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"} | |
| # ========================= | |
| # 污染物合理范围校验 | |
| # ========================= | |
| POLLUTANT_RANGES = { | |
| "CO": (0, 50), | |
| "NO2": (0, 500), | |
| "PM25": (0, 999), | |
| "PM10": (0, 999), | |
| "O3": (0, 500), | |
| } | |
| # ========================= | |
| # 初始化分割模型 | |
| # ========================= | |
| segmenter = Segmenter(dataset="cityscapes", task="semantic", device="cpu") | |
| # ========================= | |
| # FastAPI 初始化 | |
| # ========================= | |
| app = FastAPI(title="香港空气污染预测") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=False, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") | |
| app.mount("/runs", StaticFiles(directory=str(RUNS_DIR)), name="runs") | |
| app.mount("/AirStationImage", StaticFiles(directory=str(AIR_STATION_DIR)), name="AirStationImage") | |
| app.mount( | |
| "/air-pollution-mapping", | |
| StaticFiles(directory=str(AIR_POLLUTION_MAPPING_DIR)), | |
| name="air-pollution-mapping" | |
| ) | |
| def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: | |
| radius = 6_371_000.0 | |
| p1, p2 = math.radians(lat1), math.radians(lat2) | |
| dp = math.radians(lat2 - lat1) | |
| dl = math.radians(lon2 - lon1) | |
| value = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 | |
| return 2 * radius * math.asin(math.sqrt(value)) | |
| def panorama_view( | |
| image: np.ndarray, | |
| center_x: float, | |
| center_y: float, | |
| fov_deg: float = 90.0, | |
| width: int = 768, | |
| height: int = 512 | |
| ) -> np.ndarray: | |
| focal = width / (2 * math.tan(math.radians(fov_deg) / 2)) | |
| px, py = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32)) | |
| x = (px - (width - 1) / 2) / focal | |
| y = -((py - (height - 1) / 2) / focal) | |
| z = np.ones_like(x) | |
| norm = np.sqrt(x * x + y * y + z * z) | |
| x, y, z = x / norm, y / norm, z / norm | |
| yaw = math.radians((center_x - 0.5) * 360.0) | |
| pitch = math.radians(max(-75.0, min(75.0, (0.5 - center_y) * 180.0))) | |
| cp, sp = math.cos(pitch), math.sin(pitch) | |
| y2, z2 = y * cp + z * sp, -y * sp + z * cp | |
| cy, sy = math.cos(yaw), math.sin(yaw) | |
| x3, z3 = x * cy + z2 * sy, -x * sy + z2 * cy | |
| longitude = np.arctan2(x3, z3) | |
| latitude = np.arcsin(np.clip(y2, -1, 1)) | |
| source_h, source_w = image.shape[:2] | |
| map_x = ((longitude / (2 * np.pi) + 0.5) * source_w).astype(np.float32) | |
| map_y = np.clip((0.5 - latitude / np.pi) * source_h, 0, source_h - 1).astype(np.float32) | |
| return cv2.remap(image, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_WRAP) | |
| async def mapillary_get(path: str, params: Dict[str, Any]) -> Dict[str, Any]: | |
| if not MAPILLARY_ACCESS_TOKEN: | |
| raise HTTPException(status_code=503, detail="Mapillary 尚未設定。請配置 MAPILLARY_ACCESS_TOKEN。") | |
| request_params = {**params, "access_token": MAPILLARY_ACCESS_TOKEN} | |
| try: | |
| async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client: | |
| response = await client.get(f"{MAPILLARY_API}/{path.lstrip('/')}", params=request_params) | |
| response.raise_for_status() | |
| return response.json() | |
| except httpx.HTTPStatusError as exc: | |
| raise HTTPException( | |
| status_code=502, | |
| detail=f"Mapillary API 回應錯誤(HTTP {exc.response.status_code})。" | |
| ) from exc | |
| except (httpx.HTTPError, ValueError) as exc: | |
| raise HTTPException(status_code=502, detail="暫時無法連接 Mapillary,請稍後再試。") from exc | |
| def get_model_path_from_hf(pollutant: str) -> str: | |
| if pollutant not in MODEL_FILENAMES: | |
| raise HTTPException(status_code=400, detail=f"不支持的污染物类型: {pollutant}") | |
| if not HF_MODEL_REPO: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="未设置 HF_MODEL_REPO。请在 Hugging Face Space 的 Secrets 中配置 HF_MODEL_REPO。" | |
| ) | |
| filename = MODEL_FILENAMES[pollutant] | |
| try: | |
| return hf_hub_download( | |
| repo_id=HF_MODEL_REPO, | |
| filename=filename, | |
| repo_type="model", | |
| token=HF_TOKEN, | |
| ) | |
| except Exception as e: | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"无法从 Hugging Face 下载模型文件 {filename}: {str(e)}" | |
| ) | |
| def load_pollution_model(pollutant: str) -> PollutionDifferenceModel: | |
| if pollutant not in MODEL_FILENAMES: | |
| raise HTTPException(status_code=400, detail=f"不支持的污染物类型: {pollutant}") | |
| if pollutant in loaded_models: | |
| return loaded_models[pollutant] | |
| with model_lock: | |
| if pollutant not in loaded_models: | |
| model_path = get_model_path_from_hf(pollutant) | |
| try: | |
| checkpoint = torch.load( | |
| model_path, | |
| map_location="cpu", | |
| weights_only=True | |
| ) | |
| except TypeError: | |
| checkpoint = torch.load( | |
| model_path, | |
| map_location="cpu" | |
| ) | |
| model = PollutionDifferenceModel(num_classes=19, pollution_dims=1) | |
| if isinstance(checkpoint, dict) and "model" in checkpoint: | |
| model.load_state_dict(checkpoint["model"]) | |
| elif isinstance(checkpoint, dict) and "state_dict" in checkpoint: | |
| model.load_state_dict(checkpoint["state_dict"]) | |
| else: | |
| model.load_state_dict(checkpoint) | |
| model.eval() | |
| torch.set_grad_enabled(False) | |
| loaded_models[pollutant] = model | |
| print(f"✅ 已加载 {pollutant} 模型: {model_path}") | |
| return loaded_models[pollutant] | |
| def create_request_dirs() -> Dict[str, Any]: | |
| request_id = uuid.uuid4().hex | |
| base_dir = RUNS_DIR / request_id | |
| input_dir = base_dir / "input" | |
| output_dir = base_dir / "output" | |
| summary_dir = base_dir / "summary" | |
| for d in [input_dir, output_dir, summary_dir]: | |
| d.mkdir(parents=True, exist_ok=True) | |
| return { | |
| "request_id": request_id, | |
| "base_dir": base_dir, | |
| "input_dir": input_dir, | |
| "output_dir": output_dir, | |
| "summary_dir": summary_dir, | |
| } | |
| async def save_upload_file(upload_file: UploadFile, save_path: Path) -> None: | |
| content = await upload_file.read() | |
| if not content: | |
| raise HTTPException(status_code=400, detail=f"上传文件为空: {upload_file.filename}") | |
| if len(content) > MAX_FILE_SIZE: | |
| raise HTTPException(status_code=413, detail=f"文件过大(最大 10MB): {upload_file.filename}") | |
| if upload_file.content_type not in ALLOWED_CONTENT_TYPES: | |
| raise HTTPException( | |
| status_code=415, | |
| detail=f"不支持的文件类型 '{upload_file.content_type}',仅支持 JPEG / PNG / WebP" | |
| ) | |
| save_path.write_bytes(content) | |
| def preprocess_image(img_np: np.ndarray) -> torch.Tensor: | |
| img = cv2.resize(img_np, (256, 256)) | |
| img = img.astype(np.float32) / 255.0 | |
| img = img.transpose(2, 0, 1) | |
| return torch.from_numpy(img).unsqueeze(0) | |
| def read_rgb_image(path: Path) -> np.ndarray: | |
| img = cv2.imread(str(path)) | |
| if img is None: | |
| raise HTTPException(status_code=400, detail=f"无法读取图像: {path.name}") | |
| return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) | |
| async def run_segmentation_async(input_dir: Path, output_dir: Path, summary_dir: Path) -> None: | |
| loop = asyncio.get_event_loop() | |
| await loop.run_in_executor( | |
| None, | |
| partial( | |
| segmenter.segment, | |
| dir_input=str(input_dir), | |
| dir_image_output=str(output_dir), | |
| dir_summary_output=str(summary_dir) | |
| ) | |
| ) | |
| def find_segmented_img(output_dir: Path, base_name: str) -> Optional[Path]: | |
| candidates = sorted([ | |
| f for f in output_dir.iterdir() | |
| if base_name in f.name and "colored_segmented" in f.name | |
| ]) | |
| return candidates[0] if candidates else None | |
| def find_blend_img(output_dir: Path, base_name: str) -> Optional[Path]: | |
| candidates = sorted([ | |
| f for f in output_dir.iterdir() | |
| if base_name in f.name and "blend" in f.name | |
| ]) | |
| return candidates[0] if candidates else None | |
| def copy_segmentation_outputs(output_dir: Path, request_id: str) -> Dict[str, str]: | |
| ref_seg_path = find_segmented_img(output_dir, "ref") | |
| query_seg_path = find_segmented_img(output_dir, "query") | |
| ref_blend_path = find_blend_img(output_dir, "ref") | |
| query_blend_path = find_blend_img(output_dir, "query") | |
| if not ref_seg_path or not query_seg_path: | |
| raise HTTPException(status_code=500, detail="找不到分割结果图像") | |
| target_ref = STATIC_RESULTS_DIR / f"{request_id}_ref_seg.png" | |
| target_query = STATIC_RESULTS_DIR / f"{request_id}_query_seg.png" | |
| target_ref_blend = STATIC_RESULTS_DIR / f"{request_id}_ref_blend.png" | |
| target_query_blend = STATIC_RESULTS_DIR / f"{request_id}_query_blend.png" | |
| shutil.copy(ref_seg_path, target_ref) | |
| shutil.copy(query_seg_path, target_query) | |
| if ref_blend_path and ref_blend_path.exists(): | |
| shutil.copy(ref_blend_path, target_ref_blend) | |
| if query_blend_path and query_blend_path.exists(): | |
| shutil.copy(query_blend_path, target_query_blend) | |
| return { | |
| "ref_seg": f"/static/results/{request_id}_ref_seg.png", | |
| "query_seg": f"/static/results/{request_id}_query_seg.png", | |
| "ref_blend": f"/static/results/{request_id}_ref_blend.png" if target_ref_blend.exists() else "", | |
| "query_blend": f"/static/results/{request_id}_query_blend.png" if target_query_blend.exists() else "", | |
| } | |
| def infer_difference( | |
| model: PollutionDifferenceModel, | |
| ref_tensor: torch.Tensor, | |
| query_tensor: torch.Tensor | |
| ) -> float: | |
| with torch.no_grad(): | |
| out = model(ref_tensor, query_tensor) | |
| return float(out.item()) | |
| def validate_ref_data(pollutant: str, ref_data: float) -> None: | |
| if pollutant not in POLLUTANT_RANGES: | |
| raise HTTPException(status_code=400, detail=f"不支持的污染物: {pollutant}") | |
| lo, hi = POLLUTANT_RANGES[pollutant] | |
| if ref_data < lo: | |
| raise HTTPException(status_code=422, detail=f"{pollutant} 参考值不能为负数") | |
| if ref_data > hi: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"{pollutant} 参考值 {ref_data} 超出合理范围(最大 {hi})" | |
| ) | |
| async def cleanup_old_runs(max_age_hours: int = 24) -> None: | |
| cutoff = datetime.now() - timedelta(hours=max_age_hours) | |
| if not RUNS_DIR.exists(): | |
| return | |
| for run_dir in RUNS_DIR.iterdir(): | |
| if run_dir.is_dir(): | |
| try: | |
| mtime = datetime.fromtimestamp(run_dir.stat().st_mtime) | |
| if mtime < cutoff: | |
| shutil.rmtree(run_dir, ignore_errors=True) | |
| except Exception: | |
| pass | |
| # ========================= | |
| # 启动事件 | |
| # ========================= | |
| async def startup_event(): | |
| await cleanup_old_runs() | |
| try: | |
| for pollutant in MODEL_FILENAMES.keys(): | |
| load_pollution_model(pollutant) | |
| print("✅ 所有污染预测模型预加载完成") | |
| except Exception as e: | |
| print(f"⚠️ 模型预加载失败: {e}") | |
| # ========================= | |
| # 首页 | |
| # ========================= | |
| async def read_index(): | |
| index_path = FRONTEND_DIR / "index.html" | |
| if not index_path.exists(): | |
| raise HTTPException(status_code=500, detail="frontend/index.html not found") | |
| return FileResponse(str(index_path)) | |
| # ========================= | |
| # Mapillary 接口 | |
| # ========================= | |
| async def mapillary_config(): | |
| return { | |
| "enabled": bool(MAPILLARY_ACCESS_TOKEN), | |
| "client_token": MAPILLARY_ACCESS_TOKEN | |
| } | |
| async def mapillary_nearby( | |
| lat: float = Query(..., ge=-90, le=90), | |
| lng: float = Query(..., ge=-180, le=180), | |
| radius_m: int = Query(250, ge=50, le=1500), | |
| ): | |
| lat_delta = radius_m / 111_320.0 | |
| lng_delta = radius_m / max(111_320.0 * math.cos(math.radians(lat)), 1.0) | |
| bbox = f"{lng-lng_delta},{lat-lat_delta},{lng+lng_delta},{lat+lat_delta}" | |
| fields = "id,computed_geometry,thumb_1024_url,thumb_2048_url,is_pano,captured_at,creator" | |
| payload = await mapillary_get("images", {"fields": fields, "bbox": bbox, "limit": 60}) | |
| images = [] | |
| for item in payload.get("data", []): | |
| coordinates = (item.get("computed_geometry") or {}).get("coordinates") or [] | |
| if len(coordinates) < 2: | |
| continue | |
| distance = haversine_m(lat, lng, float(coordinates[1]), float(coordinates[0])) | |
| images.append({ | |
| "id": str(item.get("id", "")), | |
| "lat": coordinates[1], | |
| "lng": coordinates[0], | |
| "distance_m": round(distance), | |
| "is_pano": bool(item.get("is_pano")), | |
| "captured_at": item.get("captured_at"), | |
| "creator": item.get("creator"), | |
| "thumbnail": item.get("thumb_1024_url") or item.get("thumb_2048_url"), | |
| }) | |
| images.sort(key=lambda item: (not item["is_pano"], item["distance_m"])) | |
| return {"images": images[:12]} | |
| async def mapillary_query_image( | |
| image_id: str, | |
| center_x: float = Query(0.5, ge=0, le=1), | |
| center_y: float = Query(0.5, ge=0, le=1), | |
| bearing: Optional[float] = Query(None), | |
| ): | |
| if not image_id.isdigit(): | |
| raise HTTPException(status_code=400, detail="無效的 Mapillary 影像編號。") | |
| metadata = await mapillary_get( | |
| image_id, | |
| { | |
| "fields": "id,thumb_2048_url,thumb_original_url,is_pano,computed_compass_angle,captured_at,creator" | |
| } | |
| ) | |
| image_url = metadata.get("thumb_2048_url") or metadata.get("thumb_original_url") | |
| if not image_url: | |
| raise HTTPException(status_code=404, detail="Mapillary 沒有提供這張影像。") | |
| try: | |
| async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: | |
| response = await client.get(image_url) | |
| response.raise_for_status() | |
| encoded = np.frombuffer(response.content, dtype=np.uint8) | |
| image = cv2.imdecode(encoded, cv2.IMREAD_COLOR) | |
| if image is None: | |
| raise ValueError("invalid image") | |
| if metadata.get("is_pano"): | |
| if bearing is not None and metadata.get("computed_compass_angle") is not None: | |
| relative_bearing = (bearing - float(metadata["computed_compass_angle"]) + 540) % 360 - 180 | |
| center_x = (0.5 + relative_bearing / 360.0) % 1.0 | |
| image = panorama_view(image, center_x, center_y) | |
| ok, output = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 92]) | |
| if not ok: | |
| raise ValueError("encode failed") | |
| except (httpx.HTTPError, ValueError) as exc: | |
| raise HTTPException(status_code=502, detail="無法準備這張 Mapillary 街景。") from exc | |
| headers = { | |
| "Content-Disposition": f'inline; filename="mapillary-{image_id}.jpg"', | |
| "X-Mapillary-Image-Id": image_id, | |
| } | |
| return StreamingResponse(io.BytesIO(output.tobytes()), media_type="image/jpeg", headers=headers) | |
| # ========================= | |
| # 单图预测 | |
| # ========================= | |
| async def predict( | |
| pollutant: str = Form(...), | |
| ref_data: float = Form(...), | |
| ref_img: UploadFile = File(...), | |
| query_img: UploadFile = File(...) | |
| ): | |
| try: | |
| validate_ref_data(pollutant, ref_data) | |
| paths = create_request_dirs() | |
| request_id = paths["request_id"] | |
| input_dir = paths["input_dir"] | |
| output_dir = paths["output_dir"] | |
| summary_dir = paths["summary_dir"] | |
| ref_path = input_dir / "ref.jpg" | |
| query_path = input_dir / "query.jpg" | |
| await save_upload_file(ref_img, ref_path) | |
| await save_upload_file(query_img, query_path) | |
| await run_segmentation_async(input_dir, output_dir, summary_dir) | |
| seg_urls = copy_segmentation_outputs(output_dir, request_id) | |
| ref_tensor = preprocess_image(read_rgb_image(ref_path)) | |
| query_tensor = preprocess_image(read_rgb_image(query_path)) | |
| model = load_pollution_model(pollutant) | |
| model_out = infer_difference(model, ref_tensor, query_tensor) | |
| final_pred = ref_data - model_out | |
| ratio_json_path = summary_dir / "pixel_ratios.json" | |
| if not ratio_json_path.exists(): | |
| raise HTTPException(status_code=500, detail="分割后未找到 pixel_ratios.json") | |
| return { | |
| "status": "ok", | |
| "request_id": request_id, | |
| "pollutant": pollutant, | |
| "ref_data": ref_data, | |
| "model_out": round(model_out, 4), | |
| "pred_value": round(final_pred, 4), | |
| "ref_seg": seg_urls["ref_seg"], | |
| "query_seg": seg_urls["query_seg"], | |
| "ref_blend": seg_urls["ref_blend"], | |
| "query_blend": seg_urls["query_blend"], | |
| "ratio_json": f"/runs/{request_id}/summary/pixel_ratios.json" | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"预测失败: {str(e)}") | |
| # ========================= | |
| # 批量预测后台任务 | |
| # ========================= | |
| async def batch_predict_task( | |
| request_id: str, | |
| pollutant: str, | |
| ref_data: float, | |
| ref_tensor: torch.Tensor, | |
| model: PollutionDifferenceModel, | |
| query_file_paths: list, | |
| batch_input_dir: Path | |
| ): | |
| results = [] | |
| failed = [] | |
| total = len(query_file_paths) | |
| with progress_lock: | |
| batch_progress[request_id] = { | |
| "total": total, | |
| "current": 0, | |
| "results": [], | |
| "failed": [], | |
| "status": "processing" | |
| } | |
| with torch.no_grad(): | |
| for idx, file_info in enumerate(query_file_paths): | |
| safe_name = file_info["name"] | |
| query_path = Path(file_info["path"]) | |
| try: | |
| query_np = read_rgb_image(query_path) | |
| query_tensor = preprocess_image(query_np) | |
| out = model(ref_tensor, query_tensor) | |
| model_out = float(out.item()) | |
| final_pred = ref_data + model_out | |
| results.append({ | |
| "filename": safe_name, | |
| "status": "ok", | |
| "pred_value": round(final_pred, 4), | |
| "model_out": round(model_out, 4), | |
| }) | |
| except Exception as e: | |
| error_msg = f"文件:{safe_name},错误:{str(e)}" | |
| print(f"【批量预测失败】{error_msg}") | |
| failed.append({ | |
| "filename": safe_name, | |
| "status": "error", | |
| "message": str(e) | |
| }) | |
| with progress_lock: | |
| batch_progress[request_id]["current"] = idx + 1 | |
| batch_progress[request_id]["results"] = results | |
| batch_progress[request_id]["failed"] = failed | |
| await asyncio.sleep(0.05) | |
| with progress_lock: | |
| batch_progress[request_id]["status"] = "completed" | |
| print(f"【批量任务完成】{request_id} | 成功:{len(results)} 张,失败:{len(failed)} 张") | |
| # ========================= | |
| # 批量预测 | |
| # ========================= | |
| async def batch_predict( | |
| pollutant: str = Form(...), | |
| ref_data: float = Form(...), | |
| ref_img: UploadFile = File(...), | |
| query_files: List[UploadFile] = File(...) | |
| ): | |
| try: | |
| validate_ref_data(pollutant, ref_data) | |
| if not query_files: | |
| raise HTTPException(status_code=400, detail="未上传任何查询图像") | |
| paths = create_request_dirs() | |
| request_id = paths["request_id"] | |
| batch_input_dir = paths["input_dir"] | |
| ref_path = batch_input_dir / "ref.jpg" | |
| await save_upload_file(ref_img, ref_path) | |
| ref_tensor = preprocess_image(read_rgb_image(ref_path)) | |
| model = load_pollution_model(pollutant) | |
| query_file_paths = [] | |
| for file in query_files: | |
| safe_name = os.path.basename(file.filename) if file.filename else f"{uuid.uuid4().hex}.jpg" | |
| query_path = batch_input_dir / safe_name | |
| await save_upload_file(file, query_path) | |
| query_file_paths.append({ | |
| "path": str(query_path), | |
| "name": safe_name | |
| }) | |
| asyncio.create_task( | |
| batch_predict_task( | |
| request_id=request_id, | |
| pollutant=pollutant, | |
| ref_data=ref_data, | |
| ref_tensor=ref_tensor, | |
| model=model, | |
| query_file_paths=query_file_paths, | |
| batch_input_dir=batch_input_dir | |
| ) | |
| ) | |
| return { | |
| "status": "processing", | |
| "request_id": request_id, | |
| "total_files": len(query_files) | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"批量预测启动失败: {str(e)}") | |
| # ========================= | |
| # 批量预测进度推送 | |
| # ========================= | |
| async def get_batch_progress(request_id: str): | |
| async def event_generator(): | |
| while True: | |
| progress = batch_progress.get(request_id, {}) | |
| if not progress: | |
| yield 'data: {"error": "任务不存在"}\n\n' | |
| break | |
| progress_data = { | |
| "total": progress.get("total", 0), | |
| "current": progress.get("current", 0), | |
| "status": progress.get("status", "processing"), | |
| "results": progress.get("results", []), | |
| "failed": progress.get("failed", []) | |
| } | |
| yield f"data: {json.dumps(progress_data, ensure_ascii=False)}\n\n" | |
| if progress.get("status") in ["completed", "failed"]: | |
| break | |
| await asyncio.sleep(0.1) | |
| return StreamingResponse(event_generator(), media_type="text/event-stream") | |
| # ========================= | |
| # 健康检查 | |
| # ========================= | |
| async def health_check(): | |
| return {"status": "ok"} | |
| # ========================= | |
| # 启动 | |
| # ========================= | |
| if __name__ == "__main__": | |
| port = int(os.getenv("PORT", "7860")) | |
| uvicorn.run(app, host="0.0.0.0", port=port) |