mvdocs

# 多格式图像模型与公共接口

目标设计与 API 草案,尚未实施。总体边界和推进状态见设计概述。

本文负责 Info、格式与颜色/深度语义、内容版本、图像接口、类型化访问与工厂。图像端口传递一个图像值。

关联:内存 · ToolFlow · 示例 · 验证

# 一份 ImageInfo 描述图像

宽高、像素在内存中的排列方式、像素格式及可选采集信息合并为一个不可变 ImageInfo。调用方创建一份信息对象,直接交给图像工厂,不再组合多份描述对象。

public enum ImagePixelFormat
{
    Gray8 = 0,
    Rgb24 = 1,
    Bgr24 = 2,
    Depth16 = 3
}

public enum ImageColorEncoding
{
    Unspecified = 0,
    Srgb = 1,
    LinearRgb = 2
}

public sealed record ImageInfo
{
    public ImageInfo(
        int width,
        int height,
        ImagePixelFormat pixelFormat = ImagePixelFormat.Gray8,
        long? rowStepBytes = null,
        long startOffsetBytes = 0);

    public int Width { get; }
    public int Height { get; }
    public ImagePixelFormat PixelFormat { get; }
    public ImageColorEncoding ColorEncoding { get; init; }
    public long RowStepBytes { get; }
    public long StartOffsetBytes { get; }
    public int PixelBytesPerRow { get; }
    public long PixelByteCount { get; }

    public long? ImageId { get; init; }
    public DateTimeOffset? CapturedAt { get; init; }
    public int OriginX { get; init; }
    public int OriginY { get; init; }
    public double? DepthUnitMeters { get; init; }
    public ushort? InvalidDepthValue { get; init; }

    public void ValidateCapacity(long capacity);
    public ImageInfo GetRegion(int x, int y, int width, int height);
}

最常用的创建方式只有 new ImageInfo(width, height):默认 Gray8、无行尾额外字节、起始偏移为零,不必提供编号和时间。需要采集编号时使用 new ImageInfo(width, height) { ImageId = 7 }。

成员 调用方可直接理解的含义
Width / Height 图像宽高,单位为像素
PixelFormat 每个像素如何解释:Gray8、Rgb24、Bgr24 或 Depth16
ColorEncoding RGB/BGR 的编码声明:Unspecified、Srgb 或 LinearRgb;不等于通道顺序
RowStepBytes 从本行起点到下一行起点的字节距离,包含行尾空隙;负值表示倒序行
StartOffsetBytes 逻辑第一行在缓冲区中的起始字节位置
PixelBytesPerRow 一行有效像素占用的字节数,不包括行尾空隙
PixelByteCount 全部有效像素的字节数,不代表整份分配成本
ImageId / CapturedAt 可选业务编号与采集时间,不参与显示排序
OriginX / OriginY 当前视图左上角在原始采集坐标中的位置
DepthUnitMeters Depth16 原始值每单位对应的米数;为空表示未声明尺度,不能假定毫米
InvalidDepthValue Depth16 的可选无效码;为空表示未声明,不默认把零判为无效

rowStepBytes 为 null 时取 PixelBytesPerRow;显式零步进、无效尺寸、未知格式或算术溢出均拒绝。ValidateCapacity 再检查完整像素范围没有超出存储,但不授予读取权限。

ImageInfo 不拥有内存,也不绑定某个内容版本。IPixelImage.Info 保存信息,实际访问对象的 Version 表示已经取得的内容;因此图像信息与访问寿命不会混在一起。ImageInfo 是 sealed record,尺寸与字节排列在构造后固定,业务信息只能在初始化或通过 with 创建新对象时改变,已经提交的图像不会被后续原地改写信息。

GetRegion 检查区域位于父图像内,保留 RowStepBytes,按每像素字节数增加起始偏移,并增加 OriginX/OriginY;保留 ImageId/CapturedAt、ColorEncoding 与深度解释,返回新 ImageInfo,不复制像素。可以用有效 reader 和新信息创建另一张共享同一内容的图像。非 RGB/BGR 的首版格式只接受 ColorEncoding.Unspecified,未知枚举拒绝。

Gray8/Rgb24/Bgr24/Depth16 的每像素字节数分别为 1/3/3/2,PixelBytesPerRow 为 checked(Width * 每像素字节数)。Depth16 定义为小端无符号整数;初版类型化零复制访问要求运行平台为小端,其他平台显式拒绝,不静默进行字节交换。外部大端数据由适配器转换后导入。ushort 行视图不额外要求偏移或行步长为偶数;若 native 算法要求地址对齐,由 native 桥接单独验证或复制。

