Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- README.md +4 -1
- app.py +36 -28
- graph.sqlite +3 -0
- requirements.txt +0 -2
- web/app.js +46 -5
- web/index.html +3 -0
- web/styles.css +13 -0
README.md
CHANGED
|
@@ -26,7 +26,10 @@ license: mit
|
|
| 26 |
<span style="color:#b8a2e0">紫=桥接边</span>,白色加粗虚线=高花费边。
|
| 27 |
`B` 切换电子地图,`E` 切换沿路走线/直线。
|
| 28 |
- **右栏**:选中节点或边的完整属性与自动判定信号。
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
图片存在配套的私有 Dataset 里,由 CDN 按需分发。
|
| 31 |
|
| 32 |
-
建图规则见
|
|
|
|
| 26 |
<span style="color:#b8a2e0">紫=桥接边</span>,白色加粗虚线=高花费边。
|
| 27 |
`B` 切换电子地图,`E` 切换沿路走线/直线。
|
| 28 |
- **右栏**:选中节点或边的完整属性与自动判定信号。
|
| 29 |
+
- **街景朝向**:每个 pano 有 4 张街景,图下方一排按钮按罗盘顺时针列出
|
| 30 |
+
(`NE 40°`、`SE 130°`…),点一下换朝向。`H` 键下一个、`Shift`+`H` 上一个;
|
| 31 |
+
选中一条边时两侧会同步翻,方便对同朝向比较。
|
| 32 |
|
| 33 |
图片存在配套的私有 Dataset 里,由 CDN 按需分发。
|
| 34 |
|
| 35 |
+
建图规则见 `GRAPH_RULES.md`(在源仓库里,**不随本 Space 一起上传**)。
|
app.py
CHANGED
|
@@ -5,8 +5,9 @@
|
|
| 5 |
三栏布局、J/K 键盘导航、底图切换、邻接矩阵。区别只有一个——**去掉了全部写入**:
|
| 6 |
没有判定按钮、没有保存、没有新增边,后端也没有对应的接口。
|
| 7 |
|
| 8 |
-
架构:FastAPI 提供静态页面与只读 API,
|
| 9 |
-
gradio
|
|
|
|
| 10 |
|
| 11 |
图片来源两种,按需切换:
|
| 12 |
- 本地包:SETVL_PACKAGE 指向 prepare.py 的产出目录
|
|
@@ -23,7 +24,6 @@ import threading
|
|
| 23 |
from pathlib import Path
|
| 24 |
from typing import Any, Dict, List, Optional
|
| 25 |
|
| 26 |
-
import gradio as gr
|
| 27 |
from fastapi import FastAPI, HTTPException, Query
|
| 28 |
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
| 29 |
|
|
@@ -65,6 +65,22 @@ def con() -> sqlite3.Connection:
|
|
| 65 |
return c
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def asset_path(rel: str) -> Optional[Path]:
|
| 69 |
"""包内相对路径 -> 本地文件。数据集模式下按需从 CDN 拉取并缓存。"""
|
| 70 |
if not rel or ".." in rel:
|
|
@@ -268,12 +284,23 @@ def scene(scene_id: str) -> JSONResponse:
|
|
| 268 |
"SELECT * FROM edge WHERE scene_id=? ORDER BY source_label, target_label",
|
| 269 |
(scene_id,))]
|
| 270 |
effective(nodes, edges)
|
| 271 |
-
# 街景路径:打包时建的索引,运行时猜不出文件名(含 alt/year/heading)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
for n in nodes:
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
n["
|
| 276 |
-
n["
|
| 277 |
return JSONResponse({"scene": dict(s), "nodes": nodes, "edges": edges,
|
| 278 |
"asset": {"satellite": dict(s)["satellite_path"]}})
|
| 279 |
|
|
@@ -292,26 +319,7 @@ def serve_asset(path: str = Query(...)) -> FileResponse:
|
|
| 292 |
return FileResponse(p)
|
| 293 |
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
def _gradio_fallback() -> gr.Blocks:
|
| 298 |
-
st = None
|
| 299 |
-
try:
|
| 300 |
-
st = stats_payload()
|
| 301 |
-
except Exception:
|
| 302 |
-
pass
|
| 303 |
-
with gr.Blocks(title="SetVL Node Graph Viewer") as demo:
|
| 304 |
-
if st:
|
| 305 |
-
gr.Markdown(
|
| 306 |
-
f"# SetVL Node Graph Viewer\n\n"
|
| 307 |
-
f"{st['scenes']:,} 场景 · {st['nodes']:,} 节点 · {st['edges']:,} 边\n\n"
|
| 308 |
-
f"**[→ 打开浏览器界面](/)**")
|
| 309 |
-
else:
|
| 310 |
-
gr.Markdown("# SetVL Node Graph Viewer\n\n数据库未就绪。")
|
| 311 |
-
return demo
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
app = gr.mount_gradio_app(api, _gradio_fallback(), path="/gradio")
|
| 315 |
|
| 316 |
|
| 317 |
if __name__ == "__main__":
|
|
|
|
| 5 |
三栏布局、J/K 键盘导航、底图切换、邻接矩阵。区别只有一个——**去掉了全部写入**:
|
| 6 |
没有判定按钮、没有保存、没有新增边,后端也没有对应的接口。
|
| 7 |
|
| 8 |
+
架构:FastAPI 提供静态页面与只读 API,HF Spaces 用 docker SDK 跑。
|
| 9 |
+
曾经在 /gradio 挂过一个 Gradio Blocks 当兜底视图,已移除——它只是一段
|
| 10 |
+
带链接的 Markdown,却把 gradio 拖进了镜像依赖。
|
| 11 |
|
| 12 |
图片来源两种,按需切换:
|
| 13 |
- 本地包:SETVL_PACKAGE 指向 prepare.py 的产出目录
|
|
|
|
| 24 |
from pathlib import Path
|
| 25 |
from typing import Any, Dict, List, Optional
|
| 26 |
|
|
|
|
| 27 |
from fastapi import FastAPI, HTTPException, Query
|
| 28 |
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
| 29 |
|
|
|
|
| 65 |
return c
|
| 66 |
|
| 67 |
|
| 68 |
+
_HAS_HEADING: Optional[bool] = None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def ground_has_heading(c: sqlite3.Connection) -> bool:
|
| 72 |
+
"""旧包的 ground_index 只有 (panoid, rel),没有 heading 列。
|
| 73 |
+
|
| 74 |
+
重新 prepare 过的包才有。这里探一次并记住,免得每个节点都查一次
|
| 75 |
+
pragma;旧包降级成「只有一张街景」而不是 500。
|
| 76 |
+
"""
|
| 77 |
+
global _HAS_HEADING
|
| 78 |
+
if _HAS_HEADING is None:
|
| 79 |
+
cols = {r[1] for r in c.execute("PRAGMA table_info(ground_index)")}
|
| 80 |
+
_HAS_HEADING = "heading" in cols
|
| 81 |
+
return _HAS_HEADING
|
| 82 |
+
|
| 83 |
+
|
| 84 |
def asset_path(rel: str) -> Optional[Path]:
|
| 85 |
"""包内相对路径 -> 本地文件。数据集模式下按需从 CDN 拉取并缓存。"""
|
| 86 |
if not rel or ".." in rel:
|
|
|
|
| 284 |
"SELECT * FROM edge WHERE scene_id=? ORDER BY source_label, target_label",
|
| 285 |
(scene_id,))]
|
| 286 |
effective(nodes, edges)
|
| 287 |
+
# 街景路径:打包时建的索引,运行时猜不出文件名(含 alt/year/heading)。
|
| 288 |
+
# 每个 pano 有 4 个朝向,全部返回;按 heading 排序让前端的顺序稳定。
|
| 289 |
+
sql = ("SELECT rel, heading FROM ground_index WHERE panoid=?"
|
| 290 |
+
if ground_has_heading(c) else
|
| 291 |
+
"SELECT rel, NULL FROM ground_index WHERE panoid=?")
|
| 292 |
+
|
| 293 |
+
def order(row) -> tuple:
|
| 294 |
+
# 文件名里的 heading 有负角(-140 等),按原值排出来的是 SW/NW/NE/SE。
|
| 295 |
+
# 归一到 [0,360) 再排,朝向条就是正北起顺时针,和地图对得上。
|
| 296 |
+
h = row[1]
|
| 297 |
+
return (1, 0.0, row[0]) if h is None else (0, (float(h) % 360 + 360) % 360, row[0])
|
| 298 |
+
|
| 299 |
for n in nodes:
|
| 300 |
+
rows = sorted(c.execute(sql, (n["panoid"],)).fetchall(), key=order)
|
| 301 |
+
n["ground_assets"] = [r[0] for r in rows]
|
| 302 |
+
n["ground_headings"] = [r[1] for r in rows]
|
| 303 |
+
n["ground_asset"] = rows[0][0] if rows else None
|
| 304 |
return JSONResponse({"scene": dict(s), "nodes": nodes, "edges": edges,
|
| 305 |
"asset": {"satellite": dict(s)["satellite_path"]}})
|
| 306 |
|
|
|
|
| 319 |
return FileResponse(p)
|
| 320 |
|
| 321 |
|
| 322 |
+
app = api
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
|
| 324 |
|
| 325 |
if __name__ == "__main__":
|
graph.sqlite
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:58a07b075f692f69317cc1956f4eff308e6ca3c112d711c38cbb32f6b195f721
|
| 3 |
+
size 153534464
|
requirements.txt
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
fastapi>=0.115
|
| 2 |
uvicorn[standard]>=0.30
|
| 3 |
-
gradio>=5.0
|
| 4 |
-
pillow>=10.0
|
| 5 |
huggingface_hub>=0.25
|
|
|
|
| 1 |
fastapi>=0.115
|
| 2 |
uvicorn[standard]>=0.30
|
|
|
|
|
|
|
| 3 |
huggingface_hub>=0.25
|
web/app.js
CHANGED
|
@@ -513,22 +513,48 @@ function drawAdjacency() {
|
|
| 513 |
});
|
| 514 |
}
|
| 515 |
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
}
|
|
|
|
| 520 |
function drawGroundImages() {
|
| 521 |
var sel = S.selected;
|
| 522 |
if (!sel || !S.scene) return;
|
| 523 |
function fill(role, label) {
|
| 524 |
var n = S.scene.nodes.find(function (x) { return x.label === label; });
|
| 525 |
$(role + 'Badge').textContent = label || '—';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
$(role + 'Caption').textContent = n
|
| 527 |
-
? (n.lat.toFixed(6) + ', ' + n.lon.toFixed(6) + ' · ' + (n.corridor_name_auto || '无走廊')
|
|
|
|
| 528 |
: '—';
|
| 529 |
var img = $(role + 'Image');
|
| 530 |
-
if (
|
| 531 |
else { img.removeAttribute('src'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
}
|
| 533 |
if (sel.kind === 'node') {
|
| 534 |
$('anchorKind').textContent = 'Node';
|
|
@@ -542,6 +568,20 @@ function drawGroundImages() {
|
|
| 542 |
}
|
| 543 |
}
|
| 544 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
function pickNode(n) {
|
| 546 |
S.selected = { kind: 'node', scene_id: S.scene.scene.scene_id,
|
| 547 |
key: [S.scene.scene.scene_id, n.label] };
|
|
@@ -763,6 +803,7 @@ function boot() {
|
|
| 763 |
else if (k === ']') { page(1); ev.preventDefault(); }
|
| 764 |
else if (k === 'q') { setMode(S.mode === 'queue' ? 'scenes' : 'queue'); ev.preventDefault(); }
|
| 765 |
else if (k === 'e') { setEdgeMode(S.edgeMode === 'road' ? 'straight' : 'road'); ev.preventDefault(); }
|
|
|
|
| 766 |
});
|
| 767 |
}
|
| 768 |
document.addEventListener('DOMContentLoaded', boot);
|
|
|
|
| 513 |
});
|
| 514 |
}
|
| 515 |
|
| 516 |
+
// 每个 role(anchor/target)当前看的是第几个朝向。
|
| 517 |
+
// 换节点时保留同一个下标,这样连按 J/K 浏览时朝向不会跳来跳去。
|
| 518 |
+
var groundView = { anchor: 0, target: 0 };
|
| 519 |
+
|
| 520 |
+
function headingLabel(h) {
|
| 521 |
+
if (h == null) return '—';
|
| 522 |
+
var d = ((+h % 360) + 360) % 360; // 文件名里有负角,先归一到 [0,360)
|
| 523 |
+
var names = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
| 524 |
+
return names[Math.round(d / 45) % 8] + ' ' + Math.round(d) + '°';
|
| 525 |
}
|
| 526 |
+
|
| 527 |
function drawGroundImages() {
|
| 528 |
var sel = S.selected;
|
| 529 |
if (!sel || !S.scene) return;
|
| 530 |
function fill(role, label) {
|
| 531 |
var n = S.scene.nodes.find(function (x) { return x.label === label; });
|
| 532 |
$(role + 'Badge').textContent = label || '—';
|
| 533 |
+
var assets = (n && n.ground_assets) || [];
|
| 534 |
+
var headings = (n && n.ground_headings) || [];
|
| 535 |
+
var i = Math.min(groundView[role] || 0, Math.max(assets.length - 1, 0));
|
| 536 |
+
groundView[role] = i;
|
| 537 |
$(role + 'Caption').textContent = n
|
| 538 |
+
? (n.lat.toFixed(6) + ', ' + n.lon.toFixed(6) + ' · ' + (n.corridor_name_auto || '无走廊')
|
| 539 |
+
+ (assets.length > 1 ? ' · ' + (i + 1) + '/' + assets.length : ''))
|
| 540 |
: '—';
|
| 541 |
var img = $(role + 'Image');
|
| 542 |
+
if (assets[i]) img.src = '/asset?path=' + encodeURIComponent(assets[i]);
|
| 543 |
else { img.removeAttribute('src'); }
|
| 544 |
+
|
| 545 |
+
var strip = $(role + 'Strip');
|
| 546 |
+
strip.innerHTML = '';
|
| 547 |
+
if (assets.length < 2) return; // 只有一张就不占一行
|
| 548 |
+
assets.forEach(function (rel, k) {
|
| 549 |
+
var b = document.createElement('button');
|
| 550 |
+
b.type = 'button';
|
| 551 |
+
b.textContent = headingLabel(headings[k]);
|
| 552 |
+
b.className = k === i ? 'active' : '';
|
| 553 |
+
b.setAttribute('role', 'tab');
|
| 554 |
+
b.setAttribute('aria-selected', k === i ? 'true' : 'false');
|
| 555 |
+
b.onclick = function () { groundView[role] = k; drawGroundImages(); };
|
| 556 |
+
strip.appendChild(b);
|
| 557 |
+
});
|
| 558 |
}
|
| 559 |
if (sel.kind === 'node') {
|
| 560 |
$('anchorKind').textContent = 'Node';
|
|
|
|
| 568 |
}
|
| 569 |
}
|
| 570 |
|
| 571 |
+
// H 把当前可见的街景卡一起翻一个朝向——比较一对节点时两边保持同一朝向。
|
| 572 |
+
function cycleGround(step) {
|
| 573 |
+
if (!S.selected || !S.scene) return;
|
| 574 |
+
var labels = S.selected.kind === 'node'
|
| 575 |
+
? [['anchor', S.selected.key[1]]]
|
| 576 |
+
: [['anchor', S.selected.key[1]], ['target', S.selected.key[2]]];
|
| 577 |
+
labels.forEach(function (pair) {
|
| 578 |
+
var n = S.scene.nodes.find(function (x) { return x.label === pair[1]; });
|
| 579 |
+
var len = ((n && n.ground_assets) || []).length;
|
| 580 |
+
if (len > 1) groundView[pair[0]] = ((groundView[pair[0]] + step) % len + len) % len;
|
| 581 |
+
});
|
| 582 |
+
drawGroundImages();
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
function pickNode(n) {
|
| 586 |
S.selected = { kind: 'node', scene_id: S.scene.scene.scene_id,
|
| 587 |
key: [S.scene.scene.scene_id, n.label] };
|
|
|
|
| 803 |
else if (k === ']') { page(1); ev.preventDefault(); }
|
| 804 |
else if (k === 'q') { setMode(S.mode === 'queue' ? 'scenes' : 'queue'); ev.preventDefault(); }
|
| 805 |
else if (k === 'e') { setEdgeMode(S.edgeMode === 'road' ? 'straight' : 'road'); ev.preventDefault(); }
|
| 806 |
+
else if (k === 'h') { cycleGround(ev.shiftKey ? -1 : 1); ev.preventDefault(); }
|
| 807 |
});
|
| 808 |
}
|
| 809 |
document.addEventListener('DOMContentLoaded', boot);
|
web/index.html
CHANGED
|
@@ -195,6 +195,7 @@
|
|
| 195 |
<button type="button" class="image-stage ground-stage" data-role="anchor" aria-label="放大街景">
|
| 196 |
<img id="anchorImage" alt="ground view">
|
| 197 |
</button>
|
|
|
|
| 198 |
<div class="image-caption mono" id="anchorCaption">—</div>
|
| 199 |
</article>
|
| 200 |
<article class="image-card ground-card" id="targetCard">
|
|
@@ -205,6 +206,7 @@
|
|
| 205 |
<button type="button" class="image-stage ground-stage" data-role="target" aria-label="放大街景">
|
| 206 |
<img id="targetImage" alt="ground view">
|
| 207 |
</button>
|
|
|
|
| 208 |
<div class="image-caption mono" id="targetCaption">—</div>
|
| 209 |
</article>
|
| 210 |
</div>
|
|
@@ -263,6 +265,7 @@
|
|
| 263 |
<kbd>K</kbd><span>队列上一条</span>
|
| 264 |
<kbd>B</kbd><span>切换卫星图 / 电子地图</span>
|
| 265 |
<kbd>E</kbd><span>切换边的画法:沿路走线 / 直线</span>
|
|
|
|
| 266 |
<kbd>[</kbd> <kbd>]</kbd><span>列表上一页 / 下一页</span>
|
| 267 |
<kbd>Q</kbd><span>切换「全部场景」/「带信号的」</span>
|
| 268 |
<kbd>?</kbd><span>打开本帮助</span>
|
|
|
|
| 195 |
<button type="button" class="image-stage ground-stage" data-role="anchor" aria-label="放大街景">
|
| 196 |
<img id="anchorImage" alt="ground view">
|
| 197 |
</button>
|
| 198 |
+
<div class="heading-strip" id="anchorStrip" role="tablist" aria-label="街景朝向"></div>
|
| 199 |
<div class="image-caption mono" id="anchorCaption">—</div>
|
| 200 |
</article>
|
| 201 |
<article class="image-card ground-card" id="targetCard">
|
|
|
|
| 206 |
<button type="button" class="image-stage ground-stage" data-role="target" aria-label="放大街景">
|
| 207 |
<img id="targetImage" alt="ground view">
|
| 208 |
</button>
|
| 209 |
+
<div class="heading-strip" id="targetStrip" role="tablist" aria-label="街景朝向"></div>
|
| 210 |
<div class="image-caption mono" id="targetCaption">—</div>
|
| 211 |
</article>
|
| 212 |
</div>
|
|
|
|
| 265 |
<kbd>K</kbd><span>队列上一条</span>
|
| 266 |
<kbd>B</kbd><span>切换卫星图 / 电子地图</span>
|
| 267 |
<kbd>E</kbd><span>切换边的画法:沿路走线 / 直线</span>
|
| 268 |
+
<kbd>H</kbd><span>下一个街景朝向(<kbd>Shift</kbd>+<kbd>H</kbd> 上一个)</span>
|
| 269 |
<kbd>[</kbd> <kbd>]</kbd><span>列表上一页 / 下一页</span>
|
| 270 |
<kbd>Q</kbd><span>切换「全部场景」/「带信号的」</span>
|
| 271 |
<kbd>?</kbd><span>打开本帮助</span>
|
web/styles.css
CHANGED
|
@@ -231,6 +231,19 @@ kbd {
|
|
| 231 |
.map-controls { display: flex; border: 1px solid var(--line); }
|
| 232 |
.map-controls button { background: transparent; border: 0; width: 22px; cursor: pointer; color: var(--muted); font: 700 12px var(--mono); }
|
| 233 |
.map-controls button:hover { color: var(--signal); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
.image-caption { min-height: 34px; padding: 8px 11px; display: flex; justify-content: space-between; gap: 10px; color: var(--faint); border-top: 1px solid var(--line-soft); font: 600 9px var(--mono); }
|
| 235 |
.image-caption.mono { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 236 |
|
|
|
|
| 231 |
.map-controls { display: flex; border: 1px solid var(--line); }
|
| 232 |
.map-controls button { background: transparent; border: 0; width: 22px; cursor: pointer; color: var(--muted); font: 700 12px var(--mono); }
|
| 233 |
.map-controls button:hover { color: var(--signal); }
|
| 234 |
+
/* 街景朝向切换条:一个 pano 有 4 个朝向,全部列出来可点 */
|
| 235 |
+
.heading-strip {
|
| 236 |
+
display: flex; gap: 1px; border-top: 1px solid var(--line-soft);
|
| 237 |
+
background: var(--line-soft);
|
| 238 |
+
}
|
| 239 |
+
.heading-strip:empty { display: none; }
|
| 240 |
+
.heading-strip button {
|
| 241 |
+
flex: 1; background: var(--panel); border: 0; padding: 4px 0; cursor: pointer;
|
| 242 |
+
color: var(--faint); font: 700 9px var(--mono); letter-spacing: .06em;
|
| 243 |
+
}
|
| 244 |
+
.heading-strip button:hover { color: var(--cyan); }
|
| 245 |
+
.heading-strip button.active { background: var(--signal); color: var(--ink); }
|
| 246 |
+
|
| 247 |
.image-caption { min-height: 34px; padding: 8px 11px; display: flex; justify-content: space-between; gap: 10px; color: var(--faint); border-top: 1px solid var(--line-soft); font: 600 9px var(--mono); }
|
| 248 |
.image-caption.mono { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 249 |
|