像素写回后红蓝颠倒,或者第二行错位,往往不是颜色计算出了问题,而是读取者和写入者对缓冲布局没有达成一致。像素工坊第四篇不靠一张复杂照片猜原因,改用四个已知颜色和一个刻意带空隙的缓冲区。只要通道、偏移或行跨度理解错一个,读回结果就会立即暴露差异。

像素通道和内存行跨度

一、把实验变量压缩到四个像素

工程仍采用 API 12 的 Stage 模型,沿用像素工坊项目名。本篇新增公共目录的通道实验文件,并替换首页;不需要 PNG 样本文件,也不读取任何外部资源。前面几篇从编码内容生成位图,这次直接构造颜色数组,目的是检查像素接口的内存契约。创建工作位图的输入是原始颜色,因此调用创建位图函数,不能把它交给图像源当成压缩图片。

工作位图只有四列三行,初始全部为不透明灰色。我们只改写其中横坐标一、纵坐标一开始的两列两行,四个新颜色按行排列为红、绿、蓝、黄。区域小到可以手工写出每个通道的值,也能明确看出数组布局与图像位置之间的关系。位图边缘保留灰色,便于日后增加整图读回时检查区域写入是否误伤其他位置。

缓冲区开头预留四个字节,每行承载两个像素共八个字节,但下一行起点相距十二个字节。于是第一行结束后还有四字节空隙。这个空隙不是第三个像素,也不应该被解释成颜色。最后一行只需要保留实际像素数据,所以最小缓冲长度是偏移加前面各行的跨度,再加最后一行的有效字节数,总共二十四字节。

二、用同一套布局完成写入与读回

以下代码保存为公共目录中的通道实验文件。源缓冲与输出位图都显式指定蓝绿红透明度的顺序,透明度全部为不透明。初始化选项中用于描述输入缓冲格式的字段从 API 12 提供,因此本篇不能直接降低到旧基线而保持代码不变。实验没有把图像编码格式与原始像素格式混用,也没有省略透明度类型。

import { image } from '@kit.ImageKit';

export interface ChannelResult {
  colors: string[];
  values: string[];
}

function hex(value: number): string {
  return value.toString(16).padStart(2, '0');
}

export async function runStrideExperiment(): Promise<ChannelResult> {
  const seed = new Uint8Array(4 * 3 * 4);
  for (let i = 0; i < seed.length; i += 4) {
    seed.set([224, 224, 224, 255], i);
  }
  const map = await image.createPixelMap(seed.buffer as ArrayBuffer, {
    size: { width: 4, height: 3 }, editable: true,
    srcPixelFormat: image.PixelMapFormat.BGRA_8888,
    pixelFormat: image.PixelMapFormat.BGRA_8888,
    alphaType: image.AlphaType.OPAQUE
  });
  try {
    const expected: number[][] = [
      [0, 0, 255, 255], [0, 255, 0, 255],
      [255, 0, 0, 255], [0, 255, 255, 255]
    ];
    const input = new Uint8Array(24);
    input.fill(170);
    for (let row = 0; row < 2; row++) {
      for (let col = 0; col < 2; col++) {
        input.set(expected[row * 2 + col], 4 + row * 12 + col * 4);
      }
    }
    const region: image.Region = {
      x: 1, y: 1, size: { width: 2, height: 2 }
    };
    await map.writePixels({
      pixels: input.buffer as ArrayBuffer, offset: 4, stride: 12, region: region
    });
    const output = new Uint8Array(24);
    output.fill(204);
    await map.readPixels({
      pixels: output.buffer as ArrayBuffer, offset: 4, stride: 12, region: region
    });
    const colors: string[] = [];
    const values: string[] = [];
    for (let row = 0; row < 2; row++) {
      for (let col = 0; col < 2; col++) {
        const start = 4 + row * 12 + col * 4;
        const actual: number[] = [];
        for (let channel = 0; channel < 4; channel++) {
          const value = output[start + channel];
          if (value !== expected[row * 2 + col][channel]) {
            throw new Error('像素读回不一致');
          }
          actual.push(value);
        }
        colors.push(`#${hex(actual[2])}${hex(actual[1])}${hex(actual[0])}`);
        values.push(actual.join(', '));
      }
    }
    return { colors: colors, values: values };
  } finally {
    await map.release();
  }
}

区域读写采用一对对应接口。区域结构中的偏移表示缓冲区里的字节起点,区域坐标表示位图里的像素起点,这两个起点属于不同空间。把横坐标一误当成缓冲偏移一,会从颜色通道中间开始读取,后面每个颜色都错位。反过来,把缓冲区预留的四字节当成位图边距,也会把图像位置挪到错误区域。