深度尺度在声明时必须为有限正数,有效原始值的物理深度为 raw * DepthUnitMeters。无效码先于尺度换算判定。非 Depth16 格式不得携带深度解释字段;构造与 with 派生的信息在创建图像、克隆、ROI、Open 和显示适配入口统一校验。多种无效码、置信度平面、相机内参和非线性标定不在这两个字段中隐式表达,后续按实际算法扩展。

native buffer 可使用长容量与长偏移,每行 span 长度仍为 int。目标 Display 的单视图 PixelByteCount 不超过 int.MaxValue,超限明确拒绝。Gray8/RGB24/BGR24/Depth16 均建立对应渲染路径,未知格式明确拒绝,不静默按 Gray8 解释。ImageFramePixelFormat 的迁移属于旧 API 清理。

# 像素范围计算

rowDelta = (info.Height - 1) * info.RowStepBytes
lowestByte = info.StartOffsetBytes + min(0, rowDelta)
endExclusive = info.StartOffsetBytes + max(0, rowDelta) + info.PixelBytesPerRow
0 <= lowestByte < endExclusive <= allocationCapacity
abs(info.RowStepBytes) >= info.PixelBytesPerRow

# 内容与显示身份

public readonly record struct ImageContentVersion(Guid BufferId, long Generation);
身份 意义 是否约束图像显示顺序
BufferId 一次有效分配或导入的身份 否
Generation 同一存储的内容代次,每次成功 Complete 后增加 否,不是业务帧号或显示请求代次
ContentVersion 同一存储的内容修订,来源于有效 reader 否,不跨图像比较大小
Info.ImageId 可选业务编号,可空、可重复、可倒退 否
Info.CapturedAt 可选采集时间 否
UpdateId 一次显示请求的唯一 Guid 否,不是公共帧序号
内部请求代次 阻止旧异步工作覆盖新请求 仅限控件内部

保留的历史静态图像可以再次显示;原内存已覆盖且没有保留副本的内容,无法凭图像编号恢复。ImageInfo 不能冒充 reader,也不能让已关闭图像重新获得像素访问权。

# 期望版本是访问条件

ImageContentVersion 由 BufferId 与 Generation 共同确定,表示哪份存储的哪一代完整内容。BufferId 在建立底层协调实体时生成,不使用数组地址或 SDK 指针代替;池或 SDK 以后复用相同地址建立新图像时取得新 BufferId。Generation 由内存层维护,调用方不设置。已初始化导入的首次 Generation 为 1;Rent 首次 Complete 后为 1;之后每次 Complete 严格增加,即使写回相同字节也视为一次新提交。Generation 不表示业务帧号、采集顺序、显示顺序或控件内部请求代次。

expectedVersion 是可选的精确匹配条件,不是目标帧号、等待未来版本的命令或读取历史内容的句柄。校验顺序为:验证令牌与 BufferId,等待所需读/写权限,确认内容有效,在同一个同步边界比较完整版本并授予权限。不能先比较版本再排队,不能用“大于等于期望值”代替相等。不提供期望版本时,申请取得实际授予时的内容。

版本值只记录身份,不保留内存,不阻止后续写入,也不提供回滚。IPixelImage 不提供看似稳定的当前 Version 属性;可靠版本从有效 ReadOnlyImage.Version、WritableImage.Complete/CompleteAndOpenRead 或安装结果取得。调用方可以保存值,但在下次申请成功前,不能据此假定原内容还存在。

Share 与 ROI 不提交内容,仍使用同一 BufferId 与 Generation。任意 ROI 完成写入都会推进整份分配的 Generation,其他 ROI 的旧期望版本也随之过期。Clone/Copy 分配新存储,使用新的 BufferId,Generation 从 1 开始,不能把原图令牌直接用于副本。写入中止使内容 Invalid,旧令牌不能继续读取;恢复写入成功后的 Generation 继续推进,不复用旧的有效令牌。

例如算法提交版本 (A, 6),随后调用 SetImage(image, expectedVersion: version)。若控件实际取得读取前另一写者提交了 (A, 7),显示请求以 VersionMismatch 失败;不会悄悄改成显示版本 7,也不会等待版本 6 再次出现。是否重新提交最新内容由宿主决定。若必须保证版本 6 可被捕获,应保持该阶段不被再次修改,或复制成独立图像再提交;仅传版本令牌不能代替这项生命周期安排。

