HarmonyOS 7图像超分·显影排程:瓦片队列的调度设计空间与无接缝一致性验证

暗房(Image-Super-Resolution/sr-darkroom)把整图超分切成 4×4 共十六块瓦片,每块独立计算、按 120ms 节拍逐一浮现——全图算完只要 3~5ms,显影仪式却有两秒。这场仪式的观感背后,是这台机器更硬的一面:排程与一致性。

瓦片化把「一次大计算」解构成「一队小计算」,随之而来是两个必须回答的工程问题:这队任务该怎么排——串行仪式感、有界并行、优先级抢跑、乱序完成,能否收敛成一个可插拔的调度器抽象?以及块与块之间的接缝,能否从「靠余量掩盖」升级为「可数学证明、可自动检测、可进 CI」?全文配图为 Mate 90 Pro 模拟器实机截图,结论均可复算。


一、被测机架:一台把大计算拆成小队列的机器

机架全景:DevEco 工程与模拟器上的暗房

工程 Image-Super-Resolution/sr-darkroom(HarmonyOS 7 / API 26),Native 引擎约 270 行、ArkTS 约 300 行。它在本篇的身份是被测机架,先锁定实验口径:180×180 低清底片、瓦片 45px、HALO=2、核支撑半径 2(每轴 6-tap)、药液 crisp ∈ [0,1] 连续可调、倍率 2×/4×。

装好底片、待显影的雪山

这张「待显影」的截图在排程语言里就是队列就绪态:十六个任务已经确定,一个都还没跑。把任务模型写下来——每个瓦片是一个自描述的纯函数调用:

struct TileSpec {
    int tx, ty;          // 瓦片坐标,0..3
    int tile;            // 瓦片边长(源像素 45)
    int factor;          // 2 或 4
    double crisp;        // 药液浓度 0..1
};

// developTile(src, sw, sh, tx, ty, tile, factor, crisp)
// 返回尺寸内嵌的 RGBA 缓冲,无共享状态、无先后依赖

两个性质决定了调度自由度:纯函数(同一入参永远同一输出)与零共享状态(十六个任务互不读写对方的数据)。这意味着调度策略可以完全放在交互层——引擎对「谁先跑、跑多快」一无所知,也不需要知道。


二、调度设计空间:一个接口,四种排程器

调度器的全部职责是决定「下一个跑谁、同时跑几个」。把它收敛成一个最小接口:

export interface TileScheduler {
  // 按策略返回下一个要显影的瓦片;没有可跑任务时返回 -1
  next(developed: boolean[], viewportHint?: number): number;
  // 本策略允许的最大并发数
  concurrency(): number;
}

四种实现覆盖了完整的设计空间:

S0 串行节拍(暗房的现行选择)

class SequentialScheduler implements TileScheduler {
  next(developed: boolean[]): number {
    return developed.indexOf(false);          // 行优先,逐块推进
  }
  concurrency(): number { return 1; }
}

每块之间 await sleep(120) 注入呼吸间隔,总时长被精确钉死在 16 × 120ms + 计算 ≈ 2s。它的价值不是性能,是确定性叙事:进度可精确预估、观感节奏恒定、任何设备上都一样。

S1 有界并行(吞吐优先)

class BoundedParallelScheduler implements TileScheduler {
  private cursor: number = 0;
  constructor(private limit: number = 4) {}
  next(developed: boolean[]): number {
    return developed.indexOf(false, this.cursor);
  }
  concurrency(): number { return this.limit; }
}

并发 4 时总时长约 ⌈16/4⌉ × 单块耗时,从 2s 压到约 0.5s。注意 ArkTS 侧的并发是逻辑并发(Promise 池),真正的 CPU 并行发生在 Native 线程池——Promise 池只负责把请求排进去:

private async pumpAll(sched: TileScheduler): Promise<void> {
  const inFlight: Promise<void>[] = [];
  while (true) {
    const idx: number = sched.next(this.developedFlags());
    if (idx < 0 || inFlight.length >= sched.concurrency()) {
      if (inFlight.length === 0) break;
      await Promise.race(inFlight);          // 等任意一块完成再补位
      continue;
    }
    const p = this.developOne(idx);          // developOne 内部完成 UI 揭晓
    inFlight.push(p);
    p.then(() => { const k = inFlight.indexOf(p); if (k >= 0) inFlight.splice(k, 1); });
  }
  await Promise.all(inFlight);
}

