Skip to content

分层流水线与边缘精修 ​

分层流水线把一张照片切成由远及近的若干层,输出 .layers(manifest 加每层一张透明图)。代码在 src/segmenter/,服务端与浏览器端共用同一份实现:线上运行在分层服务的工作线程中,图片编解码由 sharp 实现;只有部署中没有分层服务时才在浏览器中运行。模块划分见架构与模块。

流程 ​

text
照片
 └→ 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 / maxLayers2 / 4层数范围。超过 4 层在卡片尺寸上看不出差别,只增加渲染开销
bins256直方图桶数
smoothSigma4高斯平滑强度(桶),过小时噪声会产生虚假谷底
minValleyProminence0.08谷底显著性阈值。显著性 = (两侧较矮的峰 − 谷底)/ 全局最大值;以较矮的峰为准,避免大峰旁的小坑被误判
minSeparationBins18相邻切点的最小间隔,避免切出薄片

切点按显著性贪心选取。找不到显著谷底时(例如一片连续的斜坡地面),退化为按像素数等分的等质量切分,这类切点一律标为刚性(见下文)。深度的有效跨度(去掉两端各 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)值含义
feather0.035深度羽化半宽(深度单位),平缓过渡区用 smoothstep 过渡
maxDimension1400输出最大边长
snapRadius0.01深度边缘吸附的窗口半径(相对图宽)
snapThreshold0.12邻域深度落差超过该值才视为遮挡边界并吸附
foregroundGrow0前景膨胀宽度,当前不膨胀
fringe0.005被更近层覆盖的区域再向外扩的宽度,这一圈的颜色不作为填充源
mirrorReach0.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(计算用宽度)5201400(全分辨率)
windowRadius(窗口半径,相对图宽)0.0160.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 中的文件名。格式说明见文件格式。