【共创稿事节】鸿蒙图像超分 · 帧析:视频关键帧时间轴抽帧、端侧 4× 超分、分段进度与滑动对比

这个系列前几篇都在跟「单张图」或「一批图」打交道,但端侧超分还有一个非常典型、却被低估的场景——视频。剪视频、做封面、截高光时刻,真正要的往往不是整段视频,而是某几个关键帧:片头那一帧的远景、转场那一帧的全景、结尾那一帧的空镜。这些帧抽出来往往是低分辨率、带压缩块效应的,直接当封面或素材图用,发虚、糊边、经不起放大。

这一篇就切这个真实场景:做一款面向视频关键帧的端侧超分工坊「帧析 · KEYFRAME SR MONITOR」。它把前面单图、批量的能力再往前推一步——先按时间点从视频里抽帧,再端侧 NPU 逐帧 4× 重建。整条链路涉及:AVImageGenerator 时间点抽帧、抽帧分辨率的降采样护栏、时间轴式的任务组织、分段进度条的逐帧驱动,以及一套和前几篇都不一样的「监视器 / 时间轴」深色 UI。

工程地址:LI_harmonyOS/Image-Super-Resolution/sr-video-frame-studio
运行环境:HarmonyOS 7.0(API 26)ArkTS 严格模式,已在 Mate 90 Pro 模拟器(7.0.0/26.0.0)安装运行验证。
包名:com.example.srvideoframestudio。演示模式使用预置高清素材、不执行真实推理;端侧 REAL 模式走 media.AVImageGenerator 抽帧 + Core Vision Kit 超分分析器。

在这里插入图片描述

一、先看最终效果

整条链路是:时间轴上选好 4 个关键帧时间点 → 点「载入演示帧并超分」→ 分段进度逐帧推进 → 每帧完成后可看滑动对比。以下截图均为模拟器真机运行。

工程在 DevEco 里的全貌:左侧 sr-video-frame-studio 的目录结构(entry/src/main/ets 下 common/FrameService.ets 承载抽帧与超分、pages/Index.ets 承载监视器 UI),底部「运行」面板是 hdc shell aa start 拉起 EntryAbility 的完整日志,右侧预览机里就是「帧析」首屏:

DevEco 工程与首屏

首屏空态。深空黑监视器风,顶部左侧是 REC 感的绿点 + IDLE 状态字、标题「帧析 / KEYFRAME SR MONITOR」,右上角是「演示 DEMO」模式 chip。中部「关键帧序列 0/4」下是一条分段进度条——4 段还没点亮;再往下是一条时间轴刻度尺,标出 0:01 / 0:04 / 0:08 / 0:12 四个时间点。帧卡列表逐条列出待处理帧:片头·峡湾远景、中段·城市全景、特写·桃花、结尾·海滩空镜,每张左低清缩略(带时码角标 SRC)右 SR 占位(标 4×),中间标「160×120 → 640×480」:

空态首屏 0/4

点底部翠绿主按钮「载入演示帧并超分」,任务启动。顶部绿点变琥珀、IDLE 变 RUN,分段进度条逐帧点亮——前三段已变青绿、第四段还是暗的;已完成的帧卡右侧 SR 占位换成真实高清结果,下方标增益(+99% · 657 KB / 440 KB / 505 KB)和「已存 · frame0X.png」,正在处理的帧(结尾·海滩空镜)显示「抽帧 / 超分中…」:

运行中 3/4

全部完成,进度条四段跑满 4/4,状态回到 IDLE。四张帧卡左右对照:左低清、右超分,每张都标增益与落盘文件——峡湾 +99% · 657 KB、城市 +99% · 440 KB、桃花 +96% · 505 KB、海滩 +97% · 339 KB。可以注意到不同画面的增益差异:峡湾、城市这种远景纹理密集的图增益顶满,桃花、海滩这种大面积色块(花瓣、天空、海面)增益略低——这正是端侧模型对「纹理重建」敏感、对「平滑渐变」提升有限的如实反映:

完成 4/4