S2 优先级抢跑(注视点优先)

class CenterFirstScheduler implements TileScheduler {
  next(developed: boolean[]): number {
    let best = -1, bestD = Infinity;
    for (let ty = 0; ty < ROWS; ty++) {
      for (let tx = 0; tx < COLS; tx++) {
        const idx = ty * COLS + tx;
        if (developed[idx]) continue;
        // 瓦片中心到图中心的距离——用户视线最先落的地方先显影
        const d = Math.hypot(tx - (COLS - 1) / 2.0, ty - (ROWS - 1) / 2.0);
        if (d < bestD) { bestD = d; best = idx; }
      }
    }
    return best;
  }
  concurrency(): number { return 1; }
}

总时长与 S0 相同,但首印象瓦片(画面主体所在)提前约 4 个节拍出现。对「用户盯着屏幕等第一块浮现」的场景,感知等待时间显著低于物理等待时间——这是排程里少见的「零成本收益」。

S3 乱序完成 + 按位揭晓

并行化后 completion 顺序不再确定,直接「完成即显示」会得到一场空间上随机的显影,叙事崩塌。解法是显影与揭晓解耦:完成即缓存、揭晓严格按位序——

class ReorderBuffer {
  private done: Map<number, DevTile> = new Map();
  private nextIdx: number = 0;

  accept(idx: number, tile: DevTile): void {       // 任意顺序到达
    this.done.set(idx, tile);
    this.flush();
  }
  private flush(): void {                          // 只按位序揭晓
    while (this.done.has(this.nextIdx)) {
      this.reveal(this.nextIdx, this.done.get(this.nextIdx)!);
      this.done.delete(this.nextIdx);
      this.nextIdx++;
    }
  }
  private reveal(idx: number, t: DevTile): void { /* UI 揭晓 */ }
}

四种排程器对比

排程器总时长首块出现进度可预估实现复杂度
S0 串行节拍~2s(确定性)120ms精确最低
S1 有界并行 ×4~0.5s~30ms统计估计中(Promise 池)
S2 中心优先~2s120ms(主体瓦片提前 ~0.5s)精确低
S3 乱序+按位揭晓同 S1同 S1精确(按位序)中高

关键结论:四者共用同一个引擎、同一批任务、同一个揭晓 UI 组件。切换排程器只是换一个 TileScheduler 实现——这就是瓦片化的真正红利:它把调度策略从算法问题降格为配置问题。


三、进度协议:显影中截图里藏着的状态机

显影进行时 10/16:左上十块浮现,右下仍是暗红纸面

翠鸟显影中 9/16:羽毛的青橙色块率先浮现

两张「显影进行时」截图共同展示了一个显式状态机:SAFELIGHT ON(显影中)→ 定影 → SAFELIGHT OFF;顶部计数 10/16、9/16 是进度原语。值得拆解的是进度本身在不同排程器下的语义差异:

串行:进度即时间

S0 下进度是确定性的——k/16 直接对应 k × 120ms,倒计时可以精确到节拍。进度条可以画成十六格的「显影盘」,每一格的浮现都是承诺兑现。

并行:进度必须重新定义

S1 下「完成块数」不再线性映射时间:最后一块可能在任何时刻完成,进度条会在 14/16 处长时间停住(短板效应——总时长由最慢的一批决定)。正确的做法是把进度拆成两个量分别呈现:

class ProgressEstimator {
  constructor(private total: number, private perTileMs: number,
              private concurrency: number) {}

  // 完成度:与排程无关,永远可用
  completed(finished: number): number { return finished / this.total; }

  // 剩余时间:只有 S0 是精确公式;S1 用批次模型估计
  remainingMs(finished: number, mode: 'serial' | 'parallel'): number {
    if (mode === 'serial') {
      return (this.total - finished) * (this.perTileMs + PULSE_MS);
    }
    const left = this.total - finished;
    const waves = Math.ceil(left / this.concurrency);      // 剩余批次数
    return waves * (this.perTileMs + PULSE_MS);            // 批次模型,仍偏乐观
  }
}

并行模式下 waves × perTile 是下界估计(忽略批内倾斜),UI 应把它标注为「约」——给用户一个诚实的模糊,好过一个精确的错误。

进度原语的最小集