每个像素的缓冲位置使用同一个公式计算:起始偏移,加行号乘跨度,再加列号乘四。行号与列号都相对于被读写的区域,而不是相对于整张位图。区域在图像中的位置只出现在区域结构里。这样的分工让同一块两行缓冲能够写入图像的不同位置,而无需跟着改变缓冲索引公式。

四个颜色故意选择容易区分通道顺序的组合。红色在这套缓冲顺序里是零、零、二百五十五、二百五十五;蓝色则把第一个通道置满。绿色无法单独揭示红蓝交换,黄色增加了两个同时非零的颜色通道。只测试全黑或全白会让多种错误排列都得到相同画面,因此它们不适合作为本实验唯一的样本。

填充区域使用另一种固定数值,便于在调试器里看出哪些字节属于有效像素。但代码只断言四个像素的通道,没有把填充区是否保持原值当成主要功能要求。我们关心的是显式指定的区域能否正确往返,不能将未承诺的旁路行为写成所有实现都必须满足的结论。若需要检查写入范围,应另做完整位图读取并对区域外像素进行比较。

三、让页面显示读回值,而非输入常量

首页根据实际读回的四通道值构造色块,先从蓝绿红顺序重新排列为界面颜色所需的红绿蓝字符串,再显示原始四通道数字。这样色块和数字都来自同一次读回结果,输入数组不会绕过验证直接进入界面。任何一个通道不一致都会使实验失败,页面也不会显示四个像素一致的状态。

import { ChannelResult, runStrideExperiment } from '../common/ChannelLab';

@Entry
@Component
struct Index {
  @State colors: string[] = [];
  @State values: string[] = [];
  @State status: string = '等待开始';
  @State busy: boolean = false;
  @State done: boolean = false;
  private alive: boolean = true;
  private visible: boolean = true;

  private async run(): Promise<void> {
    if (!this.alive || !this.visible || this.busy || this.done) { return; }
    this.busy = true;
    this.status = '正在读写';
    try {
      const result: ChannelResult = await runStrideExperiment();
      if (!this.alive) { return; }
      this.colors = result.colors;
      this.values = result.values;
      this.status = '四个像素读回一致';
      this.done = true;
    } catch (error) {
      if (this.alive) { this.status = `实验失败:${(error as Error).message}`; }
    } finally {
      if (this.alive) { this.busy = false; }
    }
  }

  onPageShow(): void { this.visible = true; }

  onPageHide(): void { this.visible = false; }

  aboutToDisappear(): void {
    this.alive = false;
    this.visible = false;
  }

  build() {
    Column({ space: 16 }) {
      Text('像素工坊').fontSize(16).fontColor('#756574')
      Text('行跨度实验').fontSize(28).fontWeight(FontWeight.Bold)
        .fontColor('#352B37')
      Text('BGRA_8888 · 区域 2 × 2').fontSize(14).fontColor('#756574')
      Text('偏移 4 B   ·   跨度 12 B').fontSize(17).fontColor('#864A79')
      Column({ space: 10 }) {
        ForEach([0, 1, 2, 3], (index: number) => {
          Row({ space: 14 }) {
            Column().width(40).height(40)
              .backgroundColor(this.colors[index] ?? '#E4DEE4').borderRadius(8)
            Column({ space: 4 }) {
              Text(`像素 ${index + 1}`).fontSize(14).fontColor('#756574')
              Text(this.values[index] ?? '尚未读取')
                .fontSize(15).fontColor('#352B37')
            }.alignItems(HorizontalAlign.Start)
          }.width(312).height(66).padding({ left: 16 })
            .backgroundColor('#FFFFFF').borderRadius(12)
        }, (index: number) => index.toString())
      }
      Text(this.status).fontSize(19).fontColor('#864A79')
      Button('运行读写实验').width(192).height(42)
        .backgroundColor(this.done || this.busy ? '#E4DEE4' : '#864A79')
        .fontColor(this.done || this.busy ? '#756574' : '#FFFFFF')
        .enabled(!this.busy && !this.done).onClick(() => { this.run(); })
    }.width('100%').height('100%').padding({ top: 24 })
      .backgroundColor('#F7F2F6')
  }
}

四个像素读回值的应用页面

图 2:四个像素读回一致后的可达页面示意。红、绿、蓝、黄色块与四通道值由返回结果生成,不显示虚构性能数据。

页面故意不用把四乘三的位图放大到整屏作为唯一证据。放大时可能发生插值,截图采样还会受到显示流程影响,不适合验证内存中的整数通道值。这里用文字保留准确数字,色块只是辅助辨认。图像本身在读回完成后就被释放,返回给页面的只有普通数组,界面不再承担原生位图的生命周期。