读取阶段结束后再原地写入也可使用 expectedVersion:保存读取对象的 Version,关闭读取,再以该版本申请写入。授予时若已有其他写者修改内容,抛 ImageVersionMismatchException,节点自行决定重算或失败,不静默覆盖别人的处理结果。

Snapshot/Detach 对显示创建私有副本后,公开 CurrentVersion 继续表示来源内容身份;内部副本的读取版本另行记录。原图之后改写不使已经完成捕获的显示请求失败,也不改变旧画面。VersionMismatch 仅针对授予原图访问时的条件,不是对未来全部时间持续成立的保证。显示层按异常类型映射失败类别,不解析异常消息来区分版本过期与无效内容。

# 图像接口、像素类型与临时访问实例

流程连线传递一个 IPixelImage;需要特定像素格式的节点声明 IGray8Image、IRgb24Image、IBgr24Image 或 IDepth16Image。公共接口命名为 IPixelImage,泛型版本 IPixelImage<TPixel> 表达具体像素类型;Pixel 表明它是可访问像素的图像,避免与 Avalonia 的 IImage 混淆。IImageGeneric 不用于命名非泛型基接口。Gray 统一采用这一拼写,格式名称按“语义、位数”排列。

接口 像素类型 格式与语义
IGray8Image byte 8 位无符号灰度,每像素 1 字节
IRgb24Image Rgb24 24 位彩色,内存顺序 R、G、B,每通道 8 位
IBgr24Image Bgr24 24 位彩色,内存顺序 B、G、R,每通道 8 位
IDepth16Image ushort 16 位无符号原始深度,需要结合单位与无效值规则解释

不采用含糊的 IColor24Image;RGB 与 BGR 的算法输入和内存排列不能混用。以后增加 IGray16Image 时即使同样使用 ushort,也不能当成 IDepth16Image。像素位数描述存储,图像接口还表达语义。

public interface IPixelImage : IDisposable
{
    ImageInfo Info { get; }
    int Width { get; }
    int Height { get; }
    Guid BufferId { get; }
    ImageBufferAccess Access { get; }
    long RetainedByteLength { get; }

    IPixelImage Share();
    IPixelImage GetRegion(int x, int y, int width, int height);
    ValueTask<IPixelImage> CloneAsync(CancellationToken cancellationToken = default);

    ValueTask<ReadOnlyImage> OpenReadAsync(
        CancellationToken cancellationToken = default);
    ValueTask<ReadOnlyImage> OpenReadAsync(
        ImageContentVersion expectedVersion,
        CancellationToken cancellationToken = default);
    ValueTask<WritableImage> OpenWriteAsync(
        CancellationToken cancellationToken = default);
    ValueTask<WritableImage> OpenWriteAsync(
        ImageContentVersion expectedVersion,
        CancellationToken cancellationToken = default);
}

public interface IPixelImage<TPixel> : IPixelImage where TPixel : unmanaged
{
    new IPixelImage<TPixel> Share();
    new IPixelImage<TPixel> GetRegion(int x, int y, int width, int height);
    new ValueTask<IPixelImage<TPixel>> CloneAsync(
        CancellationToken cancellationToken = default);

    new ValueTask<ReadOnlyImage<TPixel>> OpenReadAsync(
        CancellationToken cancellationToken = default);
    new ValueTask<ReadOnlyImage<TPixel>> OpenReadAsync(
        ImageContentVersion expectedVersion,
        CancellationToken cancellationToken = default);
    new ValueTask<WritableImage<TPixel>> OpenWriteAsync(
        CancellationToken cancellationToken = default);
    new ValueTask<WritableImage<TPixel>> OpenWriteAsync(
        ImageContentVersion expectedVersion,
        CancellationToken cancellationToken = default);
}

public interface IGray8Image : IPixelImage<byte>
{
    new IGray8Image Share();
    new IGray8Image GetRegion(int x, int y, int width, int height);
    new ValueTask<IGray8Image> CloneAsync(CancellationToken cancellationToken = default);
}

public interface IRgb24Image : IPixelImage<Rgb24>
{
    new IRgb24Image Share();
    new IRgb24Image GetRegion(int x, int y, int width, int height);
    new ValueTask<IRgb24Image> CloneAsync(CancellationToken cancellationToken = default);
}

public interface IBgr24Image : IPixelImage<Bgr24>
{
    new IBgr24Image Share();
    new IBgr24Image GetRegion(int x, int y, int width, int height);
    new ValueTask<IBgr24Image> CloneAsync(CancellationToken cancellationToken = default);
}

