HarmonyOS 7图像超分·显影排程:瓦片队列的调度设计空间与无接缝一致性验证
HarmonyOS 7图像超分·显影排程:瓦片队列的调度设计空间与无接缝一致性验证
暗房(
Image-Super-Resolution/sr-darkroom)把整图超分切成 4×4 共十六块瓦片,每块独立计算、按 120ms 节拍逐一浮现——全图算完只要 3~5ms,显影仪式却有两秒。这场仪式的观感背后,是这台机器更硬的一面:排程与一致性。瓦片化把「一次大计算」解构成「一队小计算」,随之而来是两个必须回答的工程问题:这队任务该怎么排——串行仪式感、有界并行、优先级抢跑、乱序完成,能否收敛成一个可插拔的调度器抽象?以及块与块之间的接缝,能否从「靠余量掩盖」升级为「可数学证明、可自动检测、可进 CI」?全文配图为 Mate 90 Pro 模拟器实机截图,结论均可复算。
一、被测机架:一台把大计算拆成小队列的机器

工程 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 中心优先 | ~2s | 120ms(主体瓦片提前 ~0.5s) | 精确 | 低 |
| S3 乱序+按位揭晓 | 同 S1 | 同 S1 | 精确(按位序) | 中高 |
关键结论:四者共用同一个引擎、同一批任务、同一个揭晓 UI 组件。切换排程器只是换一个 TileScheduler 实现——这就是瓦片化的真正红利:它把调度策略从算法问题降格为配置问题。
三、进度协议:显影中截图里藏着的状态机


两张「显影进行时」截图共同展示了一个显式状态机: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 的能力。
六、大画幅的预算账:排程的真正收益是「首块提前」

大画幅浴把输出推到 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 并发) |
三个结论:
- (T_{total}) 上瓦片化永远输给整图直出(多付调度与拼接成本)——所以「为了快而切块」是伪命题;
- (T_{first}) 上瓦片化赢一个数量级:用户看到第一块浮现的时间,从「全图算完」提前到「一块算完」。计算越慢、图越大,这个差距越大。流式的本质收益是首响应提前,不是总吞吐;
- 内存峰值下降一个数量级:整图 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 接口依赖的只有三件事:任务可枚举、任务纯函数、进度可计数。地图瓦片渲染、分块转码、长文档分段翻译,都是同一个形状——先把大计算切成纯函数小任务,然后所有调度智慧都发生在交互层。
更多推荐



所有评论(0)