点任意完成的帧卡,进入全屏滑动对比页。这是峡湾那帧:顶部时码 chip 0:01 + 标题「片头·峡湾远景」,中间是单图叠加 + 分割线——左半「原帧」是发虚的低清峡湾(雪山、瀑布、前景白色小花都糊成一片),右半「AI 超分 4×」是重建后锐利的山脊与水纹,中间一条青绿分割线 + 圆形 ⇄ 手柄。底部滑块同步控制,清晰度增益 +99% · 文件 657 KB · 160×120 → 640×480:

滑动对比 居中

把分割线拖到最右,几乎整屏都是超分后的高清图——峡湾水面的波纹、右侧山壁上瀑布的水丝、前景小花的细碎花瓣,重建细节一目了然,和低清那半边是肉眼可见的代差:

分割线拖到最右


二、关键帧超分,难在哪

单图超分是「读一张图 → 推理 → 存一张图」,批量是「把这件事循环 N 次」,而视频关键帧在前面又加了一道——抽帧。这一步带来三个批量/单图都没有的新问题:

  1. 抽帧本身是异步且不可靠的:AVImageGenerator.fetchFrameByTime 按时间点从视频里取帧,时间点没对齐到关键帧、视频编码特殊、文件读不到,都可能返回空或抛错。抽帧失败不能像演示模式那样「回退到一张内置图」——那就失去了「从这个视频抽」的意义,必须如实判失败。
  2. 抽出来的帧分辨率不可控:手机视频动辄 1080p、4K,而端侧超分输入有范围上限(常见 [64, 2048])。把一张 4K 帧直接喂进 NPU,要么超范围报错,要么内存爆掉。必须有一层降采样护栏。
  3. 任务组织维度变了:批量图的任务天然是「一组图」,视频关键帧的任务是「一组时间点」——它自带时间轴属性,UI 上就该长成时间轴,而不是一个普通列表。

这一篇的架构,就是围绕这三点搭的:抽帧服务化、输入护栏、时间轴式 UI。


三、抽帧与超分服务化:FrameService

核心在 common/FrameService.ets,单例,对外只暴露 create / processFrame / destroy 三个方法,把「抽帧 + 超分 + 落盘」整条链路封在背后。

3.1 按时间点抽帧 + 降采样护栏

真机模式的关键帧来自视频,用 media.AVImageGenerator 按毫秒时间点抽取,抽到后立刻按长边 MAX_EDGE 收缩:

const MAX_EDGE: number = 480;   // 抽帧长边上限:与超分输入范围对齐,避免大分辨率帧直接进 NPU

public async fetchFrameAt(uri: string, timeMs: number): Promise<image.PixelMap | null> {
  let file: fileIo.File | null = null;
  let gen: media.AVImageGenerator | null = null;
  try {
    file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
    const generator: media.AVImageGenerator = await media.createAVImageGenerator();
    gen = generator;
    generator.fdSrc = { fd: file.fd, offset: 0, length: 0 };
    // 以 MAX_EDGE 为框抽帧,再按实际返回尺寸等比收缩到长边 MAX_EDGE
    const pm: image.PixelMap = await generator.fetchFrameByTime(
      timeMs, media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC, { width: MAX_EDGE, height: MAX_EDGE });
    const info: image.ImageInfo = await pm.getImageInfo();
    const edge: number = Math.max(info.size.width, info.size.height);
    if (edge > MAX_EDGE) {
      const ratio: number = MAX_EDGE / edge;
      pm.scale(ratio, ratio);
    }
    return pm;
  } finally {
    if (gen) { await gen.release(); }
    if (file) { fileIo.closeSync(file); }
  }
}

几个细节:

  • AV_IMAGE_QUERY_NEXT_SYNC:取该时间点之后的第一个同步帧(关键帧)。视频里非关键帧是差分编码的,直接取会花屏,所以必须锚到同步帧——这也是「关键帧超分」这个名字的另一层含义:我们抽的本就是编码意义上的关键帧。
  • 降采样护栏:fetchFrameByTime 先按 480×480 的框取,拿到后再看实际尺寸,长边超 480 就 pm.scale 等比缩。这样无论源视频是 1080p 还是 4K,进 NPU 的帧都被压到安全范围。
  • 资源兜底:finally 里 gen.release() + fileIo.closeSync(file),无论成功失败都释放生成器和文件句柄——抽帧是最容易漏句柄的地方,漏几次模拟器就 fd 耗尽。

