# 多格式图像模型与公共接口
目标设计与 API 草案,尚未实施。总体边界和推进状态见设计概述。
本文负责 Info、格式与颜色/深度语义、内容版本、图像接口、类型化访问与工厂。图像端口传递一个图像值。
# 一份 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。