这也使退出行为更清楚:实验函数负责释放工作对象,页面只判断自身是否仍然存活。隐藏时保留当前任务和普通数组,重新显示时继续使用,读写可以正常结束。只有真正销毁才关闭回写;销毁回调不清空数组、不修改状态变量。没有必要为了显示四个数字把位图挂到全局变量里,也不会让新建页面复用旧实例尚未完成的对象。

四、三次修改分别揭露三类错误

第一轮保持默认代码,期望四个像素依次为红、绿、蓝、黄。第二轮只把写入时的行跨度改为八,数组填充与读回仍保持原来的十二。第二行会从不同位置取值,检查应报告像素不一致。这个试验验证的是写入参数与实际缓冲布局必须一致,不是在证明八字节跨度本身非法;若布局也改成紧凑排列,八字节本来就是合法值。

第三轮保留跨度,把红色通道数组改成红绿蓝顺序,却仍声明它是蓝绿红格式。读回的实际颜色会发生红蓝置换,说明格式声明不能自动理解人的颜色意图。系统按照声明解释字节,调用方需要让字节内容与声明一致。最后恢复数组,只把区域宽度改成三而不改变缓冲长度,应该在进入系统前重新计算布局;生产代码应直接拒绝这类不足的缓冲,而不是拿它继续试运气。

偏移、行跨度和像素通道的内存布局

图 3:二十四字节缓冲的布局模型。有效像素起点为四、八、十六、二十,行间填充不属于任何像素。

布局校验可以写成通用前置条件:跨度至少为区域宽乘每像素字节数,偏移不能为负,缓冲长度至少覆盖最后一个像素的最后一个通道,区域本身不能越过位图边界。本例所有数值是固定常量,所以直接展示计算;接收用户输入或其他模块提供的区域时,应先校验整数和上限,再分配缓冲,避免异常只在原生接口深处出现。

不要把这里的显式缓冲跨度误认为所有位图内部的实际行跨度。内部存储可能有自己的对齐要求,图像信息中也有相应字段;区域接口则让调用方描述这一次提供的缓冲布局。整图缓冲读写与区域读写还有各自的格式规则。若切换到整图接口,应成对核对读写方法的文档,不能因为两种接口都接收缓冲区就随意交叉使用。

透明度在这一轮被固定为完全不透明,这让预乘与非预乘的差异暂时消失。若把透明度改为一半,同时仍保留满值颜色通道,必须重新确定输入是否采用预乘表示。否则颜色变暗可能被误诊为通道颠倒。下一篇编码策略会用明确的透明样本讨论这个边界,本篇先把整数通道和行布局验证到可解释的程度。

实验规模小,也不代表可以把相同循环不加限制地用于整幅照片。大图逐像素处理会增加计算与额外缓冲占用,需要考虑执行位置、批量大小与资源预算。本例没有测量速度,也没有把四个像素的操作时间外推到相册处理。它提供的是内存布局的正确性基线,性能工作应在这条基线成立之后再进行。

验证报告可以只记录四类信息:输入与输出像素格式、区域坐标和尺寸、偏移及跨度、实际通道值。设备型号与运行版本用于复现环境,耗时只有在真正测量时才加入。这样一次失败就能沿着明确字段排查,而不是只附一张颜色不对的截图。颜色图和数字值共同保留,讨论更容易落到具体字节。

如果要扩展为通用图像滤镜,应首先确定滤镜处理的是哪种颜色表示,以及是否需要保留透明度。通道布局正确只是必要条件,色彩空间、动态范围和预乘规则仍会影响运算含义。本篇明确使用不透明八位颜色,因而可以用整数值做严格往返比较。超出这个范围时应重新定义预期,而不是沿用四个色块就宣称算法对所有图像成立。

如果需要把实验推广到多通道格式,首先要替换每像素字节数这个前提。有的格式并不是每个像素连续存放四个字节,不能只把公式里的四改成另一个常数就宣称支持。平面布局、子采样与通道精度都会改变寻址方式。本篇刻意限制为明确的四字节格式,让每个地址可以手工复核,避免通用接口的名字掩盖实际布局差异。

数值比较还应在读回完成后进行。若提前遍历输出数组,看到的可能只是初始化填充值,错误就会被误认为系统写入失败。示例在每个异步读写调用前后都保留清晰顺序,验证代码只读取已经完成的结果。这个顺序与接口采用回调还是承诺无关;改变语法并不会改变数据何时才有效的契约。

参考文档

Logo

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

更多推荐