在 HarmonyOS 7 / API 26 上做图像超分,常见做法是把插值核做成三档离散开关,或者直接封装成“一键美化”。SR CONSOLE 换了个思路:把超分做成一台可演奏的合成器——四根竖置推子实时驱动参数化核函数,拨动即全图重渲染,按住 HOLD A/B 随时切回原始信号对比。工程位于 Image-Super-Resolution/sr-console,实测环境为 Mate 90 Pro 模拟器(HarmonyOS 7.0 / API 26)。
一、面板布局与渲染链路
界面从上到下分为四层:300 宽的显示窗强制最近邻插值,故意保留低清“音源”的颗粒感;右上角 LED 状态条用等宽字体回报引擎状态,41MS OK 为绿灯,异常转红;KERNEL、DENOISE、SHARPEN、GLOW 四根竖置推子,顶端数码管显示当前值;底部是 PORTRAIT、DOC、NIGHT 三颗预设芯片、OUTPUT 2×/3×/4× 输出选择,以及 HOLD A/B 监听键。音源可选内置的雪山、老城、翠鸟三张 180×180 图像,也可从相册导入,长边自动压到 280。
渲染链路的顺序是刻意排的:先做核函数变形,再做中值去噪,然后 USM 锐化,最后盒模糊柔焦。柔焦放在最后,是为了让锐化抬起来的高频“收”一点,避免数码味过重。实测 180×180 到 2× 约 40ms,4× 且 GLOW 推满时约 68ms,仍在实时预算内。
二、把插值核变成连续变量
KERNEL 推子的本质是一个混合系数:推到 0 是纯双线性,推到 1 是纯 Catmull-Rom,推到 0.5 则得到“半软半硬”的混合核。锐度不再是档位,而是无级旋钮。核函数只做三角波与三次曲线的线性混音:
- double Kernel(double x, double blend) {
- double ax = std::fabs(x);
- double tri = std::max(0.0, 1.0 - ax); // 双线性成分
- double cr = 0.0; // Catmull-Rom 成分
- if (ax < 1.0) {
- cr = 1.5 * ax * ax * ax - 2.5 * ax * ax + 1.0;
- } else if (ax < 2.0) {
- cr = -0.5 * ax * ax * ax + 2.5 * ax * ax - 4.0 * ax + 2.0;
- }
- return (1.0 - blend) * tri + blend * cr;
- }
复制代码
每次渲染按目标尺寸为横竖两根轴重建 4-tap 权重表,覆盖半径 2,并对权重做归一化:
- void BuildTaps(int srcLen, int dstLen, double blend, Taps* taps) {
- for (int d = 0; d < dstLen; ++d) {
- double center = (d + 0.5) * srcLen / dstLen - 0.5; // 半像素对齐
- int s0 = (int)std::floor(center) - 1;
- double sum = 0.0;
- for (int t = 0; t < 4; ++t) {
- taps[d].idx[t] = ClampInt(s0 + t, 0, srcLen - 1);
- taps[d].w[t] = Kernel(center - (s0 + t), blend);
- sum += taps[d].w[t];
- }
- for (int t = 0; t < 4; ++t) taps[d].w[t] /= sum; // 亮度不漂移
- }
- }
复制代码
这里有两个不能省的细节。第一是半像素对齐:直接用角点对齐会让整图偏移半个源像素。第二是权重归一化:混合核在边界处权重和会偏离 1,不归一就会出现亮度漂移。权重表每次渲染重算,但 dstLen 只有几百,相对主循环可以忽略。
三、后级效果器:去噪、锐化、柔焦
DENOISE 推子把结果向 3×3 中值滤波混合。每通道取 3×3 邻域中值,9 个元素用插入排序,比通用排序快一个量级;推子值控制原信号与中值信号的线性混合比例:
- if (denoise > 0.0) {
- std::vector<uint8_t> med(out);
- Median3(med, dw, dh);
- for (size_t i = 0; i < out.size(); i += 4)
- for (int c = 0; c < 3; ++c)
- out[i + c] = ClampByte(
- out[i + c] * (1.0 - denoise) + med[i + c] * denoise);
- }
复制代码
SHARPEN 推子做 USM 锐化,直接抬高频。这里要注意索引:模糊缓冲按“像素×3 通道”布局,混回 RGBA 缓冲时要做 i/4*3+c 的换算,否则容易写出越界崩溃:
- Box3(out, dw, dh, blur);
- for (size_t i = 0; i < out.size(); i += 4)
- for (int c = 0; c < 3; ++c) {
- size_t bi = i / 4 * 3 + c; // 像素索引换算!
- out[i + c] = ClampByte(out[i + c] + sharpen * (out[i + c] - blur[bi]));
- }
复制代码
GLOW 推子与锐化互为镜像:不做减法做加法,把低频以 glow * 0.7 的比例混回,高光晕开、结构保留。它也是信号链里最贵的一级,4× 输出下耗时明显上升,但仍在可接受范围。
四、渲染泵:推子甩得再快也不糊
竖置滑杆的 onChange 每秒能触发几十次,而一次渲染要几十毫秒。如果每次事件都排队渲染,画面会严重滞后。合成台用“脏标记 + 泵”把密集事件合并成连续帧:
- private dirty = false;
- private pumping = false;
- private schedule(): void {
- this.dirty = true; // 每次拨动只标记脏位
- if (this.pumping) return; // 泵在工作?等它来取最新参数
- this.pumping = true;
- this.pump();
- }
- private async pump(): Promise<void> {
- while (this.dirty) {
- this.dirty = false;
- const f = await this.svc.render(...); // 取的是当前最新参数
- this.frame = f; // 状态更新即渲染
- }
- this.pumping = false;
- }
复制代码
效果是:无论推子甩多快,荧幕永远朝最终位置收敛,中间帧自动被跳过。松手的瞬间,画面上就是最终参数的结果,不会回放历史帧。
五、预设芯片与无状态引擎
三颗预设芯片只是“推子位置的快照”。按下后四根推子立即跳到对应位置,用户还能继续微调,这比“一键美化”的黑盒友好得多。PORTRAIT 偏柔焦人像,DOC 偏文档锐利,NIGHT 偏夜景降噪,核心实现就是把推子拨到指定值:
- private applyPreset(kind: number): void {
- if (kind === 0) { // PORTRAIT:柔焦人像
- this.blend = 0.35; this.denoise = 0.25;
- this.sharpen = 0.55; this.glow = 0.4;
- } else if (kind === 1) { // DOC:文档锐利
- this.blend = 0.9; this.denoise = 0.0;
- this.sharpen = 1.1; this.glow = 0.0;
- } else { // NIGHT:夜景降噪
- this.blend = 0.6; this.denoise = 0.55;
- this.sharpen = 0.3; this.glow = 0.15;
- }
- this.schedule(); // 拨完推子,渲染泵自动接手
- }
复制代码
之所以能这么简单,是因为引擎完全无状态:render() 是纯函数,相同推子位置必得相同输出。预设快照、撤销重做、参数分享,在架构上都只是“记住几个数字”的事。
六、三个容易踩的坑
第一个坑是 Slider 的方向参数。竖置滑杆的方向不是属性方法,而是构造选项。写成 Slider({...}).direction(Axis.Vertical) 会撞上通用的 RTL 方向属性,参数类型是 Direction,报出一串误导性错误。正确写法是在构造器里传 direction,枚举值是 Axis.Vertical,注意大小写:
- Slider({ value, min, max, step: 0.01,
- style: SliderStyle.OutSet,
- direction: Axis.Vertical }) // 构造参数,枚举值是 Axis.Vertical
- .height(170) // 且注意大小写:Vertical 而非 VERTICAL
复制代码
第二个坑是前面提到的 USM 索引换算。模糊缓冲是像素×3 通道布局,主输出是 RGBA,混回时必须用 i/4*3+c 找到对应通道,写错就会越界。
第三个坑在 NAPI 入口。参数解析失败时,必须立即拒绝 Promise,绝不能把带默认零值的任务送进工作线程;否则 sw * factor 这类计算会带着 0 进去,除零、越界接踵而至:
- if (!parsed) {
- napi_value promise0 = nullptr;
- napi_value msg = nullptr;
- napi_create_string_utf8(env, t->err.c_str(), t->err.size(), &msg);
- napi_value err = nullptr;
- napi_create_error(env, nullptr, msg, &err);
- napi_create_promise(env, &t->deferred, &promise0);
- napi_reject_deferred(env, t->deferred, err); // 拒绝,而非入队
- delete t;
- return promise0;
- }
复制代码
入队之前还有一层数值护栏,校验倍率、源尺寸、缓冲区大小和推子范围:
- if (t->factor <= 1 || t->factor > 8 ||
- t->sw * t->factor > 8192 || t->sh * t->factor > 8192 ||
- t->src.size() != (size_t)t->sw * t->sh * 4) {
- t->err = "bad args";
- return;
- }
- if (t->blend < 0.0 || t->blend > 1.0 || ...) { // 推子范围校验
- t->err = "slider out of range";
- return;
- }
复制代码
推子的合法区间在 UI 层钳制,保护体验;引擎层再校验一遍,保护安全。两层缺一不可。
七、小结
把超分做成合成器,最大的收获是“交互即教学”:拨一遍 KERNEL 推子,比读十页插值核教材更直观地理解锐度从哪来。系列至此覆盖了批处理、交互、诊断、演奏四种形态,同一套超分能力换一种交互范式,就是一个新产品。后续排期包括推子长按微调、参数二维码分享,以及把 GLOW 换成可调半径的柔焦。 |