一、整图调用成功了,应用却在保存前退出

PanoramaSRLab 最初只是把普通照片超分的代码搬到长图页面:读取 river_walk_4096x1024.jpg,交给 ImageSRAnalyzer 做 2× 推理,再用 ImagePacker 导出。模拟器上的 1080P 小图一直正常,换成这张 4:1 全景图后,推理回调甚至能返回,但紧接着创建 8192×2048 输出 PixelMap 时内存峰值越过设备预算,页面在“保存中”退出。

我一开始把注意力放在压缩质量上,实际问题却发生在更早的位置。输入 RGBA 约 16 MiB,输出缓冲已经是 64 MiB;模型内部张量、输入和输出 PixelMap、编码临时缓冲在某个时间窗口同时存活,峰值远高于“文件大小”。把整图切块虽然能降单次推理压力,但如果没有重叠区,27 个块拼回去会出现明显的棋盘接缝。这个 Demo 最终要解决的不是“调用超分 API”,而是同时守住三条线:块覆盖完整、接缝不可见、资源峰值可预测。

二、任务快照先于算法参数

页面名为 PanoramaSuperResPage,任务 ID 是 SR-TILE-0915。输入尺寸 4096×1024,倍率 2×,目标尺寸 8192×2048;核心块 512×512,四周重叠 32 像素,步长因此是 448。边缘块不能简单丢弃不足 512 的部分,而要把起点回拉到图像边界,最后得到 9×3、共 27 块。

运行参数在点击“开始超分”时复制成不可变快照,任务代次为 22。状态机为 PLANNING → PROCESSING → BLENDING → ENCODING → COMPLETED;取消进入 CANCELLED,任一块异常进入 FAILED。UI 的 27/27、峰值租约 2、输出缓冲 64 MiB 都来自同一个 TileRunSnapshot,不能让进度条、日志和导出模块各算一套。

三、最后一行块必须回贴边界

第一段代码解决切块覆盖。只用 for (x += step) 会让最后一块越界或留下窄缝;axisStarts() 在尾端补一个 length - tile,同时去重。这样 4096 宽得到 9 个 x 起点,1024 高得到 3 个 y 起点,且每个像素至少被一个块覆盖。

interface TileRect { id: number; x: number; y: number; width: number; height: number }

function axisStarts(length: number, tile: number, overlap: number): number[] {
  const step = tile - overlap * 2
  const starts: number[] = []
  for (let p = 0; p + tile < length; p += step) starts.push(p)
  starts.push(Math.max(0, length - tile))
  return Array.from(new Set(starts)).sort((a, b) => a - b)
}

function planTiles(width: number, height: number): TileRect[] {
  const xs = axisStarts(width, 512, 32)
  const ys = axisStarts(height, 512, 32)
  const tiles: TileRect[] = []
  ys.forEach(y => xs.forEach(x => tiles.push({
    id: tiles.length, x, y,
    width: Math.min(512, width - x), height: Math.min(512, height - y)
  })))
  return tiles
}

这里的 overlap 是单侧 32 像素,所以有效步长要减两次,不能误写成 480。边界块回拉后会与前一块产生更宽重叠,羽化权重必须根据实际矩形计算,不能假设每条接缝恒定 64 像素。若输入小于 512,起点只有 0,逻辑自然退化为单块;这比另写“小图分支”更容易测试。

四、两块并发是设备预算,不是性能口号

第二段代码负责调度。Demo 没有一次性创建 27 个 Promise,而是用容量为 2 的租约池。每块先裁剪输入 PixelMap,执行超分,复制像素到可转移的 ArrayBuffer;复制完成后立即释放输入块与结果 PixelMap,主线程只保留待混合的字节数组。

async function runTile(rect: TileRect, generation: number): Promise<TileResult> {
  await this.lease.acquire()
  let inputTile: image.PixelMap | undefined
  let srTile: image.PixelMap | undefined
  try {
    this.assertGeneration(generation)
    inputTile = await cropPixelMap(this.source, rect)
    srTile = await this.analyzer.process(inputTile, { scale: 2 })
    const info = await srTile.getImageInfo()
    const bytes = new ArrayBuffer(info.size.width * info.size.height * 4)
    await srTile.readPixelsToBuffer(bytes)
    this.assertGeneration(generation)
    return { rect, scale: 2, rgba: bytes }
  } finally {
    srTile?.release()
    inputTile?.release()
    this.lease.release()
  }
}