public interface IDepth16Image : IPixelImage<ushort>
{
    new IDepth16Image Share();
    new IDepth16Image GetRegion(int x, int y, int width, int height);
    new ValueTask<IDepth16Image> CloneAsync(CancellationToken cancellationToken = default);
}

[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 3)]
public struct Rgb24
{
    public byte R;
    public byte G;
    public byte B;
}

[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 3)]
public struct Bgr24
{
    public byte B;
    public byte G;
    public byte R;
}

公共图像实现封装存储引用和不可变 ImageInfo。Width/Height 是 Info 的转发,Access 是当前 handle 的能力上限,不是实际是否已经持有写锁。ReadWrite 图像仅保持存储寿命,可按契约修改;ReadOnly 图像还承诺 handle 有效期内内容稳定。稳定性由底层只读存储的不可改写约束,或从已授予读取派生的持久读取引用保证。不可仅把可写图像换个 Access 返回值就声称稳定。

节点依据自己的算法契约调用 OpenReadAsync/OpenWriteAsync;执行器不预判读写、不代为提交,也不在返回时替节点关闭临时访问。ReadOnly 图像申请写入立即抛 NotSupportedException;需要修改时节点显式 CloneAsync 得到 ReadWrite 图像,再自行申请写入。ReadWrite 图像上的 OpenWriteAsync 仍按既定互斥规则等待,不隐式复制。

泛型接口用 new 提供类型更精确的成员;实现需显式桥接非泛型、泛型及语义接口成员。ValueTask<T> 不具有协变能力,不能仅靠改变返回类型宣称实现基接口。无论经哪个入口调用,一次 Open 都只授予一份权限,桥接不得重复打开。Share/GetRegion/CloneAsync 保留实际格式,精确接口上的返回值保留语义类型,避免灰度节点复制后退化为普通字节图像。

// 非泛型入口用于通用工具、序列化和显示适配。
public abstract class ReadOnlyImage : IDisposable
{
    public ImageInfo Info { get; }
    public int Width { get; }
    public int Height { get; }
    public ImageContentVersion Version { get; }
    public ReadOnlySpan<byte> GetPixelBytes(int row);
    public ReadOnlyImage Share();
    public IPixelImage ShareImage();
    public ImageBufferPin Pin();
    public void Dispose();
}

public sealed class ReadOnlyImage<TPixel> : ReadOnlyImage where TPixel : unmanaged
{
    public ReadOnlySpan<TPixel> GetPixelRow(int row);
    public new ReadOnlyImage<TPixel> Share();
    public new IPixelImage<TPixel> ShareImage();
}

public abstract class WritableImage : IDisposable
{
    public ImageInfo Info { get; }
    public int Width { get; }
    public int Height { get; }
    public Span<byte> GetPixelBytes(int row);
    public ImageBufferPin Pin();
    public ImageContentVersion Complete();
    public ReadOnlyImage CompleteAndOpenRead();
    public void Dispose();
}

public sealed class WritableImage<TPixel> : WritableImage where TPixel : unmanaged
{
    public Span<TPixel> GetPixelRow(int row);
    public new ReadOnlyImage<TPixel> CompleteAndOpenRead();
}

临时实例没有公开构造函数,由图像的 Open 方法创建。GetPixelRow 每行返回 Width 个像素;GetPixelBytes 返回 PixelBytesPerRow 个字节,两者均排除行尾空隙。RGB24 的 row[x] 是 Rgb24 而不是某一个通道字节;Depth16 的 row[x] 是 ushort 原始深度值。

ReadOnlyImage 与 WritableImage 是两条独立继承链,不能通过转型升级读权限。WritableImage<TPixel> 修改的是节点自己构建的输出或流程外可写存储,不允许改变绑定的尺寸、格式或步长。工具流输入统一为 Access=ReadOnly,不能因持有 IPixelImage 就申请输入写入。当前公共接口仍有 OpenWriteAsync,输入对象以能力上限立即拒绝;不宣称现有格式接口已在编译期隐藏该方法。

只接受库已登记、布局明确的像素类型与格式组合。unmanaged 约束本身不能证明任意结构体可解释为某种图像;禁止用相同 sizeof 绕过格式验证,禁止默认把 Depth16 解释为灰度。类型化行访问使用原存储的行起点,支持负步长和行尾空隙,不要求把整个分配直接转换为一个连续 Span<TPixel>。