两张截图里的信息已经足够抽出跨排程器通用的进度协议:{done, total, phase, etaMs?} 四个字段。etaMs 允许缺省——S0 给精确值,S1/S3 给估计值,S2 与 S0 相同。任何调度器只要吐这四个字段,进度 UI 组件零改动复用。


四、无接缝:从「余量掩盖」到「可证明 + 可检测」

定影完成:十六块瓦片严丝合缝拼成完整大图

这张定影图上,山脊线横跨四块瓦片却没有任何亮度台阶——支撑它的是 HALO 余量机制。但「截图上看不出来」不是工程结论,本节把它升级为三件事:一致性证明、自动检测、回归矩阵。

1. 一致性证明:为什么 HALO=2 恰好够

设相邻两块瓦片 W 与 W′ 共享一条竖直边界。W 的 halo 向右多裁 2 个源像素,W′ 的 halo 向左多裁 2 个。对边界附近任意一个同时落在两块输出范围内的输出像素 (p):

  • 在 W 中,§ 的 6-tap 采样窗口横跨其 patch 内部,所需源像素编号集合为 (S§);
  • 在 W′ 中,同一个 § 落在其 patch 中央区域,采样集合同为 (S§)。

两块瓦片对 (p) 的计算是同一函数作用于同一输入集合,而 developTile 是纯函数,故两份输出逐位相等——接缝在数学上不可能存在。证明成立的唯一前提是:每个瓦片内部输出像素的采样窗口完整落在自己的 patch 内。核支撑半径 2 意味着采样窗口最多向外伸 2 个源像素,于是:

[
\mathrm{HALO} \ge r \quad\Longrightarrow\quad \mathrm{HALO}=2 \text{ 恰好成立}
]

HALO=1 会截断三次核的采样窗(边界像素拿到被钳制的邻居,输出与整图路径不一致);HALO=3 则每块多算一圈、毫无收益。这个推导同时给出了正确值与下界——参数不再是经验值,而是定理。

值得补一个容易被忽略的边界情况:瓦片贴着图像边缘时,采样窗口会越过图界被 ClampInt 钳制。此时两块相邻瓦片对同一像素的钳制行为必须一致(同一次 clamp 到同一编号),而两块 patch 的裁剪范围都从同一张完整底图出发、钳制在 [0, sw) 内,一致性自动成立。推论是:钳制必须发生在源坐标上,而不是 patch 局部坐标上——若先在 patch 内钳制,两块 patch 的局部边界不同,钳制结果就会分叉,接缝从数学上回归。

2. 自动检测:SeamScanner

证明保证了「正确实现无接缝」,但实现可能被改坏。给拼接结果装一台自动扫描仪——沿全部瓦片交界线,对比「跨界梯度」与「线内梯度」的分布:

struct SeamReport {
    double worstZ;        // 最大异常分(z-score)
    int    worstX, worstY;
    bool   ok;            // worstZ < 3.0
};

// 仅需竖直交界线版本:水平同理
SeamReport ScanVerticalSeams(const std::vector<uint8_t>& img,
                             int w, int h, int tileOut, int factor) {
    SeamReport r{0.0, -1, -1, true};
    int step = tileOut * factor;                       // 输出域的瓦片步长
    for (int gx = step; gx < w; gx += step) {          // 每条竖直交界线
        for (int y = 1; y < h - 1; ++y) {
            double cross = GradH(img, w, gx, y);       // 跨线水平梯度 |I(gx)-I(gx-1)|
            double inL   = GradH(img, w, gx - 1, y);   // 线内参照
            double inR   = GradH(img, w, gx + 1, y);
            double base  = std::max(1.0, 0.5 * (inL + inR));
            double z = std::fabs(cross) / base;        // 跨线/线内 比值
            if (z > r.worstZ) { r.worstZ = z; r.worstX = gx; r.worstY = y; }
        }
    }
    r.ok = r.worstZ < 3.0;
    return r;
}

判据不是绝对阈值而是比值:真实图像边缘的跨界梯度可以很大(山脊线本身就是突变),但一条真正的接缝会让「跨界梯度 / 线内梯度」出现统计意义上的孤立尖峰。z-score 形式的判据对内容自适应——雪山和翠鸟用同一把尺。

3. 回归矩阵:接缝检测进 CI

接缝问题的复现条件是「药液 × 倍率 × 边界几何」的组合,逐一铺开就是回归矩阵:

const MATRIX: Array<[string, number, number]> = [
  // [药液, 倍率, 预期]
  ['柔光浴 0.0', 2, 0], ['标准浴 0.5', 2, 0], ['脆调浴 1.0', 2, 0],
  ['大画幅 0.6', 4, 0], ['脆调浴 1.0', 4, 0],
];
// 每个组合:跑 16 块 → Assemble 拼图 → ScanVerticalSeams + ScanHorizontalSeams
// 断言 worstZ < 3.0;任何一条超标即 CI 失败,并输出 worstX/worstY 定位到像素

矩阵刻意包含 crisp=1.0 + 4× 这类「最锋利核 + 最大倍率」的极端组合——锐化越强,潜在接缝的对比度越高,越容易被抓。接缝从「截图上肉眼检查」变成了一行断言。


五、定影的另一个身份:两条路径的等价性校验

装好底片、待显影的翠鸟

暗房的流程里,「定影」是十六块显影完之后的一次整图重算(tile = 整图宽,绕过瓦片逻辑)。仪式意义上它是收尾,工程上它藏着一个更硬的身份:同一引擎的两条计算路径,必须逐位等价。

  • 路径 A:十六块带 halo 的 patch 分别重采样,拼回整图;
  • 路径 B:整图一次重采样。

§4 的证明说它们应当等价——那就把等价变成断言:

std::vector<uint8_t> Assemble(const std::vector<DevTile>& tiles,
                              int outW, int outH, int tileOut, int factor) {
    std::vector<uint8_t> full((size_t)outW * outH * 4, 0);
    for (const DevTile& t : tiles) {
        int ox = t.tx * tileOut * factor;
        int oy = t.ty * tileOut * factor;
        for (int y = 0; y < t.h; ++y) {                 // 逐行拷贝,跳过越界
            if (oy + y >= outH) break;
            const uint8_t* src = &t.pix[(size_t)y * t.w * 4];
            uint8_t* dst = &full[((size_t)(oy + y) * outW + ox) * 4];
            std::memcpy(dst, src, (size_t)t.w * 4);
        }
    }
    return full;
}

bool VerifyAssembly(const std::vector<uint8_t>& assembled,
                    const std::vector<uint8_t>& whole,
                    int maxDiff) {
    if (assembled.size() != whole.size()) return false;
    for (size_t i = 0; i < whole.size(); i += 4) {
        for (int c = 0; c < 3; ++c) {
            int d = std::abs(assembled[i + c] - whole[i + c]);
            if (d > maxDiff) return false;              // 逐位等价(或容差内)
        }
    }
    return true;
}

maxDiff=0 是最强断言——既然数学上等价,就没有理由容忍哪怕 ±1。若实测不等价,差异本身就携带诊断信息:第一条出现差异的像素坐标,往往就落在某条瓦片交界上,直指 HALO 或钳制的实现偏差。这条校验配合 §4 的接缝矩阵进 CI,瓦片路径的「正确性」就不再依赖任何一次人工看图。

翠鸟这张待显影截图正是第二组验证样本:不同内容(高频羽毛 vs 平滑雪山)、不同参数组合,同一套断言。一致性验证的样本多样性,决定它抓 bug 的能力。

六、大画幅的预算账:排程的真正收益是「首块提前」

翠鸟 · 大画幅 4× · 输出 720×720

大画幅浴把输出推到 720×720。羽毛丝缕已接近插值信息天花板,但这张图在排程视角下另有价值——它让「瓦片化为什么值得」变成一笔可算的账:

指标整图直出瓦片串行(S0)瓦片并行 ×4(S1)
总时长 (T_{total})最优(无调度开销)~2s(含 120ms 节拍)~0.5s
首块可见 (T_{first})(T_{total})(全部算完才可见)1 块耗时 + 120ms~1 块耗时
内存峰值整图输出缓冲patch + 输出瓦片patch × 并发数
峰值/整图比1(\approx\frac{(45+4)2}{1802}\approx 7%)(\approx 30%)(×4 并发)

三个结论:

  1. (T_{total}) 上瓦片化永远输给整图直出(多付调度与拼接成本)——所以「为了快而切块」是伪命题;
  2. (T_{first}) 上瓦片化赢一个数量级:用户看到第一块浮现的时间,从「全图算完」提前到「一块算完」。计算越慢、图越大,这个差距越大。流式的本质收益是首响应提前,不是总吞吐;
  3. 内存峰值下降一个数量级:整图 720×720×4 ≈ 2.1MB,而单 patch 仅约 137KB(4× 下 196×196×4)。对端侧多应用共存的环境,这个比值比毫秒数更有意义。

