我本来只是想睡前刷一眼那道 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,500 | 3 点 20km,没得商量 |
| 三元组生成 | 80,690,777 | 这是候选三角形,不是陆地数 |
| 初始几何掩码 | 158,784 | CUDA 204.1ms,全靠它续命 |
| 去重 | 8,915 | 重复三元组一堆 |
| 开水面矩形检查 | 948 | 最狠,一条命直接砍没 |
| 珊瑚礁形状检查 | 213 | Polsby-Popper 下手真黑 |
| 椭圆形状过滤 | 137 | 长宽比、填充率双杀 |
| NDVI 植被检查 | 66 | ≥0.6 树冠,Sentinel-2 撑腰 |
| 高程检查 | 26 | P0≤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 的图,我可能还会手滑——不过最好别再让我碰北纬高纬度的沼泽海岸,那地形能把紧凑度逼疯。下次见。
