mvdocs

# 机器视觉图像与显示使用示例

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

本文集中展示契约,不构成节点框架或现有可执行代码。所有权、接管和线程调度须与各专题共同阅读。

关联:工厂 · ToolFlow · 显示 · 采集

# 节点示例:自行读取输入、写入输出

// 只展示处理一个图像项的辅助方法,不是整个流程节点的执行模型。
public sealed class ThresholdOperation
{
    public async ValueTask ProcessOneAsync(
        IGray8Image input,
        IImageOutputCollector outputs,
        CancellationToken cancellationToken)
    {
        using var source = await input.OpenReadAsync(cancellationToken);
        using var output = PixelImage.RentGray8(new ImageInfo(source.Width, source.Height)
        {
            ImageId = source.Info.ImageId,
            CapturedAt = source.Info.CapturedAt,
            OriginX = source.Info.OriginX,
            OriginY = source.Info.OriginY
        });

        using var target = await output.OpenWriteAsync(cancellationToken);
        ProcessRows(source, target, cancellationToken);
        cancellationToken.ThrowIfCancellationRequested();
        using var published = target.CompleteAndOpenRead();
        var result = (IGray8Image)published.ShareImage();
        try
        {
            outputs.AddOwned("Images", result);
        }
        catch
        {
            result.Dispose();
            throw;
        }
    }

    private static void ProcessRows(
        ReadOnlyImage<byte> source,
        WritableImage<byte> target,
        CancellationToken cancellationToken)
    {
        for (var y = 0; y < source.Height; y++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var sourceRow = source.GetPixelRow(y);
            var targetRow = target.GetPixelRow(y);
            for (var x = 0; x < source.Width; x++)
            {
                targetRow[x] = sourceRow[x] >= 128 ? (byte)255 : (byte)0;
            }
        }
    }
}

一个实际节点可以多次调用上述辅助方法、结合多个输入、输出零项或多项。每个输入借用和每个输出接管都独立计数。ShareImage 实际对象保留 Gray8 接口,此处转换只是受验证的接口断言。节点自行完成写入并释放临时访问,执行上下文只接管已经准备好的输出。

# 彩色与深度节点的扩展方式

节点 输入与输出契约 节点内部读取
RGB 转灰度 IRgb24Image 输入项、IGray8Image 输出项 ReadOnlyImage<Rgb24>,逐像素使用 R/G/B
BGR 转 RGB IBgr24Image 输入项、IRgb24Image 输出项 ReadOnlyImage<Bgr24>,明确重排通道
深度范围筛选 IDepth16Image 输入项、IGray8Image 输出项 ReadOnlyImage<ushort>,先排除无效码再按尺度判断
深度滤波 IDepth16Image 输入项及输出项 保留尺度与无效码,节点决定是否写入副本
通用图像保存 IPixelImage 输入项,可没有图像输出 非泛型 GetPixelBytes 结合 Info.PixelFormat 处理

例如深度节点自行打开其输入:

static async ValueTask<double?> ReadDepthMetersAsync(
    IDepth16Image input, int x, int y, CancellationToken token)
{
    using var image = await input.OpenReadAsync(token);
    return ReadDepthMeters(image, x, y);
}

static double? ReadDepthMeters(ReadOnlyImage<ushort> image, int x, int y)
{
    var raw = image.GetPixelRow(y)[x];
    if (image.Info.InvalidDepthValue is ushort invalid && raw == invalid)
    {
        return null;
    }

    if (image.Info.DepthUnitMeters is not double unit)
    {
        throw new InvalidOperationException("Depth scale is required.");
    }

    return raw * unit;
}

像素格式决定类型化访问方式,不决定节点访问期限或读写权限。新增格式时同步扩展格式枚举、信息验证、类型化工厂与临时访问桥接;确有业务语义差异时增加命名接口,不为每种算法另造图像接口。

# 使用示例

以下是目标接口示例,尚不能用于当前源码。控件调用在 UI 线程执行;图像工厂、文件解码和算法按宿主调度执行,不依靠示例中的上下文捕获推断真实线程。

# 算法图像直接显示

