查看: 100|回复: 3

鸿蒙 ArkTS 瓦片超分:HALO 消接缝与显影状态竞争排障

[复制链接]
发表于 半小时前 | 显示全部楼层 |阅读模式
在 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 区互相重叠,采样集合一致,接缝在数学上就不可能存在。
  1. constexpr int HALO = 2; // 邻域余量(源像素)
  2. constexpr int SUPPORT = 3; // 核半径 → 每轴 6 tap
  3. // 感兴趣区域 = 瓦片 ± HALO,钳制在图内
  4. int px0 = ClampInt(rx - HALO, 0, sw);
  5. int py0 = ClampInt(ry - HALO, 0, sh);
  6. int px1 = ClampInt(rx + rw + HALO, 0, sw);
  7. int py1 = ClampInt(ry + rh + HALO, 0, sh);
  8. // 先整块 memcpy 出来(行拷贝快路径),再对 patch 整体重采样
  9. // 最后裁掉 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,不归一就会亮度漂移。
  1. for (int t = 0; t < SUPPORT * 2; ++t) {
  2.     int s = ClampInt(s0 + t, 0, sl - 1);
  3.     idx[d * SUPPORT * 2 + t] = s;
  4.     w[d * SUPPORT * 2 + t] = Kernel(center - (s0 + t), crisp);
  5.     sum += w[...];
  6. }
  7. for (int t = 0; t < SUPPORT * 2; ++t) w[...] /= sum; // 归一化
复制代码

显影最后一步是“定影锐化”,强度与药液联动 amount = 0.35 + crisp * 0.85:软调浴少锐化保柔和,脆调浴多锐化提细节。参数联动的意义在于用户只做一个选择,引擎负责让所有参数自洽。

四、返回协议:尺寸内嵌,让 developTile 自描述

16 块瓦片尺寸并不完全一致,边缘瓦片会被图像边界裁掉一角。如果接口只返回像素数据,调用方还得自己推算尺寸、来回对账。暗房把尺寸写进返回缓冲的头部:
  1. int32_t dims[2] = { t->outW, t->outH };
  2. memcpy(buf, dims, sizeof(dims)); // 前 8 字节:宽、高
  3. memcpy((uint8_t*)buf + sizeof(dims), t->out.data(), ...); // 其后:RGBA 像素
复制代码

ArkTS 侧用 DataView 三行解包,再交给 image.createPixelMap:
  1. const view = new DataView(out);
  2. const ow: number = view.getInt32(0, true); // little-endian
  3. const oh: number = view.getInt32(4, true);
  4. const pixels: ArrayBuffer = out.slice(8); // 像素本体
  5. const pm = await image.createPixelMap(pixels, { size: { width: ow, height: oh }, ... });
复制代码

这个“元数据前置”的约定让 developTile 成为完全自描述的接口:调用方不需要知道网格布局、不需要算边缘裁剪,拿到缓冲就知道一切。跨语言接口设计里,自描述返回值永远优于隐式约定。

五、显影节奏:串行 + 120ms,数组整体替换刷新 UI

全图计算只要 3~5ms,显影动画的节奏来自人为注入的呼吸。核心循环按行列串行执行,每块完成后等待 120ms:
  1. for (let ty = 0; ty < this.rows; ty++) {
  2.   for (let tx = 0; tx < this.cols; tx++) {
  3.     const r = await this.svc.develop(this.neg, tx, ty, TILE, bath.factor, bath.crisp);
  4.     if (r) {
  5.       const idx = ty * this.cols + tx;
  6.       const next = this.sheets.slice();
  7.       next[idx] = { pm: r.pm, ready: true };
  8.       this.sheets = next; // 替换数组触发 UI 刷新
  9.       this.developed = idx + 1;
  10.     }
  11.     await sleep(120); // 显影节拍
  12.   }
  13. }
复制代码

这里有三个刻意设计。第一,串行而非并行: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 打开签名后即可运行。
回复

使用道具 举报

发表于 半小时前 | 显示全部楼层

Re: 鸿蒙 ArkTS 瓦片超分:HALO 消接缝与显影状态竞争排障

