HKAiR / main.py
William941008's picture
Update main.py
bcddcdd verified
Raw
History Blame Contribute Delete
24.9 kB
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
# =========================
# 启动事件
# =========================
@app.on_event("startup")
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}")
# =========================
# 首页
# =========================
@app.get("/")
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 接口
# =========================
@app.get("/mapillary/config")
async def mapillary_config():
return {
"enabled": bool(MAPILLARY_ACCESS_TOKEN),
"client_token": MAPILLARY_ACCESS_TOKEN
}
@app.get("/mapillary/nearby")
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]}
@app.get("/mapillary/query-image/{image_id}")
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)
# =========================
# 单图预测
# =========================
@app.post("/predict")
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)} 张")
# =========================
# 批量预测
# =========================
@app.post("/batch-predict")
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)}")
# =========================
# 批量预测进度推送
# =========================
@app.get("/progress/{request_id}")
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")
# =========================
# 健康检查
# =========================
@app.get("/health")
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)