// image 是流程输出项,例如 IGray8Image / IRgb24Image / IDepth16Image。
var update = viewer.SetImage(image);
var result = await update.Completion.WaitAsync(token);
if (result.Status == ImageUpdateStatus.Failed)
{
    // 由宿主呈现 result.Error 或选择下一次提交。
}

此调用不转移 image 的所有权。算法若只读取可并发申请读取;若需要写入同一分配,先选择 Snapshot、脱离或自行克隆。

viewer.SetImage(image);
viewer.SetImage(image); // 显式再次捕获,无编号递增要求。

# 保留屏幕旧图,原图继续修改

static async Task<ImageDisplayUpdate> ModifyAndShowAsync(
    ImageDisplay viewer,
    IGray8Image image,
    CancellationToken token)
{
    if (image.Access != ImageBufferAccess.ReadWrite)
    {
        throw new ArgumentException("本例要求可独占编辑的图像;稳定输入应先复制。", nameof(image));
    }

    var first = viewer.SetImage(image, ImageInputRetention.Snapshot);
    var installed = await first.Completion.WaitAsync(token);
    if (installed.Status != ImageUpdateStatus.Installed)
    {
        throw new InvalidOperationException("显示输入未成功安装。");
    }

    await first.InputReleased.WaitAsync(token);
    ImageContentVersion version;
    using (var writable = await image.OpenWriteAsync(installed.Version!.Value, token))
    {
        // 示例同步处理;耗时算法应在后台调度后再回 UI 提交。
        InvertGray(writable);
        token.ThrowIfCancellationRequested();
        version = writable.Complete();
    }

    return viewer.SetImage(image, expectedVersion: version);
}

static void InvertGray(WritableImage<byte> image)
{
    for (var y = 0; y < image.Height; y++)
    {
        var row = image.GetPixelRow(y);
        for (var x = 0; x < row.Length; x++)
        {
            row[x] = (byte)(255 - row[x]);
        }
    }
}

本例处理的是明确独占使用的可写图像,不是普通任务边上的稳定只读输入。Snapshot 不会把 ReadOnly handle 升级为可写。它只结束 first 的输入访问;其他稳定图像、窗口或读取尚在时仍会等待,其他写者抢先修改则期望版本失败。RetainInput 可用 DetachInputAsync 保存旧画面并释放本次访问。

# 文件与 SDK 临时图像

using var fileImage = await PixelImageDecoder.LoadAsync(
    new FileImageSource(@"D:\Images\sample.png"), token);
var update = viewer.SetImage(fileImage);

本次 SetImage 已有自己的 Share,调用方之后可以关闭 fileImage;绑定重用和重新挂载需要宿主继续保持原对象有效。并发文件加载的意图顺序由宿主在调用 SetImage 前验证。

static unsafe IPixelImage CopyCameraCallbackImage(
    nint address, int accessibleLength, ImageInfo info)
{
    var pixels = new ReadOnlySpan<byte>((void*)address, accessibleLength);
    return PixelImage.Copy(pixels, info);
}

SDK 保证同步复制期间可读,返回后不访问临时指针。返回的 IPixelImage 由调用方持有并调度 UI 提交;在 UI 调度前累积的图像也需要宿主限流和释放。长期 SDK 引用则通过 ImageBuffer.ImportNative 和 PixelImage.Create 包装,不必为显示另造来源对象。

# 彩色与深度取样

viewer.DepthDisplay = new DepthDisplayOptions(
    minimumRawValue: 500,
    maximumRawValue: 5000,
    colorMap: DepthColorMap.Viridis);

viewer.SetImage(depthImage);

if (viewer.TryGetPixel(pointerPosition, out var pixel))
{
    if (pixel.TryGetDepthMeters(out var meters))
    {
        ShowDepth(meters);
    }
    else if (pixel.TryGetDepth16(out var raw))
    {
        ShowRawDepth(raw, pixel.IsValid);
    }
    else if (pixel.TryGetRgb24(out var rgb))
    {
        ShowColor(rgb.R, rgb.G, rgb.B);
    }
}

工具使用样本自身的格式与身份,不把用户刚设置的新 Source 当作当前已显示图像。BGR 工具分支相应调用 TryGetBgr24;深度无效值不能用普通物理距离显示。