/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Vision.Client; using Opc.Ua.Vision.Intent.Tests.Infrastructure; namespace Opc.Ua.Vision.Intent.Tests { /// /// Proves the off-server perception path: RunInference is /// refused because no local model is loaded, an external agent /// posts detections via SubmitDetections, and a downstream /// client reads the resulting DetectionResultType using the /// exact same client API it used against the on-server pipeline. /// The design claim the tests pin down: one contract, two producers. /// [TestFixture] [Category("Vision")] [Category("Integration")] [NonParallelizable] public sealed class VisionOffServerPerceptionTests { [SetUp] public async Task SetUpAsync() { m_fixture = new VisionIntentServerFixture( new VisionIntentServerFixture.Options { OffServer = true }); await m_fixture.StartAsync().ConfigureAwait(false); } [TearDown] public async Task TearDownAsync() { if (m_fixture != null) { await m_fixture.DisposeAsync().ConfigureAwait(false); } } [Test] public async Task RunInferenceIsRefusedByOffServerPipeline() { await using VisionIntentClientContext context = await m_fixture .ConnectAsync("off-server-refused").ConfigureAwait(false); NodeId pipelineNodeId = await ResolveSinglePipelineAsync(context) .ConfigureAwait(false); VisionPipelineClient pipeline = context.Vision.Pipeline(pipelineNodeId); ServiceResultException error = Assert.ThrowsAsync( async () => await pipeline.RunInferenceAsync().ConfigureAwait(false))!; Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported), "An off-server pipeline must refuse RunInference with Bad_NotSupported."); } [Test] public async Task SubmitDetectionsPublishesAResultReadableByTheSameClientContract() { await using VisionIntentClientContext context = await m_fixture .ConnectAsync("off-server-submit").ConfigureAwait(false); NodeId pipelineNodeId = await ResolveSinglePipelineAsync(context) .ConfigureAwait(false); VisionPipelineClient pipeline = context.Vision.Pipeline(pipelineNodeId); VisionFeedbackClient? feedback = await pipeline.OpenFeedbackAsync() .ConfigureAwait(false); Assert.That(feedback, Is.Not.Null, "An off-server pipeline must expose the Feedback object per ยง4.4."); VisionDetectionDataType detection = BuildValidDetection("TestCube", 1u); var detections = new[] { detection }.ToArrayOf(); var frameReference = new VisionImageReferenceDataType { Uri = "urn:test:off-server:frames:1", Format = VisionClipFormatEnum.Png, PixelFormat = "Mono8", Width = TestVisionCell.ImageWidth, Height = TestVisionCell.ImageHeight }; await feedback!.SubmitDetectionsAsync( VisionFeedbackPurposeEnum.Reconciliation, detections, frameReference, ByteString.Empty).ConfigureAwait(false); List results = new(); await foreach (VisionNodeEntry entry in pipeline.EnumerateResultsAsync()) { results.Add(entry); } Assert.That(results, Has.Count.EqualTo(1), "The off-server pipeline should have exactly one result after one submission."); VisionResultReader reader = context.Vision.Result(results[0].NodeId); VisionDetectionResultSnapshot snapshot = await reader.ReadDetectionAsync() .ConfigureAwait(false); List readBack = SnapshotDetections(snapshot); Assert.Multiple(() => { Assert.That(snapshot.ResultId, Is.Not.Null.And.Not.Empty); Assert.That(snapshot.ModelVersionUsed, Does.StartWith("agent-off-server:"), "Provenance must record that the model ran off the Server."); Assert.That(snapshot.FrameId, Is.EqualTo(TestVisionCell.CameraFrameId), "The result's FrameId must match the sensor's camera frame."); Assert.That(readBack, Has.Count.EqualTo(1)); Assert.That(readBack[0].ClassLabel, Is.EqualTo("TestCube")); Assert.That(readBack[0].Confidence, Is.EqualTo(detection.Confidence)); Assert.That(readBack[0].HasPose, Is.True); }); } [Test] public async Task PipelineExposesTheOffServerFacetSoClientsCanTellItApartFromOnServer() { await using VisionIntentClientContext context = await m_fixture .ConnectAsync("off-server-facet").ConfigureAwait(false); List profileNames = await ReadServerProfileArrayAsync(context.Session) .ConfigureAwait(false); string advertised = string.Join(", ", profileNames); Assert.That(profileNames, Is.Not.Empty, "The Server must advertise Vision facets in ServerCapabilities.ServerProfileArray."); Assert.That( profileNames.Any(p => p.Contains("Inference-OffServer", System.StringComparison.OrdinalIgnoreCase)), Is.True, FormattableString.Invariant($"An off-server pipeline must advertise VIS-Inference-OffServer. Advertised: {advertised}.")); Assert.That( profileNames.Any(p => p.Contains("Inference-OnServer", System.StringComparison.OrdinalIgnoreCase)), Is.False, FormattableString.Invariant($"An off-server pipeline must NOT advertise VIS-Inference-OnServer. Advertised: {advertised}.")); } private static async Task> ReadServerProfileArrayAsync( Opc.Ua.Client.ISession session) { NodeId profileArrayNodeId = global::Opc.Ua.VariableIds .Server_ServerCapabilities_ServerProfileArray; ArrayOf toRead = new ReadValueId[] { new() { NodeId = profileArrayNodeId, AttributeId = Attributes.Value } }.ToArrayOf(); ReadResponse response = await session.ReadAsync( null, 0.0, TimestampsToReturn.Neither, toRead, System.Threading.CancellationToken.None).ConfigureAwait(false); var profiles = new List(); if (response.Results.Count == 0 || !StatusCode.IsGood(response.Results[0].StatusCode)) { return profiles; } if (response.Results[0].WrappedValue.TryGetValue(out ArrayOf array)) { for (int ii = 0; ii < array.Count; ii++) { string? entry = array[ii]; if (!string.IsNullOrEmpty(entry)) { profiles.Add(entry); } } } return profiles; } private static VisionDetectionDataType BuildValidDetection(string classLabel, uint classId) { return new VisionDetectionDataType { DetectionId = "det-off-01", ClassLabel = classLabel, ClassId = classId, Confidence = 0.87, HasBoundingBox2D = true, BoundingBox2D = new VisionBoundingBox2DDataType { CenterX = 320.0, CenterY = 240.0, Width = 80.0, Height = 60.0, Rotation = 0.0 }, HasBoundingBox3D = false, HasPose = true, Pose = new VisionPose3DDataType { FrameId = TestVisionCell.CameraFrameId, Position = s_doubleValues1.ToArrayOf(), Orientation = s_doubleValues2.ToArrayOf(), Covariance = ArrayOf.Empty }, TrackId = classLabel }; } private static async Task ResolveSinglePipelineAsync(VisionIntentClientContext context) { ArrayOf pipelines = await context.Vision.DiscoverPipelinesAsync() .ConfigureAwait(false); Assert.That(pipelines.Count, Is.EqualTo(1)); NodeId nodeId = pipelines[0]; Assert.That(nodeId.IsNull, Is.False, "Pipeline NodeId must resolve."); return nodeId; } private static List SnapshotDetections( VisionDetectionResultSnapshot snapshot) { var buffer = new List(snapshot.Detections.Count); for (int ii = 0; ii < snapshot.Detections.Count; ii++) { buffer.Add(snapshot.Detections[ii]); } return buffer; } private VisionIntentServerFixture m_fixture = null!; private static readonly double[] s_doubleValues1 = [0.2, 0.1, 0.5]; private static readonly double[] s_doubleValues2 = [0.0, 0.0, 0.0, 1.0]; } }