迷踪 · geo-sleuth(v2)
目标:找到一个经得起核对的地点,每个结论都能指出用了哪条线索、跑了哪个命令、产出了哪个文件。
命令里的
${CLAUDE_SKILL_DIR}指本 SKILL.md 所在目录(references 里的命令也一样)。Claude Code 会自动替换;其他 agent 先export CLAUDE_SKILL_DIR=<本目录的绝对路径>再跑(shell 不跨命令保留变量时,每条命令前都带上这句),或者直接把它换成这个路径。
工作方式分三层,先记住这个,再看流程:
| 层 | 谁做 | 工具 |
|---|---|---|
| 决策:候选有哪些、证据怎么算分、能不能排除、下一步扫哪 | 脚本(候选盘) | board.py |
| 感知:读字、查表、卫星图上找目标、街景比对 | 脚本先算先排,人只看前几名 | intake.py ocr.py clues.py sat_scan.py match.py geo.py bearings |
| 判断:从画面里提线索、查表没有时提假设、在机器排好的前几名里裁定 | 你 | — |
方法来自 14 个网络迷踪博主视频、22 道题的拆解和多轮盲测对照;拆解笔记不随仓库发布。v1 的教训:规则写成散文不会被执行,同一版 skill 两次跑结果差很大;所以 v2 把能写成代码的规则都放进了 board.py。
硬规则(全程有效;标 ★ 的由 board.py 强制,你照做就行)
- 用户说照片不是自己拍的,或画面里是私人住处、未成年人,先问一句用途再继续;提示里已写明来源或用途(评测题、出题人提示、用户明说是自己拍的)时不问,照常做到最细一级。
- 不编核验。"在地图上量过""街景对上了""±10 m"必须对应本次会话里实际跑过的命令和产出的文件。没跑过就写"未核实"。
- 精度要有出处:误差半径 ≤100 m,必须有两条独立约束的交会,或实景比对 ≥3 项不变特征。报机位前先跑一次自检:
pose.py project --horizon <海天线行号>会算出 pitch 该是多少、各地物按当前机位"应落在第几行",和照片对账(目标的俯角 ≠ 相机的俯仰角;机位高程用terrain.py elev查,高差和画面俯角对不上就是机位错了)。 - 找到唯一锚点就闭环:后面的搜索和几何都从锚点出发,不再回到"湖水蓝绿""热带公园"这类泛化特征挑同类里最有名的地方。
- 先算方位,再认构件:照片里的塔、烟囱对应卫星图上哪个方块本身是解读。先用
sun.py compass或已确认地标算出它在画面里的真实方位,再用geo.py bearings看候选机位周围哪个轮廓落在那个方位上;对不上时先做"原图/镜像""日出/日落"双模板,没核实就只降档不排除。 - ★ 类别推断先列全:从"山城""热带""IP 直辖市"推地区时,
board.py children把全部下级行政区加进候选,再用证据排。不默认主城,不按人口挑。 - 元数据和提示都是假设:EXIF、IP 属地、定位标签、出题人的话和画面冲突时以画面为准,不编故事去圆。IP 是 A 国、提示或制式指向另一大洲:
clues.py lookup territories <A国> --continent <洲>求交集。 - 自洽性:关键判断换一个裁剪或一组关键词重做一次,两次结果差几十公里以上就降档。
- ★ 定不到点就给区间 + 缺什么信息;候选是离散的几个时不取中点:
board.py report的主答案永远是第一名,其余进备选并写区分检验。 - ★ 排除和确认用同一个标准:
board.py exclude只接受 read/computed 级线索 + 算过的文件(geo.py frame、terrain.py等的产出);观察和推测只能evidence --against降权,似然比被夹在 1/3–3(推测)或 1/5–5(观察)。用"附近有 X"生成候选也是过滤,X 必须是画面里确认过的东西。校园、小区、街段这类细层候选同样要进盘:一批的用board.py add --from <poi.py --out / osm.py geom 的输出> --level area/road一次全进,不手挑;放弃一个就写evidence --against附比对图,check会列出一条证据都没有的(等于没看过);第三方数据的标签(市政清单的树种、校名精确匹配)只能降权,不能当排除理由(两例都是真值被这样手动跳过)。排除范围必须 ≤ 证据范围:区县/片区/路这类有延展的候选,exclude要用--covers lat,lon[:lat,lon]写明证据覆盖到哪一段,覆盖不足一半会被脚本拒(在一条路的一个点上看过就整条排除,两例都栽过)。 - ★ 人口、名气不是证据;扫描顺序按"份额 ÷ 页数"(
board.py next):小城区先扫完,大城区放最后并设页数上限。
流程
不必走满:第 1 步识图直接命中时,跳到第 6、7 步确认。每一步的产出都要进候选盘。
第 1 步:一条命令做完第 0–3 步
uv run ${CLAUDE_SKILL_DIR}/scripts/intake.py photo.jpg --out-dir intake/ [--box x0,y0,x1,y1 ...] [--proxy socks5://127.0.0.1:10808]
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py init --photo photo.jpg
intake.py 一般 1–2 分钟,第一次运行要装依赖会更久:命令超时给够(10 分钟以上)或放后台跑。被命令超时打断时,识图子进程可能还在往 rev/ 里写文件,但不会生成 intake.md,别当成已经跑完。
intake.md 里有:元数据、OCR 文字(放大/切块读出的标 pass=up/tile,是假设)、百度相似图片(来源站点计数 + 编号拼图)、识图标签分级计票、疑似小区/楼盘名、边缘图清单、失败项。然后你做四件事:
- 看图:
edges/四边四角逐张看;references/observe.md的清单过一遍;每条线索board.py clue "<文本>" --kind <类> --status observed|read|inferred|computed --file <放大图>。状态要诚实:读出来的字是 read,"楼大概 8 层""路在上坡"是 inferred。 - 查表:车牌、区号、电话国家码、行驶方向、海外领地 →
clues.py lookup <kind> <value>;能落到行政区的直接board.py apply --kind plate --value 渝G --file <放大图>(自动加候选和证据,同级其余候选只降权不排除)。 - 识图结果:先打开
rev/<名>_baidu_similar.jpg(左上角是查询图),找同一个物体或同一处场景的近重复照片,有就按编号去 JSON 的similar[i].from看来源页;标签里的小区名、楼盘名、酒店名 →poi.py "<名>" --city <城市> --out pois.json落坐标,同名的(几个校区、几家分店)board.py add --from pois.json --level area全部进盘再核;截图务必打开看,命中帖子后把同组照片也看一遍。检索处按物体类型选(references/search.md)。 - 提示与元数据:逐条登记为 inferred 线索,写明可信度;IP 属地只说明发帖时人在哪,发帖时间不是拍摄时间。
第 2 步:候选列全、证据打分、看下一步
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py children <上级行政区名> # 直辖市 → 全部区县;国家 → 一级行政区(gazetteer.py 查 OSM,带 bbox)
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py evidence --clue K1 --for <候选A>:5 --for <候选B>:2 --against <候选C>:0.3 --why "…" --file <比对图>
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py rank
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py next
next 只会说两种话:
- 分不开:按便宜到贵做区分检验,每项对全部候选一起做——查表 → 地形(平原 vs 山城,
tiles.py fetch --zoom 13或terrain.py)→ 车辆涂装(revimg.py --query "<城市> <颜色> 公交",从结果图读线路牌,按区县比车尾腰线)→ 市政设施(baidu_pano.py sample --bbox <建成区> --n 24)→ 水系/路网模板。都做过仍分不开:不要停,按它给的"份额 ÷ 页数"顺序扫。 - 可以缩圈:先
board.py urban <候选>把范围缩到建成区(或scan-bbox手动给),再board.py falsify <候选> --text "…"写证伪条件,然后进第 3 步。
环境粗定位的规则仍在:地形先于河宽和建筑色;物候必须配月份;罕见设施组合取交集;认得出的物种只当排除工具;只有地类时先用土地覆盖图缩到那类地块(references/clues/)。
第 3 步:选分支缩圈
| 手里有什么 | 做法 | 读 |
|---|---|---|
| 唯一锚点(楼、雕像、塔、景区建筑) | 锚点几何:视线交会、对齐线、切线、按拍摄高度筛楼 | geometry.md |
| 远山、天际线 | terrain.py view 渲染候选机位比对,只定一条视线,再找第二条约束 |
geometry.md |
| 清晰影子、受光面、太阳在画面里 | sun.py locate / when / facing / compass:地带、时刻、朝向、画面里物体的真实方位 |
sky.md |
| 线路号、铁路或输电线规格、大河 | 线状走廊 + 线变点:osm.py route / crossings / along |
corridors.md |
| 两三种基础设施同框 | osm.py near --report、两类线交叉 osm.py intersect(折角只标注不删) |
corridors.md |
| 线状设施 + 认不出的山,没有文字 | 设施沿线接高程算地平线,整个大区过滤 → 天际线 + 设施距离数值打分 → 前 3–5 名叠图 → 等间距构件定机位:terrain.py scan --lines … --out hits.json --clusters-out clusters.json → terrain.py ridge photo.jpg --x0 … --x1 … --out ridge.json → terrain.py fit --hits hits.json --ridge ridge.json [--line … --line-dist …] --sheet top.jpg --photo photo.jpg(精搜换 --at lat,lon --radius 800 --grid 100 --zoom 13)→ imgprep.py piers photo.jpg --rows … --out cols.json --sheet piers.jpg → geo.py spacing --cols … --line … --span … --center lat,lon --ridge ridge.json |
corridors.md 4.3、geometry.md 7.4 / 7.7 |
| 街道格局清楚、没有锚点 | osm.py street-scan 筛路口 → tiles.py sheet → 街景 |
corridors.md |
| 识图无近重复、没有文字,只有一组场景要素(路的规格 + 相邻地物 + 地貌) | 别先手挑点位:把场景写成一句英文 query,sat_scan.py grid --bbox <海岸带/走廊> --zoom 17 --cell 360 --query … --neg … 让 CLIP 排序,人只看前 20–30 名缩略图;grid 优于 points,因为 OSM 的 amenity=parking 一类要素未必收全 |
search.md |
| 画面里 ≥4 个已知位置的点(窗景、俯拍;平视拍远处塔桥码头也行,fix 掉 height) | pose.py solve 反解机位、朝向、高度;几处候选都说得通时 pose.py check --cands 逐个打分,稳健Δchi2 > 9 的才能 board.py exclude --computed |
geometry.md 第 10 节 |
| 想用"画面里没有 X"排除 | geo.py frame 先算 X 该不该在框里、够不够大、会不会被挡;能排除的才 board.py exclude --computed |
geometry.md 第 11 节 |
| 认得出设施类型(烘干塔、饲料厂、糖厂、水泥站) | 查产业分布 → osm.py buildings 枚举大建筑 → sat_scan.py points 排序 |
search.md |
| 连锁品牌子品牌门店 | 先搜开业新闻稿拿地址,定位器只当候选池 → osm.py along + 街景抽样 |
search.md |
| 机窗、无人机俯拍 | 航拍分支 | aerial.md |
第 4 步:卫星图找点——机器先排序
uv run ${CLAUDE_SKILL_DIR}/scripts/sat_scan.py grid --bbox <scan_bbox> --zoom 17 --preset track --multi-scale --top 30 --out sat.json --sheet sat_top.jpg --heat heat.jpg
uv run ${CLAUDE_SKILL_DIR}/scripts/poi.py "<区县> 学校" --city <地级市> --out schools.json # 种子:--seeds schools.json 给附近格子加分
uv run ${CLAUDE_SKILL_DIR}/scripts/sat_scan.py points --points big.json --preset factory --out r.json --sheet r.jpg # osm.py buildings / poi.py 的候选点排序
- 预设:track(操场跑道)、stadium、factory、silo、dam、bridge、quarry、solar、greenhouse、port;自定义
--query。实测:城区 300 多格里,OSM 标注的跑道一半以上进前 30 名;国内 OSM 空白的老城,学校操场也能排第一。它是排序不是判定:看前 20–30 格的缩略图,再按照片里的方位、形状核。 - 把画面描述翻译成俯视特征后再看:圆弧楼、八角亭屋顶、球场、车位和铁路垂直;高楼看楼底;影像有年代。
- 候选多时列候选表:一行一个点,一列一条照片里直接看得到的标准。
- 前 30 名都没对上:先回头看
board.py check列的"被推测降权的候选"和证伪条件,再换预设或 z18,最后才扩大范围。
第 5 步:确认——街景也先排序
uv run ${CLAUDE_SKILL_DIR}/scripts/baidu_pano.py scan <lat,lon> --radius 300 --out panos.json # 国内;国外 gsv.py
uv run ${CLAUDE_SKILL_DIR}/scripts/match.py rank --query photo.jpg --panos panos.json --toward <地标lat,lon> --spread 15 --refine sift --top 10 --out m.json --sheet m.jpg
uv run ${CLAUDE_SKILL_DIR}/scripts/match.py rank --query photo.jpg --items around.index.json --render gsv --spread-headings -30,0,30 --out m.json --sheet m.jpg
match.py用 DINOv2 全局相似度粗排、SIFT 内点精排;实测同一地点不同年份的街景真值都进前 4。只打开前 10 名,比不变特征(楼的轮廓、窗位、阳台、电杆位置、路缘、山脊),不比车辆、招牌、树叶。到路 ≥2 项、到楼 ≥3 项。内点 ≥15 的一张都没有不代表不对:换季、老批次、照片在人行道而街景在路中间时,真值实测只有 5 个、0–8 个内点(两例),先打开前 10 张比不变特征,都不对再换--spread-headings或扩大--within。全局分会被季节主导(花期批次不论在哪都排前面),和照片同季节的历史批次(gsv.py sheet --date)最好比。- 全景点多时按日期和道路分组,
sheet --road <路名> --spread 60只看一条路;老批次视野更开阔。 - 没有街景不等于不能确认:
terrain.py view --photo比山脊;给候选设施找名字(OSM 名字、附近地名 + 当地语言的设施类型词),搜新闻、百科、官网配图比立面细节。 - 街景比照片早很多年时,以老建筑和永久结构为准。
第 6 步:定机位与输出
- 回头看:在对上的街景点转 180°,看拍摄者那一侧是什么。
- 机位要两条独立约束(
geo.py intersect视线交会、geo.py line对齐线、pose.py多点反解、拍摄高度、反向街景);只有一条时,楼级置信度最高"中"。 - 朝向没有可量的影子时用受光面:
sun.py facing --lit left --shaded camera。车里、船上、火车上拍的写出行进方向。
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py check # 出结论前:排除有文件吗、主答案有核实证据吗、哪些线索没用上
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py report --merge result.json # 主答案=第一名;备选、排除、未用线索自动写进 result.json
uv run ${CLAUDE_SKILL_DIR}/scripts/evidence.py spec.json --out evidence.jpg
输出:
- 一句话结论:地点 + 机位 + 朝向(+ 行进方向、拍摄时间,如适用)
- 坐标:WGS84 和 GCJ-02(
geo.py convert),带误差半径 - 证据图(卫星图标机位和朝向扇形 + 比对图)
- 推理链:线索 → 推断 → 实际跑过的命令和产出文件 → 范围
- 分档置信度(下表)。每一级分开评,自报档位取"中"及以上的最细一级:楼级低不拖累片区级高
- 排除过的候选和理由、备选和区分检验、没用上或没解开的线索(
board.py report生成) - 定不到点时:已确定到哪一级 + 还需要什么信息
| 级别 | 高 | 中 | 低 |
|---|---|---|---|
| 城市 | 文字、车牌、区号或确认过的锚点 | 多条独立弱线索一致 | 单条弱线索或只靠提示 |
| 片区 | 唯一的设施或地标在卫星图上对上 ≥3 项俯视特征(全候选区看完、没有第二处) | 候选区里最像的一处,还有没看完的候选 | 推测 |
| 路 | 实景对上 ≥2 项独有特征 | 格局一致,独有特征 1 项;没有任何地面照片时:片区级为高 + 方位自检通过 | 只有卫星图格局 |
| 楼 | 两条独立约束 + 实景 ≥3 项 | 两者之一 | 推测 |
| 楼层 | 两种参照交叉 | 单一参照 | 不给 |
预算与停止条件
intake.py算一次调用;识图换 3 组关键词无果就停,回清单找别的线索。- 候选还分不开时不做任何逐个扫点(
board.py next说"分不开"就先做区分检验);便宜检验做完仍分不开,按它给的顺序扫,每个候选先扫建成区前 3 页。 - 扫描和确认一律先排序:
sat_scan.py看前 30 格,match.py看前 10 张;前几名全不对再扩大,不是逐页翻。 - 用 OSM 枚举候选前先
osm.py coverage:偏少的区县会被静默排除,改用sat_scan.py grid,并在结论里写明。 - 单个片区全景点 ≤500 个;3 个片区都没对上就停,
board.py report报告已确定到哪一级。 - 停之前
board.py check:被推测降权但没排除的候选按排序先回头看前 20 个。
运行环境
- Python 3.10+;一律
uv run ${CLAUDE_SKILL_DIR}/scripts/xxx.py(脚本头写了依赖;match.py、sat_scan.py第一次会装 torch 并下载模型,走代理)。references 里的scripts/都相对于本 skill 目录。revimg.py/intake.py需要本机 Chrome;ocr.py在 macOS 用 Apple Vision。 - 代理地址以
GEO_PROXY环境变量为准(export GEO_PROXY=socks5h://...),文中和脚本帮助里的127.0.0.1:10808是示例端口,换成你自己的。走代理:Google 卫星图、Google 街景、Overpass、Yandex、HuggingFace。直连:百度全景、百度识图、必应国内版、高程切片。intake.py --proxy写socks5://(Chrome 的写法)。 - 缓存写当前目录
.geo-cache/;候选盘是当前目录board.json。脚本清单和数据源见references/data-sources.md。 - macOS 没有
timeout命令;zsh 里$var不分词,循环用bash -c或${=var}。整省 Overpass 查询可能要几分钟,放后台跑。