分层流水线与边缘精修
分层流水线把一张照片切成由远及近的若干层,输出 .layers(manifest 加每层一张透明图)。代码在 src/segmenter/,服务端与浏览器端共用同一份实现:线上运行在分层服务的工作线程中,图片编解码由 sharp 实现;只有部署中没有分层服务时才在浏览器中运行。模块划分见架构与模块。
流程
照片
└→ Depth Anything V2-Small ──→ 连续深度图(决定层序)
└→ 深度直方图谷底切层 ──→ 层数自适应,2~4 层
└→ BiRefNet_lite 抠主体(仅服务端)──→ 主体单独一层,深度切点只保留主体身后的
└→ 深度边缘吸附 ──→ 物体边缘变为干净的台阶
└→ 引导滤波精修 ──→ 层边界吸附到原图中真实的物体边缘
└→ 每层 alpha(同时是这一层的箔面遮罩)
└→ 逐层补全 + 镜像纹理填充 + 软边去背景色
└→ .layers ──→ 渲染器segmentToLayerSet(index.ts)依次报告以下阶段,服务端据此更新任务的 stage 并估计剩余时间:
| 阶段 | 内容 | 代码 |
|---|---|---|
loading-model | 加载深度模型 | depth.ts |
estimating-depth | 深度估计 | depth.ts |
analyzing | 计算梯度证据、在直方图上选切点 | slice.ts |
finding-subject | 抠主体并重定切点(仅当调用方提供 findSubject) | matte.ts、slice.ts |
extracting | 生成每层的 alpha 与颜色 | extract.ts、refine.ts、morph.ts |
为什么需要两种信息
每一层同时承担两个角色:带视差的画面,以及这一层箔面的遮罩。因此需要两种信息:
- 层序(谁在前面):来自单目深度估计。抠图模型只区分主体与背景,给不出三层以上的前后关系。
- 边界位置:遮罩边缘就是箔面边缘,边缘质量直接可见。主体的边界来自抠图模型(服务端);背景中的层边界来自深度。深度图在物体边缘是模糊的斜坡,并常向物体内收缩几个像素,因此需要另行精修,见边缘精修。
深度估计
深度模型为 Depth Anything V2-Small(Apache-2.0,24.8M 参数)。WebGPU 下使用 fp16 权重(约 50 MB),WASM 与服务端 CPU 上使用 q8 权重:在服务端 ARM CPU 上 q8 比 fp16 快一倍、内存少三成。深度图归一化到 0..1,1 表示最近。
权重来源依次为:构建时指定的 VITE_MODEL_HOST;本站 /models/(仅浏览器端回退时探测);Hugging Face 官方 CDN。服务端只读本地模型目录,不在运行时下载。
切层
切点落在深度直方图的谷底
层边界不均匀切分,而是落在深度直方图的谷底,即场景中没有内容的那些距离;在有内容的地方切分会把一个物体劈成两半。层数同样由数据决定,许多照片只有两层。
参数(DEFAULT_SLICE_OPTIONS) | 值 | 含义 |
|---|---|---|
minLayers / maxLayers | 2 / 4 | 层数范围。超过 4 层在卡片尺寸上看不出差别,只增加渲染开销 |
bins | 256 | 直方图桶数 |
smoothSigma | 4 | 高斯平滑强度(桶),过小时噪声会产生虚假谷底 |
minValleyProminence | 0.08 | 谷底显著性阈值。显著性 = (两侧较矮的峰 − 谷底)/ 全局最大值;以较矮的峰为准,避免大峰旁的小坑被误判 |
minSeparationBins | 18 | 相邻切点的最小间隔,避免切出薄片 |
切点按显著性贪心选取。找不到显著谷底时(例如一片连续的斜坡地面),退化为按像素数等分的等质量切分,这类切点一律标为刚性(见下文)。深度的有效跨度(去掉两端各 1% 像素后)小于 0.06 时不切层,只输出一层:纯色图或正对的墙面上强行切分,会得到整幅半透明的重影。
切点要有可见边界
纯色背景的插画上,深度模型会在无纹理区域给出一道虚构的平滑渐变,直方图在其中找到的谷底是噪声;按它切分,两层各上各的箔,接缝处出现原图中不存在的裂纹。因此每条切点都要核对原图上是否有对应的边界:
- 将原图缩到 512 像素计算亮度梯度,以全图平均梯度的 2 倍作为「有边」的阈值。
- 统计切点两侧 ±0.035(深度单位)过渡带内落在边上的像素比例,低于 3% 的切点丢弃。
刚性边界
看得见但深度上没有遮挡台阶的边界,两侧多半是同一个物体(一张脸的上下两半、平面印刷品上的人像)。分层供箔面使用没有问题,但两层不能相对滑动,否则物体会断成两截。
- 在切点的过渡带内,统计 1% 图宽邻域内深度落差超过 0.2 的像素比例。低于 12% 的切点保留,但标为刚性(
rigid)。 - 实测中平面海报、卡通、简笔画的比例为 0~4.6%,真实照片中干净的图底分离为 18.7%~40.7%,阈值位于两者之间的空档。
被刚性边界连接的相邻层构成一个视差组,共用同一视差系数。
主体单独成层
只看深度时,层边界是一条等深度线;一群人站在同一距离上时,深度连成一片,这条线无论切在哪里都会穿过人。更准确的深度模型也无法解决,因此服务端先抠出主体,由抠图决定主体边界,深度只负责前后关系与视差:
- 抠图结果先清理:面积不到最大连通块 5% 的碎块丢弃,距保留部分超过 1% 图宽的淡值(阴影、旁边的路人)清零。淡值保留的话,主体层上会出现半透明副本,去背景色时误差被放大,形成跟随主体移动的重影。
- 主体单独一层,轮廓即抠图轮廓,任何深度切点都不切开主体。
- 深度切点只保留同时满足以下条件的:位于主体身后(主体中至多 5% 的像素落在切点远侧),是真实谷底而非等质量兜底,并且去掉主体及其外围 2% 图宽后,切点近侧仍剩至少 2% 的画面。穿过主体或位于主体前方的切点不保留。
- 主体占一层后,深度切点最多
maxLayers − 2条,超出时去掉显著性最低的。 - 主体与身后一层之间是否可以相对滑动,按同样的遮挡台阶判据在主体轮廓一圈上测量。招牌上的文字、证件照上的人被抠成主体时,它们与背景处于同一平面,会被判为刚性。
- 主体的深度强制设为最近(其余层最大深度 + 0.05,上限 1),保证它排在最前、视差最大。
抠不出主体(纯风景)或在浏览器端回退处理时,只按深度切层。
主体前方的遮挡物(横在身前的绸带、前景虚化的花)目前归入背景层,视差较大时会从主体身后滑过。
视差系数与默认箔面
视差系数按视差组的平均深度归一化:最远组为 0,最近组为 1,与原图深度范围的宽窄无关。各组深度几乎相同时按组序均分;整张图是一个刚体时所有层视差为 0,分层只服务于箔面。
默认箔面按真实闪卡的印法分配(底材是箔,主体用不透明油墨覆盖所以哑光):
| 层 | 默认箔面 |
|---|---|
| 抠出的主体层 | none(哑光) |
| 与最近层同一视差组的层(无主体时) | none |
| 与最远层同一视差组的层 | sunpillar,强度 1 |
| 其余中间层 | holo,强度 0.8 |
箔面跟随视差组而非层序分配,避免同一个物体被切成几层后出现不同的箔。
生成每层
extractLayers(extract.ts)把深度图与切点转换为每层的 RGBA 图。输出最大边长为 1400 像素。
参数(DEFAULT_EXTRACT_OPTIONS) | 值 | 含义 |
|---|---|---|
feather | 0.035 | 深度羽化半宽(深度单位),平缓过渡区用 smoothstep 过渡 |
maxDimension | 1400 | 输出最大边长 |
snapRadius | 0.01 | 深度边缘吸附的窗口半径(相对图宽) |
snapThreshold | 0.12 | 邻域深度落差超过该值才视为遮挡边界并吸附 |
foregroundGrow | 0 | 前景膨胀宽度,当前不膨胀 |
fringe | 0.005 | 被更近层覆盖的区域再向外扩的宽度,这一圈的颜色不作为填充源 |
mirrorReach | 0.12 | 镜像纹理填充深入遮挡区的最大距离,更深处退化为平滑填充 |
边界羽化
层边界用羽化而不是硬阈值。硬切会把一圈背景像素粘在前景层上,视差位移时这圈脏边随主体移动。
深度边缘吸附
单目深度图在物体边缘是一段斜坡,斜坡中段的深度值恰好落入中间层的区间,在物体轮廓外形成一圈被误分的细环,移动时与物体分离成重影。吸附用形态学 toggle mapping 把陡坡压成台阶;落差小于 snapThreshold 的平缓延展面(地面等)不吸附,保留平滑过渡。
前景不膨胀
深度边界与物体真实边缘有出入,向外膨胀前景可以让物体边缘跟随物体移动;但膨胀吸入的那圈背景是不透明的,去背景色也无法消除,视差时会成为贴着轮廓的一道亮边。因此前景不膨胀,改由背景层在遮挡区外扩一圈(fringe)后再补全,保证物体边缘不留在背景层里。
有主体时的深度
主体连同外围 2% 图宽的一圈,其深度替换为离它最近的圈外像素的深度,深度切层只使用替换后的深度。深度图中主体的轮廓总比抠图宽几个像素;不替换的话,多出的一圈会被分给最近的深度层,在主体四周形成环,补全时还会延伸到主体身后。
逐层补全
前景移开后,露出的应该是紧挨着它的那一层的延续,因此每个非最前层都要补全:
- 最底层铺满整张卡,alpha 恒为 1,前景移开时不会露出透明空洞。
- 中间层:对每个被遮挡的像素,找离它最近的可见像素,该像素属于哪一层,就认为哪一层延伸到这里。
- 原本可见的部分与补出的延伸部分先合并为一张掩码,再统一做抗锯齿;分开处理会在接缝处各剩约 0.6 的 alpha,更远的层会从缝中透出。
只补最底层的话,中间层会在前景的原始轮廓处中断,画面与箔面一起断开。
镜像纹理填充
箔面靠 color-dodge 点亮底图中的亮像素,平滑色块上无法点亮。因此补进去的颜色以最近的源像素为镜面,把边界另一侧等距处的真实像素搬过来;push-pull 金字塔平滑填充只作兜底,用于镜像源越界或超出 mirrorReach 的位置。
补全不使用神经网络:视差位移只有几十像素,露出的是细条而不是大洞。服务端曾评估 LaMa(Apache-2.0):2 核 10 秒、900 MB,且主体占画面大半时会把主体颜色延伸进洞中,形成深色重影,因此未采用。
软边去背景色
半透明边缘像素的颜色是前景与身后背景的混合:C = a·F + (1 − a)·B。直接存 C 的话,头发、毛边会带着原背景的颜色一起移动。由远及近逐层合成,B 取这一层身后此刻实际垫着的颜色:
- 身后是原图(深度平缓过渡的羽化带):直接反解
F = (C − (1 − a)·B) / a,静止时逐层叠回严格等于原图。 - 身后是补出的颜色(真实物体边缘上):补的颜色只是猜测,反解会把误差放大
(1 − a)/a倍,主体身边的光晕、轮廓光或偏宽的抠图都会把 F 推到纯白,形成跟随主体的白边。这里改用 blur-fusion 邻域估计(与 BiRefNet 官方前景色估计同一方法),两级窗口:先在大窗口内求邻域前景均值 F̄ 与背景均值 B̄,再按F = C + (1 − a)·(F̄ − B̄)贴回原色,误差只乘1 − a,不会放大。
颜色由补全得到的像素(被更近层遮挡的部分)不参与去背景色。
边缘精修
边缘精修用引导滤波(guided filter,He, Sun, Tang, ECCV 2010)把层边界吸附到原图中真实的物体边缘。不需要额外下载,纯 JS 实现,对任何图片都生效。代码在 refine.ts。
原理
在每个局部窗口中,假设精修后的 alpha 是向导图的线性函数 q = a·I + b,用粗遮罩做最小二乘拟合。向导图有边缘的地方,alpha 沿该边缘跳变;向导图平坦的地方,a 趋于 0,结果退化为粗遮罩的局部均值。
在标准做法之上增加两道约束,因为这里只修正层边界这一条线,而不是做通用的边缘保持平滑:
- 只在层边界两侧的不确定带内生效,带外保持原样。
- 用局部拟合优度 R² 作为置信度。线性模型无法解释这条边界,说明原图在此处本来没有边(例如近处地面与中景地面的交界),则不修改。
| 参数 | 浏览器端(DEFAULT_REFINE_OPTIONS) | 服务端(SERVER_REFINE_OPTIONS) |
|---|---|---|
workingWidth(计算用宽度) | 520 | 1400(全分辨率) |
windowRadius(窗口半径,相对图宽) | 0.016 | 0.008 |
epsilon(正则项) | 2e-3 | 同左 |
bandRadius(不确定带半宽,相对图宽) | 0.016 | 同左 |
confidenceLow / confidenceHigh(R² 过渡区间) | 0.35 / 0.75 | 同左 |
服务端在全分辨率、更小窗口上计算,软边宽度约为浏览器端的一半,减少了视差时跟随前景移动的背景色带;代价是每张图多约 1 秒、峰值内存多约 90 MB。浏览器端保持缩小图计算,避免低内存手机因内存不足被终止。
按分界精修
精修针对「分界」而不是「层」:每条切点对应一张累积遮罩(像素比该分界更近的程度),一条分界修好后,两侧的层同时受益,alpha 天然互补。由此有两条规则:
- 层 alpha 用减法组合:第 k 层的 alpha =
A(k−1) − A(k),不能用乘法A(k−1)·(1 − A(k))。分界是嵌套的,一个被前景覆盖了 t 的边缘像素对两条分界的取值都是 t:减法给出中间层 0;乘法给出t(1−t)(最大 0.25),会在每条软边上为中间层凭空造出一圈环。各自精修后还要把嵌套关系扳回(更近的分界不超过更远的分界)。 - 同一条物体边缘上的分界共用一条精修结果:例如头部直接压在天空上、附近不存在中间层时,两条分界本应处处相等;各自精修的软边形状不一致,相减后仍会剩一圈。判据是第 k 层在附近一个不确定带范围内从未出现,则下面那条分界直接沿用上面那条。
主体轮廓直接使用抠图 alpha,不再经过引导滤波:它本身就是沿物体边缘的软边并包含发丝细节,精修只会把它拉向颜色边缘。
向导图
当前向导图为原图 RGB。任何能产出前景 alpha 的来源都可以作为额外通道接入(ExtractOptions.extraGuide),算法不需修改。
以颜色为向导有固有局限:描边颜色与背景接近时无法区分(黑色描边配深色背景时,线性颜色模型会把描边归向背景),发丝等半透明细结构也不如专门的抠图模型。
服务端抠主体(BiRefNet)
服务端用 BiRefNet_lite(MIT,onnx-community/BiRefNet_lite-ONNX,输入 1024×1024,fp32)给出主体 alpha,主体层的边界直接使用它。
浏览器端不抠主体
该模型在浏览器中无法运行,四条路径均已验证:
| 路径 | 结果 |
|---|---|
| WebGPU | 模型中有一个 16 路 Concat,单个着色器需要绑定 17 个 storage buffer,适配器上限通常为 16 |
| WASM,1024 输入 | std::bad_alloc,激活值超出 32 位寻址的 WASM 堆 |
| WASM,关闭 arena 分配器 | 同上 |
| WASM,512 / 768 输入 | 该 ONNX 导出时输入尺寸固定为 1024×1024,其他尺寸被拒绝 |
复现方法:node scripts/probe-matte.mjs --image 照片路径。浏览器端要使用它,需要重新导出模型(拆开宽 Concat,或放开输入尺寸)。
内存与启用条件
onnxruntime 的 CPU 内存池(arena)必须关闭:开启时推理峰值内存不归还系统,常驻内存持续增长到约 12 GB;关闭后峰值约 6.9 GB,推理结束回落到 0.6~0.8 GB。服务进程的内存上限(systemd MemoryMax)低于 8 GB 时不启用抠主体,只按深度切层,避免被 OOM 终止。上一次处理中崩溃的卡片续跑时也不抠主体。
选型
staging 上 16 张图、以完整版为参照、4 核 ARM 的对比:
| 型号 | 每张 | 峰值内存 | 与完整版比较 |
|---|---|---|---|
| 完整版(Swin-L),1024 输入 | 54 秒 | 8.8 GB | — |
| lite(Swin-T),1024 输入(当前使用) | 23 秒 | 6.9 GB | 主体重合度 0.885,边缘最接近;白底线稿会把人物内部判为背景 |
| 512 版,int8 量化 | 11 秒 | 4.4 GB | 主体重合度 0.885,边缘较模糊;招牌文字识别不全 |
三者训练集相同,lite 只是骨干网络较小。半精度在 ARM CPU 上反而慢四成;lite 量化为 int8 只快一成、结果相同,因此保持 fp32。
更换型号时修改 src/segmenter/matte.ts 的 MATTE_MODEL_ID 与输入边长;做对比实验时可不改代码,用环境变量 HOLOCARD_MATTE_MODEL、HOLOCARD_MATTE_SIZE、HOLOCARD_MATTE_DTYPE 指定,配合双盲对比脚本评估。
输出
流水线输出的层图为 PNG,文件名 layer-N.png,manifest 的 generator 字段记录深度模型、抠图模型、层数和每条切点的位置与显著性,便于排查切层结果。服务端写盘前将层图转为 WebP(画面有损、alpha 无损,体积约为 PNG 的十分之一),并相应改写 manifest 中的文件名。格式说明见文件格式。