HarmonyOS 交互式超分实战:指尖魔镜——手指按到哪,哪里实时超分

手指按在低清图的任意位置,圆形透镜内即刻呈现该区域 4 倍超分结果,拖动即逐帧刷新;
透镜左半恒为双线性基线、右半为当前算法——同一个圆里直接对比。
全程 Native C++ 计算,单帧 1~6ms,并引入一个免训练的算法创新:自举式盲锐化参数搜索。

一、换范式:从"批处理"到"交互"

整图超分的交互模型是"提交-等待-查看":用户对结果没有掌控感,参数调一次就要重跑一次全图。
而现实中的观察需求往往是局部的、探索性的——“这块山脊到底锐不锐”、“这颗水珠什么形状”、
“这个招牌的文字可不可读”。这些需求有三个共同特征:

  1. 面积小:真正关心的区域通常不到整图的 5%;
  2. 时间短:看一眼就知道答案,不需要永久保存处理结果;
  3. 不确定:用户在巡视中动态决定下一步看哪里——这是典型的探索式交互,无法预先批处理。

整图超分对这三条全部无解:5% 的需求付出 100% 的算力,等 500ms 只为看一眼,巡视路径更无法预知。

指尖魔镜把问题倒过来:

整图超分(上一篇)透镜超分(本篇)
计算单位整幅图(如 720×720)透镜小窗(64×64 源区域)
响应方式提交后等待数百毫秒跟手逐帧,1~6ms
参数调整改一次跑一次全图透镜内即时生效
对比方式两张结果图来回切换同一圆内左右分屏
信息探索被动接收整图主动巡视任意区域
算力分配均匀撒在整图集中在用户注意点

关键洞察是:交互式超分的计算量只与透镜大小相关,与整图无关。
64×64 的源区域放大 4 倍到 256×256,即使纯 CPU 单线程也是毫秒级——
这个数量级差异让"实时跟手"成为可能,也让"改参数立即看效果"成为可能。
在这里插入图片描述

二、交互设计:一镜两世界

透镜不是一个普通的放大镜,它内部被竖线一分为二:

        ┌─────────────┐
      /   双线性基线  ╲        双线性:所有插值算法的"地板"
     │   (左半圆)     │       当前算法:DSEI/双三次 + 自适应锐化
     │─────────┼───────│      竖线两侧 = 同一区域、同一时刻、两种算法
      ╲   当前算法    /
        └─────────────┘
  • 左半恒为双线性基线——它是所有算法的下限参照,永不改变;
  • 右半是当前算法(双三次 / DSEI+自举锐化),切换算法透镜内容即时刷新;
  • 按住图面拖动,透镜以 1:1 视差跟随;松手后透镜停留在原地,还可以单独拖动透镜微调位置;
  • 状态栏实时显示右半算法与耗时,DSEI 模式下还会显示自举校准出的锐化强度 s 值。

这个设计解决了一个评测心理学问题:人眼对"并排同位"的差异最敏感。
两张分开的图,差异再大也容易看漏;同一个圆里一条竖线两边,1px 的锐度差都藏不住。
这也是相机评测行业普遍使用同屏分割对比的原因——我们把同样的方法论搬进了超分交互。

主界面:示例图切换(AI 生成三张 + 相册)、透镜右半算法、放大倍数三个选项区全部内联响应式样式,
底部状态栏实时汇报引擎状态。

三、架构:为什么透镜方案可行

┌─────────────────── ArkTS(UI 线程)───────────────────┐
│  Index.ets      onTouch → 换算源坐标 → 双透镜请求        │
│  LensService.ets 源图缓冲常驻 · lens() · lastParam()    │
└───────────────┬───────────────────────────────────────┘
                │ libentry.so(NAPI Promise → 异步工作线程)
┌───────────────▼───────────────────────────────────────┐
│  napi_init.cpp                                         │
│   lens(): 取 2rl×2rl 区域 → SR 放大 → 自举锐化(可选)     │
│   calibrate(): 往返一致性搜索最优锐化强度 s              │
└────────────────────────────────────────────────────────┘

