Spaces:
Sleeping
Sleeping
File size: 14,120 Bytes
b866a78 3613e77 b866a78 3613e77 b866a78 ffe24e3 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 93d69ac b866a78 7211e3f d83423b 3613e77 ffe24e3 3613e77 b866a78 3613e77 7211e3f b866a78 3613e77 b866a78 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 b866a78 3613e77 b866a78 3613e77 eeeb56e 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 3613e77 eeeb56e 3613e77 eeeb56e b866a78 3613e77 b866a78 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 eeeb56e 3613e77 ffe24e3 6bc57e5 ffe24e3 3613e77 ffe24e3 3613e77 b866a78 3613e77 b866a78 3613e77 b866a78 ffe24e3 b866a78 3613e77 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 | #!/usr/bin/env python3
"""SetVL Node Graph Viewer —— 只读浏览器。
界面直接复用本地标注台(`tool/annotation`)那套前端:同一套 topographic 配色、
三栏布局、J/K 键盘导航、底图切换、邻接矩阵。区别只有一个——**去掉了全部写入**:
没有判定按钮、没有保存、没有新增边,后端也没有对应的接口。
架构:FastAPI 提供静态页面与只读 API,HF Spaces 用 docker SDK 跑。
曾经在 /gradio 挂过一个 Gradio Blocks 当兜底视图,已移除——它只是一段
带链接的 Markdown,却把 gradio 拖进了镜像依赖。
图片来源两种,按需切换:
- 本地包:SETVL_PACKAGE 指向 prepare.py 的产出目录
- HF 数据集:SETVL_DATASET 指向 "user/repo",按需从 CDN 取并缓存
"""
from __future__ import annotations
import json
import math
import os
import sqlite3
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
HERE = Path(__file__).resolve().parent
# 前端与本地复核台共用同一份(tool/web/)。打包进镜像时 prepare/deploy 会把
# 它复制到 /app/web,所以镜像里仍然是 HERE/"web";本机直接跑则回落到共享目录。
WEB = HERE / "web" if (HERE / "web").is_dir() else HERE.parent / "web"
PACKAGE = Path(os.environ.get("SETVL_PACKAGE", HERE / "package"))
DATASET = os.environ.get("SETVL_DATASET", "").strip()
HF_TOKEN = (os.environ.get("HF_TOKEN")
or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()
def _resolve_db() -> Path:
"""按优先级找库:显式环境变量 > 同目录(容器里就在 /app)> space/ > package/。"""
if os.environ.get("SETVL_DB"):
return Path(os.environ["SETVL_DB"])
for cand in (HERE / "graph.sqlite", HERE / "space" / "graph.sqlite",
PACKAGE / "graph.sqlite"):
if cand.exists():
return cand
return PACKAGE / "graph.sqlite" # 供报错信息指向默认位置
DB = _resolve_db()
if not DB.exists(): # 启动即失败,不要拖到浏览到一半才炸
raise RuntimeError(
f"找不到数据库。已尝试 {HERE/'graph.sqlite'}、{HERE/'space'/'graph.sqlite'}、"
f"{PACKAGE/'graph.sqlite'};可用 SETVL_DB 指定。")
_TLS = threading.local()
def con() -> sqlite3.Connection:
"""每线程一个只读连接。sqlite3 的连接不能跨线程共用。"""
c = getattr(_TLS, "con", None)
if c is None:
if not DB.exists():
raise RuntimeError(f"找不到数据库 {DB}")
c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
c.row_factory = sqlite3.Row
_TLS.con = c
return c
_HAS_HEADING: Optional[bool] = None
def ground_has_heading(c: sqlite3.Connection) -> bool:
"""旧包的 ground_index 只有 (panoid, rel),没有 heading 列。
重新 prepare 过的包才有。这里探一次并记住,免得每个节点都查一次
pragma;旧包降级成「只有一张街景」而不是 500。
"""
global _HAS_HEADING
if _HAS_HEADING is None:
cols = {r[1] for r in c.execute("PRAGMA table_info(ground_index)")}
_HAS_HEADING = "heading" in cols
return _HAS_HEADING
def asset_path(rel: str) -> Optional[Path]:
"""包内相对路径 -> 本地文件。数据集模式下按需从 CDN 拉取并缓存。"""
if not rel or ".." in rel:
return None
local = PACKAGE / rel
if local.is_file():
return local
if not DATASET:
return None
try:
from huggingface_hub import hf_hub_download
# 私有数据集必须带 token;Space 里要配 HF_TOKEN secret
return Path(hf_hub_download(DATASET, rel, repo_type="dataset",
token=HF_TOKEN or None))
except Exception:
return None
def _loads(raw: Optional[str]) -> Any:
if not raw:
return None
try:
return json.loads(raw)
except (ValueError, TypeError):
return None
def effective(nodes: List[Dict], edges: List[Dict]) -> None:
"""合成 _auto / _human 的生效值,与标注台一致。"""
for n in nodes:
n["corridor_id"] = n["corridor_id_human"] or n["corridor_id_auto"]
n["corridor_name"] = (n["corridor_name_human"] or n["corridor_name_auto"]
or n["corridor_id"])
o = n["is_orphan_human"]
n["is_orphan"] = int(o) if o is not None else (n["is_orphan_auto"] or 0)
n["corridor_overridden"] = n["corridor_id_human"] is not None
n["corridor_ids"] = _loads(n["corridor_ids_auto"]) or []
for e in edges:
e["origin"] = e["origin_human"] or e["origin_auto"] or "manual"
rc = e["road_connected_human"]
e["road_connected"] = int(rc) if rc is not None else (e["road_connected_auto"] or 0)
e["verdict"] = e["edge_verdict_human"]
e["in_graph"] = e["edge_verdict_human"] != "reject"
e["is_manual"] = e["origin_human"] == "manual" or e["origin_auto"] is None
e["origin_overridden"] = e["origin_human"] is not None
e["road_path"] = _loads(e["road_path_auto"])
e["evidence"] = _loads(e["evidence_auto"])
e["notes"] = _loads(e["notes_auto"]) or []
e["review_status"] = e["review_status_human"] or "unreviewed"
api = FastAPI(title="SetVL Node Graph Viewer")
@api.get("/")
def index() -> FileResponse:
return FileResponse(WEB / "index.html")
@api.get("/app.js")
def appjs() -> FileResponse:
return FileResponse(WEB / "app.js", media_type="application/javascript")
@api.get("/styles.css")
def css() -> FileResponse:
return FileResponse(WEB / "styles.css", media_type="text/css")
@api.get("/api/bootstrap")
def bootstrap() -> JSONResponse:
c = con()
cities = [r[0] for r in c.execute("SELECT DISTINCT city FROM scene ORDER BY city")]
return JSONResponse({
"cities": cities,
"stats": stats_payload(),
"dataset": DATASET or "local package",
"read_only": True,
})
def stats_payload() -> Dict[str, Any]:
c = con()
q = lambda s: c.execute(s).fetchone()[0]
origins = {r[0] or "unknown": r[1] for r in
c.execute("SELECT origin_auto, COUNT(*) FROM edge GROUP BY origin_auto")}
return {
"scenes": q("SELECT COUNT(*) FROM scene"),
"nodes": q("SELECT COUNT(*) FROM node"),
"edges": q("SELECT COUNT(*) FROM edge"),
"total_items": q("SELECT COUNT(*) FROM node") + q("SELECT COUNT(*) FROM edge"),
"origins": origins,
"noted_edges": q("SELECT COUNT(*) FROM edge WHERE notes_auto IS NOT NULL "
"AND notes_auto <> '[]'"),
"orphans": q("SELECT COUNT(*) FROM node WHERE is_orphan_auto=1"),
"policy": q("SELECT graph_version FROM scene WHERE graph_version IS NOT NULL "
"LIMIT 1") or "",
}
@api.get("/api/stats")
def stats() -> JSONResponse:
return JSONResponse(stats_payload())
@api.get("/api/scenes")
def scenes(city: str = "", min_nodes: Optional[int] = None,
orphan: str = "", noted: str = "", origin: str = "",
q: str = "", order: str = "scene_id",
limit: int = 60, offset: int = 0) -> JSONResponse:
where, args = [], []
if city:
where.append("s.city = ?")
args.append(city)
if min_nodes:
where.append("s.node_count >= ?")
args.append(int(min_nodes))
if q:
where.append("s.scene_id LIKE ?")
args.append(f"%{q}%")
if orphan in ("1", "true"):
where.append("EXISTS (SELECT 1 FROM node n WHERE n.scene_id=s.scene_id "
"AND n.is_orphan_auto=1)")
if noted in ("1", "true"):
where.append("EXISTS (SELECT 1 FROM edge e WHERE e.scene_id=s.scene_id "
"AND e.notes_auto IS NOT NULL AND e.notes_auto <> '[]')")
if origin:
where.append("EXISTS (SELECT 1 FROM edge e WHERE e.scene_id=s.scene_id "
"AND e.origin_auto = ?)")
args.append(origin)
clause = (" WHERE " + " AND ".join(where)) if where else ""
order_sql = {"scene_id": "s.scene_id",
"nodes_desc": "s.node_count DESC, s.scene_id",
"nodes_asc": "s.node_count ASC, s.scene_id"}.get(order, "s.scene_id")
limit = max(1, min(int(limit), 200))
rows = con().execute(
"SELECT s.scene_id, s.city, s.node_count, s.satellite_path, "
"s.graph_version, s.utm_zone_auto, "
"(SELECT COUNT(*) FROM edge e WHERE e.scene_id=s.scene_id) AS edge_count, "
"(SELECT COUNT(*) FROM node n WHERE n.scene_id=s.scene_id AND n.is_orphan_auto=1) "
" AS orphan_count, "
"(SELECT COUNT(*) FROM edge e WHERE e.scene_id=s.scene_id "
" AND e.notes_auto IS NOT NULL AND e.notes_auto <> '[]') AS noted_count, "
"0 AS revision_count "
f"FROM scene s{clause} ORDER BY {order_sql} LIMIT ? OFFSET ?",
args + [limit, max(0, int(offset))])
total = con().execute(f"SELECT COUNT(*) FROM scene s{clause}", args).fetchone()[0]
return JSONResponse({"items": [dict(r) for r in rows], "total": total,
"limit": limit, "offset": offset})
@api.get("/api/queue")
def queue(city: str = "", note: str = "", kind: str = "",
limit: int = 60, offset: int = 0) -> JSONResponse:
"""「带标记的」列表。
merged_policy_0915 没有分诊分层和置信度分数,所以这里不再按分数排序。
列出的是策略本身认为值得看一眼的:§6 的孤儿节点,和带非阻断标记的边
(§0 跨水/穿建筑、§4 绕远、无道路通路)。
"""
parts, args = [], []
limit = max(1, min(int(limit), 200))
for tbl in ("node", "edge"):
if kind and kind != tbl:
continue
key = "t.label" if tbl == "node" else "t.source_label || '-' || t.target_label"
sub: List[Any] = []
if tbl == "node":
if note and note != "orphan":
continue
cond = ["t.is_orphan_auto = 1"]
reason = ("json_array(json_object('code','orphan','snap_m',"
"t.snap_distance_m_auto))")
else:
if note == "orphan":
continue
cond = ["t.notes_auto IS NOT NULL", "t.notes_auto <> '[]'"]
reason = "t.notes_auto"
if note:
cond.append("EXISTS (SELECT 1 FROM json_each(t.notes_auto) je "
"WHERE json_extract(je.value,'$.code') = ?)")
sub.append(note)
if city:
cond.append("s.city = ?")
sub.append(city)
parts.append(
f"SELECT '{tbl}' AS kind, t.scene_id AS scene_id, s.city AS city, "
f"{key} AS item_key, {reason} AS notes_json, "
"'unreviewed' AS review_status "
f"FROM {tbl} t JOIN scene s ON s.scene_id=t.scene_id "
"WHERE " + " AND ".join(cond))
args += sub
if not parts:
return JSONResponse({"items": [], "limit": limit, "offset": offset})
sql = (" UNION ALL ".join(parts) +
" ORDER BY scene_id, kind, item_key LIMIT ? OFFSET ?")
items = []
for r in con().execute(sql, args + [limit, max(0, int(offset))]):
d = dict(r)
d["notes"] = _loads(d.pop("notes_json")) or []
items.append(d)
return JSONResponse({"items": items, "limit": limit, "offset": offset})
@api.get("/api/scene/{scene_id:path}")
def scene(scene_id: str) -> JSONResponse:
c = con()
s = c.execute("SELECT * FROM scene WHERE scene_id=?", (scene_id,)).fetchone()
if s is None:
raise HTTPException(404, f"无此场景 {scene_id}")
nodes = [dict(r) for r in c.execute(
"SELECT * FROM node WHERE scene_id=? ORDER BY label", (scene_id,))]
edges = [dict(r) for r in c.execute(
"SELECT * FROM edge WHERE scene_id=? ORDER BY source_label, target_label",
(scene_id,))]
effective(nodes, edges)
# 街景路径:打包时建的索引,运行时猜不出文件名(含 alt/year/heading)。
# 每个 pano 有 4 个朝向,全部返回;按 heading 排序让前端的顺序稳定。
sql = ("SELECT rel, heading FROM ground_index WHERE panoid=?"
if ground_has_heading(c) else
"SELECT rel, NULL FROM ground_index WHERE panoid=?")
def order(row) -> tuple:
# 新包里 prepare.py 存的已经是修正过的视线方向([0,360));旧包存的是
# 文件名原值,带负角且差 180。这里的归一化只保证排序稳定,**修不了旧包
# 的 180 度错误**——旧包必须用 prepare.py 重新打过才是对的。
h = row[1]
return (1, 0.0, row[0]) if h is None else (0, (float(h) % 360 + 360) % 360, row[0])
for n in nodes:
rows = sorted(c.execute(sql, (n["panoid"],)).fetchall(), key=order)
n["ground_assets"] = [r[0] for r in rows]
n["ground_headings"] = [r[1] for r in rows]
n["ground_asset"] = rows[0][0] if rows else None
return JSONResponse({"scene": dict(s), "nodes": nodes, "edges": edges,
"asset": {"satellite": dict(s)["satellite_path"]}})
@api.get("/api/revisions")
def revisions(table: str = "", key: str = "", limit: int = 50) -> JSONResponse:
# 只读包不带修订历史,返回空让前端正常渲染
return JSONResponse({"items": []})
@api.get("/asset")
def serve_asset(path: str = Query(...)) -> FileResponse:
p = asset_path(path)
if p is None:
raise HTTPException(404, "取不到该图片")
return FileResponse(p)
app = api
if __name__ == "__main__":
import argparse
import uvicorn
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=7860)
ap.add_argument("--host", default="0.0.0.0")
a = ap.parse_args()
uvicorn.run(app, host=a.host, port=a.port, log_level="info")
|