复古游戏制图:第二部分





5.00/5 (14投票s)
从捕获的游戏玩法中重建游戏世界地图的改进算法
引言
本文描述了为改进重建游戏世界地图的原始算法性能所做的工作。原始实现请参阅本文。
算法在以下几个方面进行了改进:
- 运行时性能
- 减少帧“错位”
- 伪影过滤
为实现这些目标,解决问题的方法做了一些改变。这些改变范围从实现细节到算法的更根本性修改。
第一个也是最明显的改变是从托管的F#代码切换到本地C++代码。此外,还采用了AVX2来提高图像低级操作的运行时性能。
方法上最根本的改变是,算法不再保持世界的全局视图(关键点数据库),也不再尝试将帧与其匹配。相反,每个连续的帧只与其前一个帧进行比较,并且只计算相对位置变化。可以匹配的连续帧流形成一个片段,或地图的一部分。如果新帧无法匹配前一个帧,则会启动一个新片段。最后,片段相互匹配并拼接在一起。这解决了运行时性能、关键点波动和帧不规则放置的一些问题,但也引入了将在本文后面讨论的新问题。
前景提取和伪影过滤也进行了额外的改进,以进一步减少移动物体留下的随机像素。为改进前景提取,地图构建分为两个阶段。第一阶段构建地图并仅应用最基本的过滤。更复杂的过滤留到第二阶段,该阶段再次遍历所有帧并尝试识别和移除所有移动物体。最后应用简单的统计分析来移除剩余的伪影——未正确识别为前景的随机像素。
构建片段
此版本的算法不保留单个全局世界地图,而是尝试构建局部区域(称为片段)的地图。片段中的当前位置被保留并随每个新帧更新。为获取下一帧的位置,会计算与前一帧的相对位移(或偏移),并通过应用此位移来更新当前位置。然后将帧内容以计算出的位置写入片段。如果无法计算偏移,则当前片段被封存,帧被移至新片段。
为计算两帧之间的偏移,从帧中提取关键点,然后将其与前一帧的关键点进行匹配。匹配算法尝试确保两帧实际相邻,然后才计算它们之间的偏移。
片段收集/构建流程如下图所示:
片段是成功匹配的连续帧序列,形成世界地图的一部分。它由一个矩阵表示,该矩阵存储每个像素的历史记录。矩阵的每个元素都是一个“点”,用于跟踪每种颜色在该位置出现的次数。将帧添加到区域(或“blitting”)只是增加了该帧在该像素处拥有的颜色的计数。片段可以“混合”成图像。该过程简单地将每个点中最常见的颜色作为输出图像的像素值。混合还会输出一个位掩码,告诉哪些点实际观察到帧/颜色数据,哪些是“空的”并且可以被忽略/过滤。添加到片段的每个帧也以压缩格式保存其位置信息,因为这些数据在算法的后续阶段将再次需要。
fgm
命名空间封装了处理片段表示和操作的代码。fragment
类是最重要的一个。
frc
命名空间中的 collector
表示从帧流中收集和构建片段的循环。Collector 接受一个生成帧的 feeder 对象。在示例应用程序中,feeder 通过从目录读取帧来生成帧。
关键点提取
关键点提取与算法的第一个版本相比发生了显著变化。为识别关键点,中值滤波器以两种不同的窗口大小(3x3和5x5)对帧应用两次,然后比较两个结果图像和原始图像。
我们将 keypoint(i,j) 定义为一个谓词,如果像素被识别为关键点则为真(originalij 代表原始图像的像素,而 medianij 代表经过中值滤波器处理的图像的像素)
weight(i,j) 是一个将关键点映射到其在像素 (i,j) 处的权重的函数
输入图像 | ||
![]() | ||
权重为 1 的关键点 | 权重为 2 的关键点 | |
![]() | ![]() | |
组合的关键点 | ||
![]() |
通常只有具有较高权重的关键点用于匹配和偏移计算,但在那些关键点稀疏的帧区域中,算法会退回到使用权重较低的关键点。一旦识别出关键点,其描述通过从周围的5x5窗口提取像素并连接权重到生成的字节数组来编码,以生成104位代码。
提取关键点时,帧被一定配置的网格“分割”。每个提取的关键点被分配到一个或多个网格区域。稍后,当算法尝试匹配两帧时,它将只尝试比较属于相同区域的关键点。
本地图像的中值滤波器
本地图像使用像素的本地颜色代码。颜色代码的实际数值不对应其真实的RGB颜色或其强度或任何“物理”属性,因此它们不适合进行任何形式的过滤。为了使图像适合处理,像素的颜色代码被转换为对应其本地颜色亮度的值(这种格式称为本地有序值)。转换方式如下:
- 本地颜色代码作为输入格式
- 使用查找表进行RGB转换
- RGB颜色强度根据标准公式计算
- 有序值等于按强度排序的所有颜色数组中颜色的索引
在应用中值滤波器之前,本地图像被转换为这种“有序值”格式,然后应用滤波器。如果需要,这种格式也可以转换回本地格式。
匹配帧
关键点匹配通过计算当前帧中每个关键点与前一帧中对应关键点之间的所有偏移来实现。对应的关键点是具有相同代码并位于帧相同区域的关键点。该过程记录每个出现的不同偏移的计数。
如果我们让 KP 是前一帧所有关键点的集合,我们定义函数 code 和 region 将关键点映射到其代码和所属区域,我们可以识别包含两帧之间所有匹配关键点的集合 CP
我们还定义函数 distθ 将关键点对映射到它们在指定维度上的距离(posθ 是将关键点映射到其位置的一个坐标的函数)
现在我们可以构建一个序列 MP(c),其中包含可用于将当前帧的单个关键点 c 与前一帧的对应关键点进行匹配的所有偏移量
最后,所有可能偏移的序列 M 可以定义为
一旦定义了所有可能的偏移,我们就可以使用 score 函数为它们分配分数
匹配两帧时,关键点被分组到8个区域。这些区域由一个4x2的网格划分。每个区域与相邻区域有16个像素的重叠。由于关键点匹配是局部于帧区域的,所以重叠是必要的,以避免排除位于区域边界附近的关键点。如果区域中有足够多的权重为2的关键点,则只使用这些关键点。如果区域中此类关键点稀疏,匹配将切换为使用权重为1的关键点。
一旦获得所有区域的偏移分数,就为它们分配投票权。根据分数,选择每个区域中的前三个偏移。分数最高的偏移获得3个投票权,而排名第三的偏移获得1个投票权。最后,每个偏移的投票权在所有区域中求和。总投票权最大的偏移被宣布为整个帧的偏移,但前提是其总投票权值比次小值至少大4点(区域数量的一半)。这种投票权结构旨在防止单个关键点丰富的区域压倒所有其他稀疏区域,并消除在有多个投票权值相近的偏移时可能出现的歧义。通过这种方式,多个区域必须就整个帧的实际偏移达成一致,如果它们未能达成一致,则该帧将被宣布为不匹配,并生成一个新片段。
片段拼接
算法第一阶段的输出是一组片段。每个片段代表游戏世界地图的一部分,由可以匹配的连续帧构成。这些片段必须在下一步中拼接在一起。
将片段拼接成地图的过程首先将每个片段与所有其他片段进行匹配。片段匹配的工作方式与帧匹配类似。关键点识别和提取是相同的,但没有将片段分割成关键点分组区域的网格(或者换句话说,网格的大小为1x1)。匹配关键点也类似,但有不同的策略来确保过滤掉错误匹配。此过程输出一个队列,其中包含可以匹配的片段对以及计算出的偏移和匹配分数。
一旦队列构建完成,选择分数最高的对并将其片段拼接在一起。拼接通过将一个片段blit到另一个片段中完成(blit片段保留所有像素历史信息)。融合会产生一个新片段,并且两个源片段必须退役。这意味着队列必须清除所有引用其中一个源片段的对。在队列清除后,这个新片段将与剩余的片段进行匹配,匹配对将重新添加到队列中。拼接过程循环回来选择下一个分数最高的对,当队列为空时(没有剩余的片段对可以匹配),该过程停止。
匹配片段
关键点从片段中提取的方式与从帧中提取的方式相同。为了提取关键点,片段被混合以从“点”矩阵中获取本地图像。关键点不像帧匹配那样分组到区域中,这意味着片段中的所有关键点都与其他片段中的所有关键点进行比较。与帧匹配的另一个区别是,使用了所有权重的关键点。
每对匹配的关键点都会产生一个可能的匹配偏移。匹配关键点数量最多的偏移被宣布为胜者。一旦选择了偏移,就必须对其进行验证。为了验证它,必须计算两个片段的重叠区域。然后将这个感兴趣区域分割成固定大小的单元格,并且将来自两个片段的每个关键点分配给包含该关键点位置的单元格。如果感兴趣区域中至少有2/3的单元格填充了至少一个匹配的关键点,则匹配是有效的。来自两个片段都没有关键点填充的单元格从感兴趣区域的单元格总数中忽略。
我们选择一对匹配的关键点,每个片段中各一个,并将它们标记为 l0 和 r0。然后帧的偏移可以定义为
接下来我们定义谓词 match(l,r),如果第一个片段的关键点 l 匹配第二个片段的关键点 r,则为真,假设 l0 和 r0 匹配
我们还需要定义函数 celll(l) 和 cellr(r),它们将关键点映射到它所属的感兴趣区域的单元格(λ 表示单元格大小)
我们使用 dx 和 dy 来表示感兴趣区域的高度和宽度(而片段的宽度和高度分别由 widthl、widthr、heightl 和 heightr 表示)
感兴趣区域中所有单元格的集合 CO 可以定义为
如果我们有 KL 和 KR 作为我们正在匹配的这两个片段的所有关键点集,那么被忽略单元格的集 CE 可以定义为
CM 是包含至少一对匹配关键点的所有单元格的集合
现在我们已经定义了所有这些单元格集,我们可以定义谓词 valid,如果提出的偏移(Δpx, Δpy)有效则为真
如果此偏移无法验证,则片段被声明为不匹配,并且不被视为拼接候选。
kpm
命名空间也是片段匹配算法的所在地,该算法由 match
函数的另一个重载实现。
前景过滤
移动的物体会在地图图像上留下各种伪影,因此为了得到一张干净的地图,必须将它们过滤掉。
最简单的前景过滤形式由片段混合过程实现。它提取片段中每个像素最常见的颜色并将其写入图像输出。该过程从输出中消除了大部分由移动物体产生的伪影,但仍有一些由移动较慢、更静态或在更受限制空间中移动的物体留下的伪影。
拼接片段后,关于每个像素的所有历史数据都将被捕获到一个片段中。一旦所有数据都可用,就可以混合片段以获得初步背景,该背景已移除大部分移动物体。此背景图像将用于识别源帧中前景对象的轮廓。
每个片段的帧序列会再次重放,并将帧blitted到新片段中。这次,帧与原始片段的初步背景进行比较。每个不匹配的像素都被标记,并且属于这些像素的轮廓被提取和过滤掉。包含前景轮廓的整个矩形从帧中移除,并且不会blit到目标片段中。
此过程也将排除一些属于背景的轮廓。为了限制这种不希望的后果,只有大小与本地精灵大小相当的轮廓才是过滤的候选对象,所有较大的轮廓仍然会被 blitted,即使它们有不匹配的像素。
background | |
![]() | |
输入帧 | |
![]() | |
提取的前景 | 前景遮罩 |
![]() | ![]() |
这个过程是(压缩的)源帧副本保留在内存中的原因:以便可以重放序列。为了节省处理时间,每个帧在片段中的位置都被保留。片段拼接会影响这些帧相对于片段原点的位置,但每次片段拼接时,相对帧位置都会调整。这比尝试再次读取和匹配帧与片段的计算成本要低得多,但它会消耗更多内存,因为必须保留帧的内容。
命名空间 fde
包含 extractor
类,该类负责从帧中提取前景轮廓。mask
函数将从提取的轮廓,或者更确切地说,它们的包围矩形生成 blit 掩码。
fdf
命名空间具有 filter
函数,该函数将执行前景过滤。
伪影过滤
在通过上一节描述的过程去除前景之后,仍然会留下一些伪影。剩余的伪影通过确定特定大小的每个像素序列的频率来识别。对于片段图像中的每个像素,会创建一个相邻像素序列,并保留每个唯一序列的计数。只有属于同一轴的像素才用于形成序列。序列计数对两个轴都进行,并分别保留计数。然后将每个像素的数字相加,并标记那些数字较低的像素为潜在伪影。
我们将 H<x, y> 和 V<x, y> 定义为分配给 x,y 相邻像素的水平和垂直颜色序列,如下所示(我们使用 n 来表示滤波器的大小,k 是滤波器考虑到的前置或后置像素的数量)
然后我们将 P 定义为图像中所有坐标的集合(width 和 height 是片段的大小)
现在我们可以定义函数 heat(x,y),它将像素 x,y 映射到其热值
最后我们定义谓词 artifact(x,y),如果像素是潜在伪影则为真
![]() |
输入图像 |
![]() |
热图 |
![]() |
识别出的伪影 |
对于每个潜在的伪影像素,应用高斯模糊般的滤波器。此滤波器对该像素及其在片段中的邻居的所有历史数据进行模糊处理。每种颜色都分离到其自身的平面中,然后逐平面应用滤波器。一旦所有平面都被过滤,选择具有最大值的平面所代表的颜色作为像素颜色。代表目标像素历史中不存在的颜色的平面被忽略。
标准高斯函数 G(x,y) 定义为
我们需要辅助函数 count(x,y,z) 和 total(x,y),它们将像素位置映射到其历史记录:指定颜色作为像素值出现的样本数以及为像素捕获的总样本数。坐标为 (x0, y0) 的像素的每个平面 z 的值计算如下:
有了这个,我们可以计算片段“点”中所有平面的值(N 是调色板中的颜色总数)
我们从 D 中选择一个元素,使得 Di 具有最大值,并使用该元素的索引作为像素的颜色值。
arf
命名空间包含 filter
函数,该函数对片段执行过滤并生成本地图像。
轮廓提取
轮廓提取过程与原始算法相同,因此不再赘述。
ctr
和 cte
命名空间封装了处理轮廓表示和轮廓提取算法的代码。ctr::contour
类是轮廓的表示,它存储为边缘像素序列。cte::extractor
类封装了轮廓提取算法。
活动窗口扫描
活动窗口扫描过程基本保持不变。对每个连续帧进行扫描,查找与前一帧的变化,并将每个变化的像素标记在专用位图中。这样,位图随着时间积累所有变化。对于每个新帧,算法定位位图中的最大轮廓。一旦最大轮廓的面积变得稳定,其包围矩形就被声明为活动窗口。
aws
命名空间封装了活动窗口扫描功能。scan
函数遍历提供的帧流,并尝试识别窗口。
支持功能
以下章节描述了被高级算法代码(重)用的支持代码。
内存分配
对于对当前帧本地数据进行操作的算法部分,采用竞技场分配器作为另一种低级优化。对于每个帧,分配一个新的竞技场,其大小略大于任何先前帧的最高水位线。一旦不再需要,内存竞技场就会被丢弃。在某些情况下,当当前帧的处理完成后就会被丢弃。在其他情况下,竞技场必须保留到连续帧的结束(例如,当算法处理帧流并且必须匹配两帧以确定它们的偏移时)。
处理内存管理和分配器的代码位于 all
命名空间中。frame_allocator
和 memory_pool
类实现了竞技场分配器模式。memory_stack
和 memory_swing
可用于管理时间上重叠的竞技场。
常用数据类型
cdt
命名空间包含所有常用数据类型,它们表示算法各个部分中使用的空间信息。
定义了以下类型
point
- 表示二维空间坐标的类模板(通常表示图像中像素的位置)offset_t
- 表示有符号坐标点类型或增量的别名(通常表示两帧的相对位置)dimensions
- 表示二维大小的类模板(通常表示图像/片段的大小)limits
- 表示一维限制的类模板region
- 表示二维限制的类模板(通常表示帧/片段的边距或帧/片段之间的重叠区域)
矩阵表示与操作
处理矩阵的代码位于 mrl
命名空间中。matrix
是一个封装矩阵并提供一些基本操作的类。它用于以不同格式表示图像。此类型具有分配器感知能力。
图像和像素类型
cpl
命名空间定义了所有必要的像素类型,sid
命名空间包含基于这些像素类型的一些标准图像类型。
提供了多种像素格式
cpl::rgb_cc
- (RGB, component color) 单个RGB颜色分量的像素cpl::rgb_bc
- (RGB, blended color) RGB24像素cpl::rgb_pc
- (RGB, packed color) 由三个RGB分量元组表示的像素cpl::nat_cc
- (native, color coded) 像素作为本地平台上值编码的颜色代码cpl::nat_ov
- (native, ordered value) 存储本地颜色强度表中颜色索引的像素cpl::grs_iv
- (greyscale, intensity value) 像素存储强度作为浮点值cpl::mon_bv
- (monochrome, bit value) 单色像素
还有一些执行像素格式转换的实用程序(名称一目了然)
|
|
|
图像只不过是特定类型像素的矩阵。sid
有两个命名空间:mon
和 nat
。它们定义了两种最常用的图像格式:单色和本地。每个命名空间都有 dimg_t
别名,它使用默认分配器定义图像,以及 aimg_t
模板别名,它以分配器类型作为其参数。
图像压缩
icd
命名空间定义了为算法提供图像(解)压缩服务的接口,nic
命名空间包含这些接口在本地图像格式下进行(解)压缩的实现。
nic::compress
和 nic::decompress
提供了一种相当简单且效率不高的本地图像压缩方式。
icd::compressor
和 icd::decompressor
概念作为(解)压缩算法的接口提供,以便可以提供更高效的实现(至少在大小方面)。
帧馈送和地图构建器
帧馈送接口,算法其余部分依赖它作为帧源,在 ifd
命名空间中定义。命名空间 mpb
包含地图构建器。
ifd::feeder
是一个概念,它定义了被活动窗口扫描器或帧收集器用于读取帧序列的馈送器的接口。馈送器返回 ifd::frame
,其中包含本地图像和帧序列号。
mpb::builder
类位于顶层,统一了算法的所有步骤和支持类。它接受应用程序必须提供的适配器对象实例。它必须向算法提供帧馈送、图像压缩回调和其他设置。适配器还提供回调,供应用程序挂接到算法的执行中。
实用程序
nil
命名空间包含用于以本地格式存储、加载和转换图像的实用程序。另一方面,ful
命名空间包含用于处理片段的实用程序。
nil::read_raw
函数从文件加载本地图像,nil::write_png
以 PNG 格式写入本地图像。ful::read
从磁盘加载片段集合,而 ful::write
将片段集合存储到磁盘。