三条架构决策:

  1. 源图像素缓冲常驻内存(ArrayBuffer 存在 Service 里):透镜每次只需要从中抠出 64×64 的小块,
    避免每帧重新 readPixelsToBuffer——那一次全图拷贝本身就要几毫秒,足以吃掉整个帧预算;
  2. 只算透镜矩形:Native 收到 (cx, cy, rl) 后先 memcpy 裁出 patch,所有后续计算都作用在
    这个小矩阵上,复杂度 O((rl·factor)²) 与整图解耦;
  3. 左右两半 = 两次独立透镜调用:左半用双线性、右半用当前算法,各自从对应半圆的源矩形计算,
    天然并行、互不等待。

NAPI 接口刻意收敛到两个函数:

// 取 (cx,cy) 处 2rl×2rl 区域,放大 factor 倍,返回 2rl*factor 见方 RGBA
lens(src, sw, sh, cx, cy, rl, factor, algo): Promise<ArrayBuffer>
// 最近一次 DSEI 自举校准得到的锐化强度 s(UI 展示用)
lastParam(): number

接口小是有意为之:交互式引擎的 API 一旦暴露太多状态(矩阵、缓存句柄、回调),
ArkTS 与 Native 的边界就会变成性能瓶颈和 bug 温床。无状态的单次调用最容易被正确使用。

四、算法详解:透镜管线与自举式盲锐化

4.1 透镜管线全貌

一次透镜调用在 Native 内部经历五步:

① 裁剪     src → patch(2rl×2rl,memcpy 行拷贝)
② 校准     patch → 往返实验 → 最优锐化 s(仅 DSEI 模式)
③ 放大     patch → SR 级联(双线性/双三次可分离卷积;DSEI 逐级 2x)
④ 锐化     base += s · (base − gauss3(base))(非锐化掩模)
⑤ 打包     结果 → ArrayBuffer → Promise resolve

③ 中的算法与上一篇 sr-edge-forge 同源:双线性 2-tap、双三次 Catmull-Rom 4-tap 均为按轴分离的
重采样;DSEI(边缘导向)只支持 2 的幂倍率,靠逐级 2x 级联覆盖 2/4/8×。此处不再展开推导,
重点是②——本篇真正的新东西。
DevEco 全景:工程与模拟器实机运行

4.2 自举式盲锐化:把输入当作参照系

插值类超分有一个绕不开的矛盾:不锐化则糊,锐化过头则脏。锐化强度 s 传统上靠人手调——
但每个区域、每张图的最优 s 都不一样,全局一个 s 必然顾此失彼。

指尖魔镜的做法是:让图像自己回答"该锐化多少"。利用往返一致性(round-trip consistency):

透镜 patch P (64×64,已知答案)
   │  ① 双三次下采样 factor 倍(模拟"低清化",即逆过程)
   ▼
tiny (16×16)
   │  ② 用与透镜完全相同的算法 + 候选锐化 s,放大回 64×64
   ▼
P'(s)  ──与 P 逐像素比较 SSE──▶  误差最小的 s 即为该区域最优锐化强度