// PixelImage 改为工厂,不再是节点依赖的具体图像类。
public static class PixelImage
{
    public static IPixelImage Create(ImageBuffer buffer, ImageInfo info);
    public static IPixelImage Copy(
        ReadOnlySpan<byte> pixels, ImageInfo info,
        ImageBufferAccess access = ImageBufferAccess.ReadOnly,
        MemoryPool<byte>? pool = null);
    public static ValueTask<IPixelImage> CloneAsync(
        IPixelImage image, MemoryPool<byte> pool,
        CancellationToken cancellationToken = default);
    public static IGray8Image RentGray8(ImageInfo info, MemoryPool<byte>? pool = null);
    public static IRgb24Image RentRgb24(ImageInfo info, MemoryPool<byte>? pool = null);
    public static IBgr24Image RentBgr24(ImageInfo info, MemoryPool<byte>? pool = null);
    public static IDepth16Image RentDepth16(ImageInfo info, MemoryPool<byte>? pool = null);
}

Create 根据 info.PixelFormat 返回实现对应语义接口的对象,校验格式和容量,成功时复制 buffer 生命周期引用,不消费调用方 handle。Rent 系列校验 Info 与工厂指定格式完全一致,按有效地址范围分配未初始化存储;首次写入完成前不能读取。无效范围、未知格式或超出托管租用容量限制时抛错,不偷偷修改 Info。具体实现类可以内部封装,不要求每个格式公开一个类。

Share/GetRegion 返回各自独立关闭的对象,不复制像素,并保持原 handle 的能力与内容稳定性:ReadWrite 的 Share 不固定内容;ReadOnly 的 Share/ROI 继续保留同一稳定版本。CloneAsync 复制逻辑像素,始终返回已初始化、可写、格式相同的新图像,整理排列并保留 ColorEncoding、业务信息及深度解释。失败清理新分配;克隆不是格式转换。

ReadWrite 图像不是不可变快照。需要固定阶段结果时,节点在稳定读取上调用 ShareImage,取得同格式、Access=ReadOnly 的独立图像 handle。该方法复制现有读取引用,不复制字节、不消费原读取、也不重新排队;返回对象可以跨队列与任务阶段存活。实际对象继续实现相应语义接口,泛型入口保留像素类型。ReadOnly 图像 OpenReadAsync 复制其已授予读取,不排在等待写者之后;其 Share/ROI 同样不得退化为可写存储。

可写根对象仍然存在时,对同一分配的写入会等待全部稳定发布引用退出。只读发布不是原图回滚或自动复制策略。CloneAsync 在稳定读取上复用现有权限,不重复申请;调用方也不应持有可写根的临时读取再调用根对象 CloneAsync,以免排队写者造成等待环。

期望版本用于输出构建/流程外 ReadWrite 图像的阶段竞争校验,不替代稳定发布。工具流内已交给下游的输入句柄已固定内容,通常无需再带期望版本。执行器检查发布契约但不为节点申请像素权限,生产者负责在输出交接前稳定发布。

ReadOnlyImage.Share 复制当前已取得的读取权限与 Info,不排队申请新版本,关闭其中一个不影响其余对象。读取的 Share 和稳定图像的 Share 都延长禁止写入的范围,必须显式关闭;只有可写根图像的 Share 不保留读取。控件内部从稳定读取派生任务时使用已有权限,不重新 OpenReadAsync 排到等待写者后面而自锁。

# 格式扩展边界

首版为单平面、逐行可寻址的 Gray8、RGB24、BGR24、Depth16。ImageInfo 与接口共同校验语义和布局;明确每像素字节数、类型大小、小端解释、行步长、ROI 范围和无效值规则。只修改 PixelFormat 枚举不能算完成一个新格式:还需工厂、类型桥接、复制/ROI、native 访问、序列化/解码适配及使用者能力声明。

图像模型允许某格式不被某个算法或显示后端支持,消费者明确拒绝,不把无法实现的成员做成静默 no-op。未来 Gray16/Float32Depth/BGRA/Bayer 等按真实语义增加接口和显式转换;平面 YUV、GPU-only 与设备 fence 需要新的平面或设备访问能力,不硬塞进现有 GetPixelRow。首版不承诺这些未来格式可以只加枚举就无破坏接入。

RGB/BGR 的字节顺序不等于色彩空间。Info 增加可选 ColorEncoding,至少区分 Unspecified、Srgb 和 LinearRgb;灰度与深度不借该字段冒充标定。显示适配对未知编码保留显式状态/诊断,不悄悄宣称完成色彩管理。核心保存内容,转换在明确的算法/解码/显示适配层进行。相机标定矩阵、置信度平面和复杂深度模型按实际需要扩展,不混入 BufferId/Generation。