在 HarmonyOS 7 / API 26 上做图像超分 Demo,算法侧 180×180 输入放大 2 倍,瓦片流式引擎全图算完只要 3~5ms。但直接把结果画到屏幕上,用户看到的是“点一下、闪一下”,甚至会怀疑操作有没有生效。Image-Super-Resolution/sr-darkroom 这个“暗房”工程没有改算法,而是把计算盈余交给交互层:整图切成 4×4 共 16 块瓦片,按顺序逐块显影,每块之间留 120ms,把 3ms 的计算拉伸成约两秒钟的冲印过程。本文按“装底片、配药液、显影、定影”的流程,梳理瓦片超分的接缝处理、核函数混合、跨语言返回协议,以及 ArkTS 侧几个实际踩坑。
一、整体结构:引擎负责快,交互层负责节奏
打开应用是全黑界面,顶部一枚状态灯:待显影时是灰点,显影中转红并显示 SAFELIGHT ON,定影完成熄灭为 SAFELIGHT OFF。安全灯的明灭就是流程状态机。底片提供三个 AI 生成示例(雪山、老城、翠鸟)和相册导入,导入图长边自动压到 280;药液配方四选一,旋钮参数 crisp 同时调制插值核形状和定影锐化强度。
设计原则很明确:引擎如实快速计算,交互层按“冲印节奏”呈现。16 块瓦片每块独立计算、按顺序揭晓,块与块之间留 120ms 呼吸间隔。性能优化的目标是消灭“无意义的等待”,而显影节奏是“有意义的呈现”;引擎本身仍然是快的,大画幅 4× 全图 720×720 也不过十几毫秒,节奏完全握在交互层手里。
二、无接缝分块:HALO 余量把接缝从数学上消掉
瓦片化超分最大的坑是接缝。每块瓦片独立重采样时,边缘像素缺少邻居信息,块与块交界处会出现亮度或锐度不一致的细缝,放大后像百叶窗。暗房的做法是“带余量裁剪”:显影 (tx, ty) 号瓦片时,实际裁剪区域向四周多扩 2 个源像素(HALO),先对含 halo 的区域整体重采样,再把结果中央的瓦片部分裁出来。相邻两块瓦片的 halo 区互相重叠,采样集合一致,接缝在数学上就不可能存在。
- constexpr int HALO = 2; // 邻域余量(源像素)
- constexpr int SUPPORT = 3; // 核半径 → 每轴 6 tap
- // 感兴趣区域 = 瓦片 ± HALO,钳制在图内
- int px0 = ClampInt(rx - HALO, 0, sw);
- int py0 = ClampInt(ry - HALO, 0, sh);
- int px1 = ClampInt(rx + rw + HALO, 0, sw);
- int py1 = ClampInt(ry + rh + HALO, 0, sh);
- // 先整块 memcpy 出来(行拷贝快路径),再对 patch 整体重采样
- // 最后裁掉 halo:cropX = (rx - px0) * factor
复制代码
HALO 取 2 的原因是核支撑半径。Catmull-Rom 成分的覆盖范围是 2 个源像素,2 个源像素的余量恰好让瓦片内部每个输出像素的 6-tap 采样窗口都完整落在 patch 内。HALO 取 1 会截断三次核的采样窗,取 3 则白白多算一圈。
三、可调“药液核”:高斯与 Catmull-Rom 连续混合
暗房的核函数把两种经典核装进同一个药瓶:crisp = 0 是纯高斯核,钟形权重天生抗振铃,输出像柔焦照片;crisp = 1 是纯 Catmull-Rom,过控制点的三次曲线,边缘立挺;中间值是两种频响特性的连续混合,crisp 是真正的连续变量。
核半径取 3,每轴 6 tap。权重构建后必须归一化:高斯核在 clamp 边界处权重和会小于 1,不归一就会亮度漂移。
- for (int t = 0; t < SUPPORT * 2; ++t) {
- int s = ClampInt(s0 + t, 0, sl - 1);
- idx[d * SUPPORT * 2 + t] = s;
- w[d * SUPPORT * 2 + t] = Kernel(center - (s0 + t), crisp);
- sum += w[...];
- }
- for (int t = 0; t < SUPPORT * 2; ++t) w[...] /= sum; // 归一化
复制代码
显影最后一步是“定影锐化”,强度与药液联动 amount = 0.35 + crisp * 0.85:软调浴少锐化保柔和,脆调浴多锐化提细节。参数联动的意义在于用户只做一个选择,引擎负责让所有参数自洽。
四、返回协议:尺寸内嵌,让 developTile 自描述
16 块瓦片尺寸并不完全一致,边缘瓦片会被图像边界裁掉一角。如果接口只返回像素数据,调用方还得自己推算尺寸、来回对账。暗房把尺寸写进返回缓冲的头部:
- int32_t dims[2] = { t->outW, t->outH };
- memcpy(buf, dims, sizeof(dims)); // 前 8 字节:宽、高
- memcpy((uint8_t*)buf + sizeof(dims), t->out.data(), ...); // 其后:RGBA 像素
复制代码
ArkTS 侧用 DataView 三行解包,再交给 image.createPixelMap:
- const view = new DataView(out);
- const ow: number = view.getInt32(0, true); // little-endian
- const oh: number = view.getInt32(4, true);
- const pixels: ArrayBuffer = out.slice(8); // 像素本体
- const pm = await image.createPixelMap(pixels, { size: { width: ow, height: oh }, ... });
复制代码
这个“元数据前置”的约定让 developTile 成为完全自描述的接口:调用方不需要知道网格布局、不需要算边缘裁剪,拿到缓冲就知道一切。跨语言接口设计里,自描述返回值永远优于隐式约定。
五、显影节奏:串行 + 120ms,数组整体替换刷新 UI
全图计算只要 3~5ms,显影动画的节奏来自人为注入的呼吸。核心循环按行列串行执行,每块完成后等待 120ms:
- for (let ty = 0; ty < this.rows; ty++) {
- for (let tx = 0; tx < this.cols; tx++) {
- const r = await this.svc.develop(this.neg, tx, ty, TILE, bath.factor, bath.crisp);
- if (r) {
- const idx = ty * this.cols + tx;
- const next = this.sheets.slice();
- next[idx] = { pm: r.pm, ready: true };
- this.sheets = next; // 替换数组触发 UI 刷新
- this.developed = idx + 1;
- }
- await sleep(120); // 显影节拍
- }
- }
复制代码
这里有三个刻意设计。第一,串行而非并行:16 块瓦片完全可以用 Promise.all 一把梭,但瞬间全出就失去了冲印的叙事,串行 + 120ms 让显影成为可以注视的过程。第二,数组整体替换:ArkTS 里直接改数组元素不触发深层 UI 刷新,slice() 后整组赋值是最稳的响应式写法,ForEach 的 key 里还拼上 ready 标志强制重建该项。第三,定影独立成步:16 块显影完后,再用 tile = 整图宽一次性请求全图,绕过瓦片逻辑直接验证整图一致性,完成后 SAFELIGHT 熄灭。
六、工程取舍:为什么是 16 块,而不是 64 块
瓦片数量是这场仪式的核心变量。作者权衡过三档,结论是显影动画的块数要同时满足两个约束:单块足够大,每块浮现都是可感知事件;总数足够少,全程不超过人类耐心阈值,约 2~3 秒。4×4 的十六块恰好卡在“每块都有存在感”与“整体节奏不拖”的交点上。
另外两个显性取舍也值得记录。每块独立调用 NAPI,共 16 次异步往返,比一次请求十六块再由 UI 分批呈现多了约 0.3ms 的调度开销,换来的是引擎接口极简:单瓦片纯函数。未来如果做“分块并行 + 乱序完成”,现有接口一行不用改。底图垫 18% 透明的低清原图,而不是把未显影格子画成纯色,前者让用户在等待时保持全局方位感,等待焦虑显著更低。
七、踩坑实录:ArkTS 严格模式与 UI 对齐
坑 1:Promise 对象字面量类型。ArkTS 严格模式不允许对象字面量做类型声明,服务层方法签名里的 Promise<{ pm, w, h } | null> 直接编译失败。解法是把返回形状提升为具名接口 DevTile,顺带让“显影产出”成为一个显式概念。
坑 2:跨文件常量的导入纪律。AI_DEMOS 定义在模型文件里,服务层顺手引用却忘了 import。ArkTS 对跨文件符号的检查比 TS 严格得多,没有“恰好同名”的侥幸,修复只需一行,但值得记录。
坑 3:Grid 的模板字符串。Grid 的 columnsTemplate 要按像素逐列拼接(${cellW}px × cols),不能用 1fr 均分。因为相纸格必须与底图完全对齐,它们叠在 Stack 里,1fr 在奇数尺寸下会产生亚像素错位,瓦片之间会露出底图的“未显影”边线。
坑 4:定影与显影的状态竞争。用户在显影进行中切换底片或药液会打断流程。解法是三态门闩:developing 为 true 时药液与底片按钮全部 enabled(false),显影循环内部不再读共享状态,进循环前先把 bath 快照成局部变量。长耗时异步过程必须自带状态快照,不能信任 UI 状态在过程中不变。
八、实测与验证:接缝消失,插值保持诚实
定影后的雪山 2× 输出 360×360,雪峰棱线清晰、湖面过渡平滑。重点看瓦片交界处:山脊线横跨四块瓦片,没有亮度台阶或错位,这就是 HALO 余量的价值,接缝在数学上被消灭,而不是被掩盖。显影中的 10/16、9/16 状态也验证了交互层设计:已显影区域与暗红纸面的边界随时间推进,未显影区域垫着 18% 透明度的低清原图,用户能“隐约看见将来会是什么”。
大画幅 4× 把输出推到 720×720。180px 输入的 4× 已明显接近插值信息天花板,羽毛丝缕开始发虚,但水珠的圆形轮廓与色彩过渡依然成立。插值的诚实之处在于它只重组已知信息,绝不编造。
九、方法论:把一次大计算拆成一队小计算
暗房补的是时间轴。当计算快于感知,就把计算的时间盈余还给体验:3ms 的算法配 2s 的显影不是浪费,那 1.995 秒的差值买的是用户对“引擎在做事”的确定感与“看着照片诞生”的参与感。反过来,当计算慢于感知,同样的瓦片架构可以无缝切换成真实进度条,块级结果先到先显示。瓦片架构的真正价值也在这里:它把“一次大计算”解构成“一队小计算”,调度策略变成纯交互层的选择题,引擎完全不用改。
后续排期包括显影液温度(显影过程中 crisp 从 0 缓动到目标值)、接触印相(保留 16 块瓦片边框,点击回看该区域 4×)、药液配方存档(利用无状态引擎把配方编码成 8 字符字符串分享)以及真实照片流。工程位置在 Image-Super-Resolution/sr-darkroom,DevEco 打开签名后即可运行。 |