跳到主要内容
空 EXIF 的无人机照片,我用八千万个三角形把它老家筛出来了

空 EXIF 的无人机照片,我用八千万个三角形把它老家筛出来了

钻牛角
钻牛角

· 阅读约 6 分钟

我本来只是想睡前刷一眼那道 OSINT 挑战,gralhix #004,一张无人机拍的度假村照片。结果点开就收不住——exiftool 在 Void Linux 上跑完,连个 GPS 影子都没有,相机型号、拍摄参数全空。嘿,这就来劲了。换别人可能顺手丢给 Google Lens,我不,我偏要拿几何硬刚。这头牛钻着钻着就钻出前端界了,你说气不气。

照片里能用的其实不多,就三块陆地:P0 那个小岛,P1 右边那座,P2 左前方那座。没有经纬度,没有任何文字,连拍摄高度都只能靠猜。可这三块陆地的相对角度、距离比例是死的,就像个形状指纹。只要我能在一个足够大的海岸线数据集里,把“正好能摆出这个形状”的三个点挖出来,这地方就跑不掉。

我先去 OpenStreetMap 下了 land-polygons-split-4326,882MB 全球海岸线多边形。这玩意儿是真大,解压后我差点以为硬盘要报警。全球陆地全在里面,理论上答案一定在,问题是它啥形态都有,怎么筛。

第一刀简单到不像话:热带纬度过滤,把候选限制在 -30° 到 30° 之间。这一步砍到 141,131 个陆地多边形。

等等,这地方我得停一下。我一度把顺序记反了,以为密度过滤先上,结果翻笔记才发现是纬度先砍到 141,131,后面局部密度过滤才压到 51,576。中间我卡了俩小时,一度拍桌子骂 OpenStreetMap 数据太糙。其实是我自己先入为主,哎。

局部密度过滤我写了个最短的验证版本,先在 CPU 小范围试跑:

neighbors = count(p for each polygon if distance(p, candidate) < 5km)
if neighbors > 10: drop(candidate)

就这。5 公里内邻居最多 10 个,超过就是大陆边上一堆碎陆地的格局,不是照片里那种孤零零的小岛群。这步把候选从 141,131 压到 51,576,一下砍掉三分之二。

接下来聚类。条件挺松:20 公里内至少 3 个点,聚出 23,500 个簇。然后就是整件事最疯的一步——在每个簇里生成所有三点组合,80,690,777 个候选三角形。

到这儿我就不装 CPU 了。八千万个三元组,要挨个算几何匹配,CPU 得跑到天亮。我把 CUDA kernel 甩给一块 RTX 3050,204.1ms 全跑完,显存吃掉 5169MB。活见鬼,比我怀疑自己代码写错的时间还短。

初始几何掩码砍到 158,784 个通过的。这个数字看着还行,可接下来还有一层层的关卡,每一层都像在问:你确定这不是巧合?

第一遍去重,8,915 个唯一三元组。开水面矩形检查沿着 P0-P1 边外推一个矩形,看里面有没有陆地,砍到 948。这步最狠,一条命直接没了。然后是珊瑚礁形状检查——Polsby-Popper 紧凑度 ≥0.5,外加至少一个微型珊瑚岛在 1.5 公里内。

PP = 4π * A / P²

这公式不陌生,但把它当过滤条件用,我还真头一回。PP 越高形状越圆。珊瑚礁岛在卫星图里是那种圆乎的小环,不是乱七八糟的海岸线锯齿。过这关剩 213 个,再靠椭圆形状过滤——长宽比卡在 1.05 到 2.2,填充率至少 0.589,也就是完美椭圆的 75%——压到 137。

再往后是两道环境检查。NDVI 植被那关用 Sentinel-2 影像,树冠阈值卡 0.6,把水泥度假村和秃礁分开。这还嫌不够,又把高程数据拉进来:P0 必须 ≤50m,P2 上要有 100–500m 的一条山弧。最后剩 26 个候选。

到这一步,终于轮到我人工看。说实话我原先以为几何掩码之后就能直接人肉,结果去重后还有八千多个,根本看不过来。不亲手跑一遍的人只会说“筛一下就完事”,真去筛才知道“筛一下”是四五个死人关卡。

过滤关卡通过候选数备注
热带纬度过滤141,131温和,但没它后边更没法看
局部密度过滤51,576我一度记反顺序,自我怀疑半天
聚类23,5003 点 20km,没得商量
三元组生成80,690,777这是候选三角形,不是陆地数
初始几何掩码158,784CUDA 204.1ms,全靠它续命
去重8,915重复三元组一堆
开水面矩形检查948最狠,一条命直接砍没
珊瑚礁形状检查213Polsby-Popper 下手真黑
椭圆形状过滤137长宽比、填充率双杀
NDVI 植被检查66≥0.6 树冠,Sentinel-2 撑腰
高程检查26P0≤50m,P2 有 100–500m 山弧
人工复核第 8 名正确位置:Oan

人工复核到第 8 个的时候,我整个人从椅子上弹了一下。密克罗尼西亚,Oan 度假村。坐标 7°21′48.4″N 151°45′20.7″E,相机朝向西北,bearing 约 324.97°。别问我为什么西北还能算出 324.97 不是 315,那几度误差是山弧和海岸线咬出来的。

最让我舒坦的其实是那个空 EXIF。这张照片一点 metadata 都没给我,连摄像头朝哪片海都要靠几何自己证。你不用 Google Lens,它不是不可以,但它在这种场合会给你一堆“看起来像”的选择。我宁可慢慢筛,也不想跟一个黑盒解释什么叫证据。

我承认,我这套管线的每一步都像是给数据下死手。OpenStreetMap 的 coastline 数据、Copernicus DEM、Sentinel-2、甚至 Natural Earth 都掺进来了。单拎出哪个都不难,难的是它们串起来之后,你得清楚每一关在杀死哪些巧合。杀少了白干,杀狠了把答案也带走了。

高程那关我一开始还不信,觉得它是凑数的。结果真跑到第 8 名,看到 P2 那条山弧的坡度跟照片里的阴影能对上,我才服气。有个小插曲:我中途还偷偷试过把高程阈值放宽到 80m,想看会不会少杀几个。结果多出来几个,但全是废的。你不亲手试,根本不知道 50 这个数是逼到求饶才出来的。

最后,说个跟这题八竿子打不着的事。我本来不碰 OSINT,总觉得自己是那种天天盯浏览器兼容性、被 Safari 气到拍桌子的前端可怜人。钻完这道题我才缓过劲来,这些看起来离我很远的门道,底子其实一样:都靠证据说话,不靠黑盒,不靠“我觉得像”。我这头牛第一次钻出前端界,居然有点爽。就是下载 882MB coastline 那会有点肉疼,下不动下不动的。嘿嘿,下次如果再有空 metadata 的图,我可能还会手滑——不过最好别再让我碰北纬高纬度的沼泽海岸,那地形能把紧凑度逼疯。下次见。

钻牛角
钻牛角

逮住一个前端小知识点挖到底:现象→实验→兼容性表格→闲聊,配 demo 玩梗。

查看主页 →