一张探针代码把账钉住:

async function MeasureLatency(bath: Bath, sched: TileScheduler): Promise<void> {
  const t0 = Date.now();
  const first = await this.developOne(sched.next(this.idle16()));  // 首块
  hilog.info(0x00ee, 'LAT', 'T_first = %{public}d ms', Date.now() - t0);
  await this.pumpAll(sched);                                        // 全队
  hilog.info(0x00ee, 'LAT', 'T_total = %{public}d ms', Date.now() - t0);
}

在模拟器上的复现方向:4× 下 (T_{first}) 约为 (T_{total}) 的 1/16(串行),且几乎不随图变大而恶化——把「一次大等待」变成「十六个小等待的首个」,这正是瓦片排程存在的理由。


七、规格与结语

项目规格
任务模型16 个纯函数瓦片任务,零共享状态,调度自由
调度空间S0 串行节拍 / S1 有界并行 / S2 中心优先 / S3 乱序+按位揭晓,共用一个 TileScheduler 接口
进度协议{done, total, phase, etaMs?} 四字段,etaMs 串行精确、并行估计
接缝保证HALO=2 的采样窗口一致性证明 + SeamScanner(z-score < 3.0)+ 药液×倍率回归矩阵
等价性校验16 块拼图 vs 整图直出逐位对比,maxDiff=0
流式收益(T_{first}) 提前一个数量级;内存峰值约为整图路径的 7%~30%

瓦片化的两条腿,本篇各掰清楚了一条。叙事腿:把计算解构成队列后,节奏、优先级、揭晓策略全部变成交互层的配置项——2 秒显影仪式和 0.5 秒并行快出,是同一个引擎的两件外衣。正确性腿:接缝不是被余量「掩盖」的,而是在「采样窗口一致性」的证明之下被消灭的,并且配上了能自动报警的扫描器和能进 CI 的等价性断言。

当计算快于感知,把时间盈余还给体验;当计算慢于感知,同样的架构自动退化成真实进度条——而无论哪一边,调度策略都是选择题,不是算法题。


FAQ

Q:为什么不直接 Promise.all 十六块一把梭?
A:能跑,但有两个代价:一是叙事崩塌("唰"地全出来,显影变成闪屏);二是进度失去语义(完成顺序随机,k/16 跳变)。如果目标是纯粹吞吐,用 S1 的有界并行——Promise.race 补位的池子比裸 Promise.all 多一层并发上限控制,避免 16 个请求同时砸进 Native 线程池。

Q:S2「中心优先」的实际收益有多大?
A:物理总时长不变(还是 ~2s),收益全在感知端:画面主体所在的瓦片提前约 0.5s 浮现,而用户对「第一眼看到的东西」的等待感知权重最高。它是四种排程器里唯一「零成本改善体验」的选项——一行排序换一笔心理收益。

Q:接缝检测的 z-score 阈值 3.0 怎么定的?
A:标定方法与接缝本身同源——先故意把 HALO 降为 1 跑一轮「已知有缝」的输出,观察 worstZ 的分布(实测会显著超过 3),再跑正确实现(worstZ 稳定在 1.5 以下),阈值取两者之间的对数中点。用可控故障标定报警线,比拍脑袋定常数可靠。

Q:等价性校验 maxDiff=0 会不会太严?
A:它严得有道理:两条路径用的是同一个 Kernel、同一套 BuildTaps、同一份底图,数学上逐位等价是可证的。若实测出现 ±1 的散差,说明某处存在未定义行为(比如未初始化缓冲或浮点求和顺序不一致),这类问题在别的输入上可能放大成可见缺陷——宁可 CI 严到抱怨,不要让未定义行为逍遥法外。

Q:这套排程抽象对非图像任务适用吗?
A:适用,而且几乎是直接照搬。TileScheduler 接口依赖的只有三件事:任务可枚举、任务纯函数、进度可计数。地图瓦片渲染、分块转码、长文档分段翻译,都是同一个形状——先把大计算切成纯函数小任务,然后所有调度智慧都发生在交互层。

Logo

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

更多推荐