租约必须在裁剪前获取,否则等待推理的 PixelMap 仍会堆在内存里。finally 中先释放大对象再归还租约,避免下一个任务刚获准进入时,上一个结果还没销毁。取消只能让 generation 失效,不能假设 Native 推理立即停止;迟到结果仍会经过 finally,但不允许写入输出缓冲。实际项目还要在页面彻底移除时调用 analyzer 的销毁接口,本段只展示单块生命周期。

五、接缝要按距离加权,不能后画覆盖前画

最初拼接用的是“后块覆盖前块”,在天空和水面上能看到竖线。第三段代码把每个输出像素乘以边缘权重,再累加到颜色和权重缓冲。外边界不衰减,只有与相邻块相接的边缘参与羽化,避免整张图四周变暗。

function edgeWeight(p: number, length: number, feather: number,
  fadeStart: boolean, fadeEnd: boolean): number {
  let w = 1
  if (fadeStart && p < feather) w = Math.min(w, p / feather)
  if (fadeEnd && p >= length - feather) w = Math.min(w, (length - 1 - p) / feather)
  return Math.max(0.001, w)
}

function blendTile(tile: TileResult, out: Float32Array, weights: Float32Array,
  outputWidth: number): void {
  const w = tile.rect.width * 2, h = tile.rect.height * 2, feather = 64
  const src = new Uint8Array(tile.rgba)
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
    const wx = edgeWeight(x, w, feather, tile.rect.x > 0,
      tile.rect.x + tile.rect.width < 4096)
    const wy = edgeWeight(y, h, feather, tile.rect.y > 0,
      tile.rect.y + tile.rect.height < 1024)
    const weight = wx * wy
    const dst = ((tile.rect.y * 2 + y) * outputWidth + tile.rect.x * 2 + x)
    const si = (y * w + x) * 4, di = dst * 4
    for (let c = 0; c < 4; c++) out[di + c] += src[si + c] * weight
    weights[dst] += weight
  }
}

权重缓冲用于最后归一化,任何权重为 0 的像素都意味着切块规划有洞,直接让任务失败。这里用 Float32 累加是为了避免多块重叠时过早截断;完成一行归一化后即可写回 Uint8 输出缓冲。羽化发生在 2× 输出坐标,所以 32 像素输入重叠对应 64 像素 feather。倍率变化时必须一起变化,这是最容易留下暗边的地方。

六、调试时我只看三本账

工程目录刻意分成 pages/PanoramaSuperResPage.ets、pipeline/TilePlanner.ets、pipeline/TileRunner.ets、blend/FeatherBlender.ets 与 store/SRManifest.ets。HiLog 标签为 SRTile,关键日志固定为:task=SR-TILE-0915 generation=22 tiles=27 completed=27 leasePeak=2,以及 output=8192x2048 buffer=64MiB memoryPeak=118MiB seamScore=0.12。

第一本是覆盖账:27 块是否全部完成,输出权重是否无零点;第二本是资源账:租约峰值是否为 2,块级 PixelMap 是否在下一块开始前释放;第三本是质量账:同一接缝测试集从直接覆盖的 1.80 降到羽化后的 0.12。只看到“100%”不代表成功,因为进度完成后仍可能在归一化或编码阶段超过内存预算。

七、最终结果要等编码和清理一起完成

实测 river_walk_4096x1024.jpg 用时 3268 ms,27 块全部完成,输出 river_walk_8k_sr.jpg,尺寸 8192×2048。输出缓冲为 64 MiB,进程峰值 118 MiB,临时块文件为 0。页面只有在 ImagePacker 完成、输出文件通过尺寸检查、任务级 PixelMap 与 analyzer 释放后才进入 COMPLETED。

手机页展示的 27/27、2×、512、32、峰值 118 MiB、接缝分 0.12 和 3268 ms 都来自 manifest sr_tile_0915.json。按钮“查看 8K 结果”读取的是已晋级文件;若编码失败,.part 会被删除,页面不会保留一个看似可点但实际不完整的结果入口。

八、分块并不是所有长图的万能答案

块内超分模型若依赖很大感受野,512 像素可能仍不足;纹理周期明显的建筑立面还需要更宽重叠或频域验收。HDR、透明通道和广色域输入也不能直接套用本 Demo 的 RGBA 权重混合,它们分别需要动态范围和 Alpha 语义处理。当前实现更适合普通 SDR 全景图,而不是替代模型侧的整图推理。

这次优化最后留下的经验是:峰值内存不能用最终文件大小估算,接缝不能靠肉眼抽查,取消也不能只改进度条。把块计划、租约、羽化权重和终态清理都放进同一代次后,8K 输出才从“偶尔能跑完”变成了一条能重复验收的工程路径。

Logo

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

更多推荐