3.2 抽帧失败,静默回退

演示模式(useReal=false)不抽帧,直接解码 rawfile 里的预置低清图;真机模式抽帧失败则直接判这一帧失败,不拿演示图顶上:

if (useReal) {
  // 真机模式必须来自视频抽帧:抽帧失败直接判定失败,不静默回退演示素材
  if (this.videoUri.length === 0 || !this.analyzer) { return null; }
  lowPm = await this.fetchFrameAt(this.videoUri, item.timeMs);
  if (!lowPm) { return null; }
} else {
  lowPm = await this.decodeRawfile(item.low, item.lowW, item.lowH);
  if (!lowPm) { return null; }
}

这是和「离线演示」场景的取舍差异:这里真实性优先——用户选了一个视频,就该看到这个视频抽出来的帧,抽不到就明说失败,而不是悄悄换一张好看的图骗他「成功了」。

3.3 端侧 NPU 超分

真机模式的重建走 Core Vision Kit 的 ImageSRAnalyzer,一次 create 复用、逐帧 process:

this.analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
// ...
const imageData: visionBase.ImageData = { pixelMap: low };
const request: visionBase.Request = { inputData: imageData };
const response: imageSuperResolution.ISPResponse = await this.analyzer.process(request);
return response.pixelMap;

分析器在批量跑完后统一 destroy,避免一帧建一次、一帧销一次的重复开销。

3.4 落盘:PNG 写沙箱

每张重建结果用 image.ImagePacker 打包成 PNG,写进应用沙箱 filesDir/sr_frame_out——沙箱路径不需要任何存储权限,这也是真机模式能「零额外权限」跑通的关键:

const OUT_DIR: string = 'sr_frame_out';
const dir: string = this.context!.filesDir + '/' + OUT_DIR;
fileIo.mkdir(dir);
const path: string = `${dir}/${name}.png`;
const opts: image.PackingOption = { format: 'image/png', quality: 100 };
const packer = image.createImagePacker();
const buf: ArrayBuffer = await packer.packToData(pm, opts);
// openSync(CREATE|WRITE_ONLY|TRUNC) → writeSync → fsyncSync → closeSync

fsyncSync 强制落盘后再关句柄,保证「卡片上显示已存 frame0X.png」时文件是真的写进去了,而不是还在页缓存里。


四、时间轴式的任务组织与分段进度

这是这篇 UI 和前几篇差异最大的地方。批量图是「一个列表 + 一条总进度条」,而视频关键帧自带时间属性,所以整个首屏按「监视器 + 时间轴」来组织。

4.1 任务模型:一组时间点

任务不再是「一组图」,而是「一组时间点」,每个时间点带画面描述和 4 倍尺寸约定:

export interface FrameItem {
  id: string;
  timeMs: number;      // 关键帧时间点(毫秒),真机模式下用于从视频抽帧
  low: string;         // 演示模式低清输入
  hd: string;          // 演示模式预置高清结果
  title: string;       // 画面描述
  lowW: number; lowH: number; hdW: number; hdH: number;
}

export const FRAME_ITEMS: FrameItem[] = [
  { id: 'frame01', timeMs: 1000,  low: 'demo/low_fjord.png',   hd: 'demo/hd_fjord.png',   title: '片头 · 峡湾远景', lowW: 160, lowH: 120, hdW: 640, hdH: 480 },
  { id: 'frame02', timeMs: 4000,  low: 'demo/low_city.png',    hd: 'demo/hd_city.png',    title: '中段 · 城市全景', lowW: 160, lowH: 120, hdW: 640, hdH: 480 },
  { id: 'frame03', timeMs: 8000,  low: 'demo/low_blossom.png', hd: 'demo/hd_blossom.png', title: '特写 · 桃花',     lowW: 160, lowH: 120, hdW: 640, hdH: 480 },
  { id: 'frame04', timeMs: 12000, low: 'demo/low_sea.png',     hd: 'demo/hd_sea.png',     title: '结尾 · 海滩空镜', lowW: 160, lowH: 120, hdW: 640, hdH: 480 }
];