楼主这套“暗房”比喻挺贴切的,读完有几个点印象比较深。 HALO 取 2 这个推导很清楚,核半径 3 对应支撑半径 2,取 1 会截断三次核采样窗、取 3 又白算一圈,这种“刚好够用”的余量说明是真的把边界情况算过了。之前自己做类似的分块处理时只想着多扩一点保险,结果重叠区重复计算反而拖慢了。另外你在 clamp 边界后强制归一化这一步也很关键,不然亮边会很明显。 返回协议把宽高塞进前 8 字节、后面接 RGBA,这个自描述思路很实用,边缘瓦片被裁角也不用调用方自己推算网格布局。ArkTS 侧 DataView 解包加 slice 拿走像素本体,读起来确实干净。 想请教两个地方: 一是 16 块串行加 120ms 等待,理论上约 1.92 秒,但每块的 await 和 PixelMap 创建本身也有开销,实际在低端机上的节奏会不会明显偏慢?是放在 TaskPool 里跑还是主线程 await 就行? 二是标题里提到“显影状态竞争”,正文还没讲到。像显影中途用户再点一次底片、或者切到后台再回来,这种串行循环加状态灯的状态机是怎么防止重入的?是加了个 isDeveloping 标志位,还是用令牌/序号丢弃过期结果? 期待后续把定影和状态机那部分补完。
回复 支持 反对

使用道具 举报

发表于 半小时前 | 显示全部楼层

Re: 鸿蒙 ArkTS 瓦片超分:HALO 消接缝与显影状态竞争排障

看完前四部分,思路挺顺的,尤其 HALO 取 2 的推导——不是拍脑袋定个余量,而是从核支撑半径反推出来,这个解释比很多“调参调到没缝”的说法靠谱多了。 几个想补充或讨论的点: 一、HALO 裁剪的取整。当放大倍数不是整数(比如以后想加 1.5×、3×),“cropX = (rx - px0) * factor” 这个式子会落到小数上,直接截断或四舍五入会在块边界引入亚像素偏移,接缝又会以另一种形式回来。建议把输出坐标映射也写成整数形式,比如输出瓦片起点固定为 tx*TILE*factor 的整数倍,反推源区域,保证相邻块共享同一套取整规则。 二、计算与呈现可以再解耦一层。现在串行 await,每块计算 3ms 加 120ms 等待,节奏是对的,但只要某一块偶发慢(比如首次触发 JIT 或内存抖动),节奏就会抖。更稳的做法是计算端先把 16 块全算好推进队列,呈现端用定时器按 120ms 出队,视觉完全一致,还不受单块耗时影响。16 块 720×720 的 RGBA 也就两兆出头,代价不大。 三、元数据前置这个约定我赞成,但建议头部再塞一个 magic 加版本号,哪怕现在只有宽高。以后想加 stride、色彩空间或者压缩标志,老调用方至少能识别出协议变了,而不是把新头部当像素读。另外 ArkTS 侧 out.slice(8) 是复制,瓦片多的时候可以用 new Uint8Array
回复 支持 反对

使用道具 举报

发表于 半小时前 | 显示全部楼层

Re: 鸿蒙 ArkTS 瓦片超分:HALO 消接缝与显影状态竞争排障

这个“引擎负责快、交互层负责节奏”的分工我很认同,把 3ms 的计算力主动让给交互层去铺陈,比硬堆性能更有产品感。HALO 那段解释得也清楚:Catmull-Rom 的支撑半径确实是 2,向四周扩 2 个源像素后,瓦片内部每个输出像素的 6-tap 窗口都完整落在 patch 里,接缝自然消失。 有个地方想确认:6-tap 的采样窗是以输出采样中心对称展开的 [center-2, center+3],这样 HALO=2 刚好覆盖;如果某天把核半径扩到 3 或者换成支撑更宽的核,HALO 就得跟着变成 3,否则最外一圈还是会缺像素。HALO 和 SUPPORT 最好做成同一个常量推导出来,别写成两个独立的 2 和 3,免得以后改核半径时漏掉。 权重归一化那段我踩过类似的坑,边缘处 clamp 后权重和会小于 1,不归一化就会出现整块偏暗。如果同一块 patch 会被重复采样,可以把归一化后的索引和权重一起缓存下来,按 (patch 尺寸, crisp) 做键,能省不少重复计算,尤其在 crisp 连续可调时值域很密集,做个小步长量化也够用。 元数据前置的返回协议很值得推广,自描述确实省掉了调用方的对账逻辑。后面如果要做行对齐或者加 stride,建议在头部预留一个字段和魔数,这样协议演进时老调用方也能安全拒绝。 最后想问的是标题里的状态竞争:正文到串行循环那里好像截断了。串行 awa
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-6 00:59 , Processed in 0.021370 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部