s∗=arg⁡min⁡s∈{0, 0.4, 0.8, 1.2, 1.6}∑x,y,c(P′(s)x,y,c−Px,y,c)2s^* = \arg\min_{s \in \{0,\,0.4,\,0.8,\,1.2,\,1.6\}} \sum_{x,y,c}\big(P'(s)_{x,y,c} - P_{x,y,c}\big)^2s∗=args∈{0,0.4,0.8,1.2,1.6}min​x,y,c∑​(P′(s)x,y,c​−Px,y,c​)2

为什么这样是合理的?三层直觉:

  1. 有答案才能评分。往返实验里我们知道标准答案 P,所以能客观度量"算法+参数"组合的失真——
    这把超分从"开环猜测"变成了"闭环实验";
  2. 退化方向对齐。下采样损失高频,真实低清输入的缺陷也是高频缺失,两者的最优补偿方向一致;
    下采样特意用双三次(而非最近邻),让"低清化"的频谱特征贴近真实相机采样;
  3. 空间自适应。校准是逐透镜帧进行的:移到雪峰上 s 可能是 1.2,移到失焦光斑上就是 0——
    参数跟着内容走,而不是跟着全局走。

它也有明确的假设与边界(诚实声明):

  • 参数迁移假设:假设在"factor 倍往返"上最优的 s,对"真实 factor 倍放大"同样最优。
    当低清输入的退化模型(相机模糊/噪声/JPEG 压缩)与我们的模拟下采样差异大时会有偏差;
  • SSE 不等于感知质量:误差最小 ≠ 最好看,过平滑的区域 SSE 天然偏爱小 s;
  • 因此 s 结果更适合作为"起点"而非"终点"——这正是 UI 上把 s 值显示出来、并保留手选算法的原因。

这是盲超分(blind SR)思想的一个轻量级、可实时化的实现:深度学习方案用网络估计退化核,
我们用五次对照实验穷举一个标量——在 1ms 的预算里,简单方法往往已经够用。

4.3 非锐化掩模的实现细节

锐化本身用经典 USM(Unsharp Mask),高斯核取 [1,2,1]/4 按轴分离:

out=base+s⋅(base−gauss3(base))\text{out} = \text{base} + s \cdot (\text{base} - \text{gauss}_3(\text{base}))out=base+s⋅(base−gauss3​(base))

两个工程细节:

  • 中间模糊结果用 double 暂存,避免 8bit 舍入让锐化噪声翻倍;
  • 输出必须 clamp 回 [0,255],USM 在强边缘处的过冲很容易溢出(表现为边缘白边/黑边)。

五、核心代码走读

5.1 Native:patch 裁剪与主流程

void ExecuteLens(napi_env env, void* data) {
    LensTask* t = static_cast<LensTask*>(data);
    // 参数与缓冲校验 → DSEI 要求 factor 为 2 的幂
    ...
    // ① 裁剪:透镜矩形钳制进图内后按行 memcpy(快路径)
    int rx = ClampInt(t->cx - t->rl, 0, t->sw - 2 * t->rl);
    int ry = ClampInt(t->cy - t->rl, 0, t->sh - 2 * t->rl);
    int m = 2 * t->rl;
    std::vector<uint8_t> patch(m * m * 4);
    for (int y = 0; y < m; ++y) {
        memcpy(&patch[y * m * 4], &t->src[((ry + y) * t->sw + rx) * 4], m * 4);
    }
    // ② 校准(仅 DSEI):往返一致性搜索最优 s
    if (t->algo == ALGO_DSEI) {
        t->param = Calibrate(patch, m, t->factor, t->algo);
        g_lastParam = t->param;
    }
    // ③④ 放大 + 应用 s 的非锐化掩模
    UpscaleFactor(patch, m, m, base, outSize, outSize, t->algo);
    if (t->algo == ALGO_DSEI && t->param > 0.0) {
        Unsharp(base, outSize, outSize, t->param);
    }
    t->out.swap(base);
}

注意 ExecuteLens 里没有任何 NAPI 调用——工作线程不能碰 JS 句柄,
结果全部装进 LensTask,等 CompleteLens 回到 JS 线程再 napi_create_arraybuffer 打包。
这是 NAPI 异步的铁律,违反它就是随机崩溃。

5.2 Native:自举校准全文

double Calibrate(const std::vector<uint8_t>& patch, int m, int factor, int algo) {
    int tiny = m / factor;
    if (tiny < 4 || tiny * factor != m) return 0.0;      // 尺寸守恒才做实验
    std::vector<uint8_t> down;
    ResizeSeparable(patch, m, m, down, tiny, tiny, ALGO_BICUBIC);  // 低清化
    const double kCand[5] = {0.0, 0.4, 0.8, 1.2, 1.6};
    double bestS = 0.0, bestErr = 1e18;
    for (int ci = 0; ci < 5; ++ci) {
        std::vector<uint8_t> up;
        if (algo == ALGO_DSEI) {                          // 与透镜同算法同级联
            std::vector<uint8_t> cur = down;
            for (int k = 0; k < factor / 2; ++k) {
                std::vector<uint8_t> nxt;
                EdgeUpscale2x(cur, tiny << k, tiny << k, nxt);
                cur.swap(nxt);
            }
            up.swap(cur);
        } else {
            ResizeSeparable(down, tiny, tiny, up, m, m, algo);
        }
        Unsharp(up, m, m, kCand[ci]);
        double err = 0.0;                                 // 与"答案"逐像素比较
        for (size_t i = 0; i < patch.size(); i += 4)
            for (int c = 0; c < 3; ++c) {
                double d = (double)up[i + c] - (double)patch[i + c];
                err += d * d;
            }
        if (err < bestErr) { bestErr = err; bestS = kCand[ci]; }
    }
    return bestS;
}

刻意的设计:校准里的放大路径与真实透镜完全同构(同算法、同级联),
保证实验结论可迁移;透明通道不参与误差(信息量为零);tiny < 4px 时放弃校准——
小到没有统计意义的实验不如不做。

5.3 ArkTS:触摸驱动与请求合并

private onTouchAt(px: number, py: number): void {
    if (!this.src || this.busy) return;
    const scale = VIEW_W / this.src.w;
    const lensSrcR = Math.max(8, Math.round((LENS_VIEW_SIZE / 2) / scale));
    // 透镜中心钳制在图内(UI 层第一道防线)
    const cxVp = Math.min(Math.max(px, LENS_VIEW_SIZE / 2), VIEW_W - LENS_VIEW_SIZE / 2);
    ...
    this.lastReq++;              // 每次触摸刷新请求号
    if (this.pending) return;    // 忙则只更新坐标,不入队
    this.pending = true;
    this.updateLens(req, cxVp / scale, cyVp / scale, lensSrcR);
}

private async updateLens(req, cxSrc, cySrc, rl) {
    const left  = await this.svc.lens(..., ALGO_BILINEAR);  // 左半基线
    const right = await this.svc.lens(..., this.algo);      // 右半当前算法
    this.pending = false;
    if (req !== this.lastReq) return;                       // 过期请求丢弃
    this.lensL = left.pm;
    this.lensR = right.pm;                                  // 状态更新即渲染
}

两个半圆各一次 Native 调用;返回后先校验请求号,过期请求直接丢弃——
透镜永远朝最新位置追赶,绝不回放历史轨迹。这就是"触摸比引擎快"问题的完整解法。

5.4 UI:两个矩形拼一个圆

Row({ space: 2 }) {
    Image(this.lensL).width(LENS_VIEW_SIZE / 2).height(LENS_VIEW_SIZE)
    Image(this.lensR).width(LENS_VIEW_SIZE / 2).height(LENS_VIEW_SIZE)
}
.borderRadius(LENS_VIEW_SIZE / 2)
.clip(true)                                          // 圆形裁切
.border({ width: 2, color: '#5EEAD4' })              // 玻璃描边
.shadow({ radius: 12, color: '#5EEAD466' })
.position({ x: this.lensXPx, y: this.lensYPx })      // 跟手定位

不需要自绘组件:左右两个 Image 各绑一个 PixelMap,外层圆角裁切即成透镜,
描边与阴影补足"物理镜片"的质感。底图 interpolation(None) 强制像素风,
让低清底图与透镜内的超分结果形成最强烈的质感反差。

六、模拟器实测与逐帧数据

测试环境:Mate 90 Pro 模拟器(HarmonyOS 7.0 / API 26,x86_64),透镜源区域 64×64。

雪山山脊:双三次 6ms

透镜压在雪峰山脊上,右半切到双三次:6ms/帧。竖线左侧(双线性)山脊明显发糊,
右侧棱线更立——这就是并排对比的直观性,看一眼就能判断算法差异值不值得。

湖面倒影:双线性 1ms

2x 模式压在湖面倒影上,左右同为双线性做一致性自检:1ms/帧——透镜方案的速度下限。
左右完全一致说明管线(裁剪→放大→回传→渲染)没有引入任何不对称误差,
透镜的开销主要在算法本身而非框架。

花间翠鸟:2x 双三次 4ms

微距场景是透镜交互的甜点区:水珠边缘、花瓣脉络、羽毛纹理在透镜里逐区巡视,
比整图放大好玩得多——探索欲本身就是这种交互形态的产品价值。

老城黄昏:DSEI 自举锐化 s=0.0 · 6ms

这张截图最有讨论价值:老城夜景光斑弥散、景深模糊,自举校准给出的结果是 s=0.0——
“这个区域不要锐化”。这不是算法偷懒:对失焦的光斑强行锐化只会放大噪点,
往返一致性实验里 s=0 的误差本来就最小。参数搜索代替了人手调参,而且做出了和人一样的判断。
换到前一张的山脊上,同一个引擎就会给出非零的 s——参数真正做到了跟着内容走。

实测数据小结

场景算法倍率单帧耗时观察
雪山山脊双三次4x6ms竖线两侧锐度差异明显
湖面倒影双线性2x1ms一致性自检通过
花间翠鸟双三次2x4ms微距纹理甜点区
老城黄昏DSEI+自举2x6ms校准自动选 s=0.0

七、性能剖析:16ms 预算的分配艺术

以最重的 4x 透镜(64×64 → 512×512 输出)为例,逐项拆账:

阶段计算量耗时量级
patch 裁剪(memcpy 64 行)16KB<0.1ms
自举校准(5 组小图实验)5×(16×16→64×64)~1ms
SR 放大(可分离卷积/级联)512×512 输出3~5ms
USM 锐化512×512×3 通道~0.5ms
ArrayBuffer 打包 + Promise1MB 拷贝~0.3ms
PixelMap 创建(ArkTS)1MB~0.5ms

总计 6ms 左右,距离 60fps 的 16ms 预算还有 60% 余量——这意味着:
双指双透镜(×2)、更深的级联(8x)、甚至叠加锐化曲线可视化,都在同一帧预算内装得下。

之所以这么富余,根源还是那个架构判断:输出 512×512 而不是整图的 1440×1440,
像素数差 8 倍,耗时自然差 8 倍。省算力最有效的方式从来不是优化循环,而是少算无关的像素。

进一步的空间(未做,留作排期):

  1. 左右两半并行化:目前两次调用串行 await,改成 Promise.all 理论再省 30%;
  2. 校准降频:s 值在相邻帧间高度相关,每 5 帧校准一次即可,再省 1ms;
  3. NEON 向量化横向趟:上一篇已经论证,收益 2~3 倍。

八、设计思考:视口即计算量

把这篇的架构抽象一层,其实是一个通用模式:

当"有意义的数据规模"由视口决定而非由数据总量决定时,重计算就能降维成实时交互。

超分透镜如此,很多"看起来必须离线做"的重活都适用:

  • 地图渲染:只栅格化当前视野瓦片,而非全世界;
  • 视频剪辑预览:只解码时间线附近的帧;
  • 代码静态分析:IDE 只分析打开文件的依赖闭包;
  • 大模型长文档问答:先检索视口相关段落(RAG),而非全文推理。

共同的设计要点有三条,本篇全部踩过:

  1. 数据常驻、视口取窗:把大数据整体放进可随机访问的缓冲(本篇是源图 ArrayBuffer),
    视口变化只改变"取窗坐标",绝不重灌数据;
  2. 请求合并与过期丢弃:交互事件产生速度 > 计算速度是常态,必须"只追最新";
  3. 无状态调用:视口计算做成纯函数 (数据, 窗口) → 结果,缓存、续算、并行都好做。

这套模式与上一篇文章的关系:sr-edge-forge 解决"单次计算的品质与速度",
sr-magic-lens 解决"计算如何组织成交互"。两块拼起来,才是一个完整端侧超分产品的两个侧面。

九、踩坑实录(四个)

坑 1:透镜取景窗越界

透镜中心贴近图像边缘时,cx ± rl 会越界读到脏数据。解法是把透镜中心钳制在
[rl, W - rl] 区间内(UI 层做一次、Native 层再兜底一次),保证取景窗永远完整落在图内。
双份钳制看似冗余,实则必要——UI 层钳的是屏幕体验,Native 层钳的是数据安全,
不能因为"上面已经钳过了"就省掉底层防御。

坑 2:NAPI 占位符拼接的 CRLF 陷阱

引擎源码分四段写入时用 string.Replace("// __P2__", ...) 拼接,占位符纹丝不动——
文件是 CRLF 行尾,而匹配串是 LF。改用正则 (?m)^\s*// __P2__\s*$ 一次通过。
教训:跨平台文本处理永远别用裸 \n 做行级匹配,要么用正则的多行模式,要么先统一行尾。

坑 3:跨文件类型忘了导出

LensFrame 接口定义在 LensService.ets,页面直接用了却没 import,ArkTS 编译器直接报
“Cannot find name”。ArkTS 的类型检查比 TS 严格——接口也必须显式导入,没有 TS 那种
"恰好同构就兼容"的侥幸。

坑 4:@Builder 的教训延续

上一篇已经踩过 @Builder 按值传参选中态卡死的坑(样式参数只在首帧求值),
本篇 UI 从第一行起就全部内联三元表达式,一次编译通过且交互即时刷新。
系列开发的最大红利就是坑只踩一遍——前提是每篇都把坑写下来。

十、FAQ

Q:为什么不用 OpenGL / XComponent 做渲染,而用 Image + PixelMap?
A:透镜输出只有 256×256 ~ 512×512,createPixelMap + Image 组件的路径实测 0.5ms 级,
远够 60fps。GPU 纹理直传(NativeImage/OH_NativeXComponent)当然是更"正确"的管线,
但它引入 EGL 上下文管理、纹理生命周期等一大坨复杂度——在 CPU 计算只要几毫秒的前提下,
先用最简单的路径验证交互范式,才是正确的工程顺序。

Q:自举校准为什么用 SSE 而不是 SSIM?
A:SSIM 更贴近感知,但计算量是 SSE 的十几倍,而校准在每一透镜帧里都要跑 5 遍。
在 1ms 预算内 SSE 是唯一选择;它的"偏爱平滑"倾向恰好被候选集里的 0 值兜底——
最差情况就是"不锐化",不会更糟。

Q:透镜拖过整图,会有残留或卡顿吗?
A:不会。透镜是纯函数式的:每次调用只依赖 (源缓冲, 窗口坐标),没有任何跨帧状态,
任何位置的结果都一样干净。内存方面,每帧的临时 buffer 都在 Native 栈上分配回收,
ArkTS 侧只保留最新两个 PixelMap 引用,旧帧会被 GC 正常回收。

Q:8x 透镜的画质怎么看?
A:8x 级联三次 DSEI,结构感仍在,但 180px 输入的 8x 已经明显越过插值信息天花板,
适合看轮廓而非细节。严肃的大倍率需求应该接 AI 方案——透镜架构对此完全开放:
algo 再加一个枚举值调 NPU 即可,管线其余部分原封不动。

Q:能直接用到相机预览上吗?
A:架构上只差数据源:把"源图缓冲常驻"换成相机帧队列,透镜 API 一个字都不用改。
但相机帧 30fps 连续写入、缓冲轮转、与 VSync 对齐是另一篇的体量了,列为后续排期。

十一、总结与展望

项目结果
交互范式触摸即超分,透镜 1:1 视差跟随
单帧耗时1~6ms(透镜 64×64 源区域,模拟器)
对比设计透镜内左右分屏:基线 vs 当前算法
算法创新自举式盲锐化:往返一致性搜索 s,免训练、逐区域自适应
帧预算6ms / 16ms(60fps),余量 60%
代码量Native ~450 行 · ArkTS ~350 行

这个项目验证了一个更普适的判断:当计算量与视口而非数据量挂钩时,“重算法"就能变成"实时交互”。
超分如此,风格迁移、去噪、HDR 皆可套用同一架构。
它也和上一篇形成了完整的方法论闭环:

sr-edge-forge:把算法做对(品质与速度的极限)
sr-magic-lens:把算法做活(组织成交互的方式)

后续排期:

  1. 双指双透镜:两只手指各带一个透镜,分别跑不同算法,对比不再只靠一条竖线;
  2. 透镜轨迹回放:录制一次拖动路径,导出 GIF,展示"巡视式对比"过程;
  3. 自举校准可视化:把 5 个候选 s 的往返误差曲线画进 UI,让"图像如何自选参数"可见;
  4. 相机实时透镜:数据源换成相机帧队列,变 shoot 看片神器;
  5. 与 sr-edge-forge 联动:透镜找到满意的区域参数后,一键推广到整图导出。

工程位置:Image-Super-Resolution/sr-magic-lens,与《图像超分实战》的 sr-edge-forge 同一系列、
共享示例图与构建规范,DevEco 打开签名后即可运行。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