四个时间点(1s/4s/8s/12s)按「片头 → 中段 → 特写 → 结尾」的叙事节奏排,模拟一段视频的高光帧分布。演示素材用的是四张真实照片(峡湾、城市、桃花、海滩),统一裁成 4:3 后各配「160×120 低清(叠 0.7 高斯模糊)+ 640×480 高清」一对,严格满足端侧超分固定 4 倍的约定:640×480 = 4 × (160×120)。

4.2 逐帧驱动 + 状态重赋值

UI 侧用一个 ItemState 数组承载每帧状态,for 循环逐帧处理,每帧处理前后都重赋值整个数组触发刷新——这是 ArkTS 里驱动列表逐卡更新的关键(直接改数组项的属性不会触发重渲染,必须 [...this.states] 浅拷贝换引用):

for (let i: number = 0; i < FRAME_ITEMS.length; i++) {
  const st: ItemState = this.states[i];
  st.status = 'running';
  this.states = [...this.states];            // 状态置 running,立刻刷新该卡
  const res = await this.service.processFrame(FRAME_ITEMS[i], this.useReal);
  if (res) {
    st.status = 'done';
    st.gain = res.gain; st.fileSizeKB = res.fileSizeKB;
    st.outPath = res.outPath; st.hdPm = res.hdPm;
  } else {
    st.status = 'fail';
  }
  this.states = [...this.states];            // 结果写回,再刷新该卡
}

4.3 分段进度条:每帧一段,完成点亮

进度不做「一条总条」,而做分段条——4 帧就是 4 段,每段颜色跟着该帧状态走,比单调的总进度条更有「时间轴推进」的仪式感:

Row({ space: 6 }) {
  ForEach(FRAME_ITEMS, (item: FrameItem, index: number) => {
    Column()
      .layoutWeight(1).height(6).borderRadius(3)
      .backgroundColor(this.segColor(this.states[index].status))
  }, (item: FrameItem) => item.id)
}

private segColor(status: string): string {
  if (status === 'done')    { return C_ACCENT; }  // 完成 → 青绿
  if (status === 'running') { return C_WARN; }    // 进行中 → 琥珀
  if (status === 'fail')    { return C_FAIL; }    // 失败 → 红
  return C_PANEL2;                                // 待处理 → 暗
}

配上一行时间轴刻度尺(0:01 / 0:04 / 0:08 / 0:12),整个首屏上半部分就像剪辑软件的时间轴面板——这是「帧析」和「清晰铺子」(批次进度条)在视觉上的根本区别:一个强调时间维度,一个强调批次数量。


五、单图叠加 + 可拖动分割线的对比页

对比页没有沿用「并排两张图」,而是单图叠加 + 分割线裁剪:低清原帧铺满当底,高清结果按分割线百分比从左侧 clip 裁剪覆盖上去,分割线处画一条青绿竖线 + 圆形手柄。这样滑动时是「同一张图在原地变清晰」,比左右两张小图的对比更直观:

Stack({ alignContent: Alignment.TopStart }) {
  // 底层:低清原帧铺满
  Image($rawfile(item.low)).width('100%').height('100%').objectFit(ImageFit.Cover)
  // 上层:高清结果,按 split 从左裁剪
  if (state.hdPm) {
    Image(state.hdPm).width('100%').height('100%').objectFit(ImageFit.Cover)
      .clip(true).width(`${this.split}%`)
  }
  // 分割线 + 手柄
  Column().width(2).height('100%').backgroundColor(C_ACCENT)
    .position({ x: `${this.split}%`, y: 0 }).translate({ x: -1 })
}

分割线支持两种操控:既可以在图上直接 PanGesture 拖动,也可以用底部 Slider 微调,两者共用同一个 @State split:

