HarmonyOS 交互式超分实战:指尖魔镜——手指按到哪,哪里实时超分
HarmonyOS 交互式超分实战:指尖魔镜——手指按到哪,哪里实时超分
手指按在低清图的任意位置,圆形透镜内即刻呈现该区域 4 倍超分结果,拖动即逐帧刷新;
透镜左半恒为双线性基线、右半为当前算法——同一个圆里直接对比。
全程 Native C++ 计算,单帧 1~6ms,并引入一个免训练的算法创新:自举式盲锐化参数搜索。
一、换范式:从"批处理"到"交互"
整图超分的交互模型是"提交-等待-查看":用户对结果没有掌控感,参数调一次就要重跑一次全图。
而现实中的观察需求往往是局部的、探索性的——“这块山脊到底锐不锐”、“这颗水珠什么形状”、
“这个招牌的文字可不可读”。这些需求有三个共同特征:
- 面积小:真正关心的区域通常不到整图的 5%;
- 时间短:看一眼就知道答案,不需要永久保存处理结果;
- 不确定:用户在巡视中动态决定下一步看哪里——这是典型的探索式交互,无法预先批处理。
整图超分对这三条全部无解: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 │
└────────────────────────────────────────────────────────┘
三条架构决策:
- 源图像素缓冲常驻内存(
ArrayBuffer存在 Service 里):透镜每次只需要从中抠出 64×64 的小块,
避免每帧重新readPixelsToBuffer——那一次全图拷贝本身就要几毫秒,足以吃掉整个帧预算; - 只算透镜矩形:Native 收到
(cx, cy, rl)后先memcpy裁出 patch,所有后续计算都作用在
这个小矩阵上,复杂度 O((rl·factor)²) 与整图解耦; - 左右两半 = 两次独立透镜调用:左半用双线性、右半用当前算法,各自从对应半圆的源矩形计算,
天然并行、互不等待。
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×。此处不再展开推导,
重点是②——本篇真正的新东西。

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∗=argmins∈{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}minx,y,c∑(P′(s)x,y,c−Px,y,c)2
为什么这样是合理的?三层直觉:
- 有答案才能评分。往返实验里我们知道标准答案 P,所以能客观度量"算法+参数"组合的失真——
这把超分从"开环猜测"变成了"闭环实验"; - 退化方向对齐。下采样损失高频,真实低清输入的缺陷也是高频缺失,两者的最优补偿方向一致;
下采样特意用双三次(而非最近邻),让"低清化"的频谱特征贴近真实相机采样; - 空间自适应。校准是逐透镜帧进行的:移到雪峰上 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——参数真正做到了跟着内容走。
实测数据小结
| 场景 | 算法 | 倍率 | 单帧耗时 | 观察 |
|---|---|---|---|---|
| 雪山山脊 | 双三次 | 4x | 6ms | 竖线两侧锐度差异明显 |
| 湖面倒影 | 双线性 | 2x | 1ms | 一致性自检通过 |
| 花间翠鸟 | 双三次 | 2x | 4ms | 微距纹理甜点区 |
| 老城黄昏 | DSEI+自举 | 2x | 6ms | 校准自动选 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 打包 + Promise | 1MB 拷贝 | ~0.3ms |
| PixelMap 创建(ArkTS) | 1MB | ~0.5ms |
总计 6ms 左右,距离 60fps 的 16ms 预算还有 60% 余量——这意味着:
双指双透镜(×2)、更深的级联(8x)、甚至叠加锐化曲线可视化,都在同一帧预算内装得下。
之所以这么富余,根源还是那个架构判断:输出 512×512 而不是整图的 1440×1440,
像素数差 8 倍,耗时自然差 8 倍。省算力最有效的方式从来不是优化循环,而是少算无关的像素。
进一步的空间(未做,留作排期):
- 左右两半并行化:目前两次调用串行 await,改成
Promise.all理论再省 30%; - 校准降频:s 值在相邻帧间高度相关,每 5 帧校准一次即可,再省 1ms;
- NEON 向量化横向趟:上一篇已经论证,收益 2~3 倍。
八、设计思考:视口即计算量
把这篇的架构抽象一层,其实是一个通用模式:
当"有意义的数据规模"由视口决定而非由数据总量决定时,重计算就能降维成实时交互。
超分透镜如此,很多"看起来必须离线做"的重活都适用:
- 地图渲染:只栅格化当前视野瓦片,而非全世界;
- 视频剪辑预览:只解码时间线附近的帧;
- 代码静态分析:IDE 只分析打开文件的依赖闭包;
- 大模型长文档问答:先检索视口相关段落(RAG),而非全文推理。
共同的设计要点有三条,本篇全部踩过:
- 数据常驻、视口取窗:把大数据整体放进可随机访问的缓冲(本篇是源图 ArrayBuffer),
视口变化只改变"取窗坐标",绝不重灌数据; - 请求合并与过期丢弃:交互事件产生速度 > 计算速度是常态,必须"只追最新";
- 无状态调用:视口计算做成纯函数
(数据, 窗口) → 结果,缓存、续算、并行都好做。
这套模式与上一篇文章的关系: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:把算法做活(组织成交互的方式)
后续排期:
- 双指双透镜:两只手指各带一个透镜,分别跑不同算法,对比不再只靠一条竖线;
- 透镜轨迹回放:录制一次拖动路径,导出 GIF,展示"巡视式对比"过程;
- 自举校准可视化:把 5 个候选 s 的往返误差曲线画进 UI,让"图像如何自选参数"可见;
- 相机实时透镜:数据源换成相机帧队列,变 shoot 看片神器;
- 与 sr-edge-forge 联动:透镜找到满意的区域参数后,一键推广到整图导出。
工程位置:Image-Super-Resolution/sr-magic-lens,与《图像超分实战》的 sr-edge-forge 同一系列、
共享示例图与构建规范,DevEco 打开签名后即可运行。
更多推荐


所有评论(0)