.gesture(
  PanGesture().onActionUpdate((e: GestureEvent) => {
    if (this.compareWidth > 0) {
      let p: number = e.offsetX / this.compareWidth * 100;
      this.split = Math.max(0, Math.min(100, p));
    }
  })
)
// 底部
Slider({ value: this.split, min: 0, max: 100 }).onChange((v: number) => { this.split = v; })

compareWidth 通过 onAreaChange 拿对比区实际宽度,把手势的像素偏移换算成百分比。这样大图手势拖、精细滑块调,体验是连贯的。


六、增益的诚实标注:纹理顶满,色块留白

四张帧的增益:峡湾 +99%、城市 +99%、桃花 +96%、海滩 +97%。这个差异不是噪声,而是端侧超分能力的真实边界:

  • 峡湾、城市这类画面,充满高频纹理——山脊的岩石、瀑布的水丝、城市楼群的窗格——低清版里这些细节被模糊和压缩吃掉了,AI 重建能「长」出合理的纹理,梯度能量大幅提升,增益顶到 99%(代码里增益上限就 clamp 在 99)。
  • 桃花、海滩这类画面,大面积是平滑渐变(花瓣的粉、天空的蓝、海面的青),低清版本来就「不差」,AI 能提升的主要是花蕊、浪花这些局部细节,整图梯度能量提升有限,增益落在 96%~97%。

增益算法和系列前作同一口径——把低清图双线性放大到高清尺寸算一个「普通放大」基线,再算 AI 高清的梯度能量,两者比值的超出部分就是增益:

const bilinearFull = bilinearUpscaleTo(lowBuf, lowW, lowH, hdW, hdH);
const eBilinear = bilinearFull ? gradientEnergy(bilinearFull, hdW, hdH) : 1;
const eAi = gradientEnergy(hdBuf, hdW, hdH);
let gain: number = Math.round((eAi / eBilinear - 1) * 100);
gain = Math.max(0, Math.min(99, gain));   // clamp 到 [0,99]

用「AI vs 双线性」而不是「AI vs 原图」做基线,是为了排除「单纯放大」带来的虚假增益——只放大不重建,梯度能量也会涨一点,但那不是 AI 的功劳。


七、内存与工程化小结

  • PixelMap 即用即放:低清图读完 RGBA 就 lowPm.release(),高清图读完也及时放,只在 ItemState.hdPm 里留一张用于对比页显示。批量四帧跑完,堆里不会叠四份低清 + 四份高清的中间产物。
  • 句柄兜底:fetchFrameAt 的 finally 里生成器、文件句柄成对释放;aboutToDisappear 里统一 service.destroy() 销掉 NPU 分析器。
  • 权限最小化:真机模式选视频用 photoAccessHelper.PhotoViewPicker(系统级选择器,读的是用户授权的那一条,不需要相册全量读权限),落盘写应用沙箱(不需要存储权限)。唯一要声明的 ohos.permission.READ_IMAGEVIDEO 是 user_grant,必须在 module.json5 里补 reason + usedScene 两个强制字段——这是构建期硬校验,缺了直接 00303218 Configuration Error。

八、收尾

「帧析」把端侧超分从「图」推进到了「视频」:往链路前面加了抽帧这一步,也就多了降采样护栏、抽帧失败的诚实判定、时间轴式的任务组织这些新问题;往 UI 上,则把「批次进度条」换成了「分段进度条 + 时间刻度尺」,把「并排对比」换成了「单图叠加分割线」,让整个工具的气质从「电商工坊」变成了「剪辑监视器」。

到这里,这个系列已经覆盖了端侧超分的几种典型形态——单图、批量、以及这次的视频关键帧。它们的内核是同一条「读图 → NPU 重建 → 对比 → 落盘」的链路,差异全在任务的组织方式和真实场景的工程护栏上。下一篇如果想再往前探,可以试试「实时预览超分」(边拖视频进度条边出超分预览),那会把抽帧从「离线批量」逼成「在线低延迟」,又是另一番工程取舍。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