/* ========================================================================
* 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.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua.Server;
using Opc.Ua.Vision.Server;
namespace Opc.Ua.Vision.Intent.Tests.Infrastructure
{
///
/// Deterministic on-server inference provider used by the loop
/// tests. Derives detections from the shared
/// — no GPU required. The reported pose is expressed in the
/// camera_eih frame the sensor is calibrated against, so a
/// client composing pose from camera → world lands on the part's
/// known world position.
///
internal sealed class TestGroundTruthInferenceProvider : IVisionInferenceProvider, IDisposable
{
public TestGroundTruthInferenceProvider(TestBinWorld world)
{
m_world = world ?? throw new ArgumentNullException(nameof(world));
}
public bool IsAttached => m_target != null;
public NodeId PipelineNodeId => m_target?.PipelineNodeId ?? NodeId.Null;
public NodeId SensorNodeId => m_target?.SensorNodeId ?? NodeId.Null;
public NodeId DeploymentNodeId => m_target?.DeploymentNodeId ?? NodeId.Null;
///
/// Called from the test-cell configurator once the pipeline
/// exists and its Results folder is created.
///
/// is null.
///
public void Attach(TestInferenceTarget target)
{
if (target == null)
{
throw new ArgumentNullException(nameof(target));
}
if (Interlocked.CompareExchange(ref m_target, target, null) != null)
{
throw new InvalidOperationException(
"TestGroundTruthInferenceProvider has already been attached.");
}
}
public bool TryGetResult(string resultId, out DetectionResultState state)
{
return m_results.TryGetValue(resultId, out state!);
}
public async ValueTask RunInferenceAsync(
VisionInferenceRunRequest request,
CancellationToken cancellationToken)
{
TestInferenceTarget target = RequireTarget();
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList parts = m_world.Snapshot();
var detections = new List(parts.Count);
for (int ii = 0; ii < parts.Count; ii++)
{
TestPartSnapshot snapshot = parts[ii];
if (snapshot.Location != TestPartLocation.InBin)
{
continue;
}
(double xc, double yc, double zc) = target.WorldToCamera(
snapshot.WorldX, snapshot.WorldY, snapshot.WorldZ);
var poseInCamera = new VisionPose3DDataType
{
FrameId = target.CameraFrameId,
Position = new[] { xc, yc, zc }.ToArrayOf(),
Orientation = s_identityOrientation.ToArrayOf(),
Covariance = ArrayOf.Empty
};
var box3D = new VisionBoundingBox3DDataType
{
Center = poseInCamera,
Size = new[]
{
snapshot.Part.Size[0],
snapshot.Part.Size[1],
snapshot.Part.Size[2]
}.ToArrayOf()
};
double halfW = snapshot.Part.Size[0] * 0.5;
double halfH = snapshot.Part.Size[1] * 0.5;
double centerU = 300.0 + (snapshot.Part.ClassId * 40.0);
double centerV = 200.0 + (snapshot.Part.ClassId * 30.0);
var box2D = new VisionBoundingBox2DDataType
{
CenterX = centerU,
CenterY = centerV,
Width = halfW * 4000.0,
Height = halfH * 4000.0,
Rotation = 0.0
};
detections.Add(new VisionDetectionDataType
{
DetectionId = FormattableString.Invariant(
$"det-{snapshot.Part.ClassLabel}-{snapshot.Part.ClassId}"),
ClassLabel = snapshot.Part.ClassLabel,
ClassId = snapshot.Part.ClassId,
Confidence = 0.98,
HasBoundingBox2D = true,
BoundingBox2D = box2D,
HasBoundingBox3D = true,
BoundingBox3D = box3D,
HasPose = true,
Pose = poseInCamera,
TrackId = snapshot.Part.ClassLabel
});
}
string resultId = "det-" + Guid.NewGuid().ToString("N");
DateTimeUtc timestamp = request.Timestamp.IsNull
? DateTimeUtc.From(DateTime.UtcNow)
: request.Timestamp;
await PublishAsync(
target, resultId, timestamp, request, detections.ToArray().ToArrayOf(), cancellationToken)
.ConfigureAwait(false);
return new VisionInferenceRunResult(ServiceResult.Good, resultId);
}
public ValueTask StartContinuousAsync(
NodeId pipeline, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
TestInferenceTarget target = RequireTarget();
if (pipeline.IsNull || !pipeline.Equals(target.PipelineNodeId))
{
return new ValueTask(new ServiceResult(
StatusCodes.BadNodeIdUnknown,
LocalizedText.From("Pipeline id does not match the attached test pipeline.")));
}
return new ValueTask(ServiceResult.Good);
}
public ValueTask StopAsync(
NodeId pipeline, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return new ValueTask(ServiceResult.Good);
}
public void Dispose()
{
}
private TestInferenceTarget RequireTarget()
{
return m_target ??
throw new InvalidOperationException(
"TestGroundTruthInferenceProvider has not been attached.");
}
private async Task PublishAsync(
TestInferenceTarget target,
string resultId,
DateTimeUtc timestamp,
VisionInferenceRunRequest request,
ArrayOf detections,
CancellationToken cancellationToken)
{
ISystemContext context = target.SystemContext;
var qualifiedName = new QualifiedName(resultId, target.InstanceNamespaceIndex);
DetectionResultState state = context.CreateInstanceOfDetectionResultType(
target.ResultsFolder, qualifiedName);
state.ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.Organizes;
if (state.ResultId != null)
{
state.ResultId.Value = resultId;
}
if (state.CreationTime != null)
{
state.CreationTime.Value = timestamp;
}
state.CreateOrReplaceSensor(context, null).Value = request.Sensor;
state.CreateOrReplacePipeline(context, null).Value = request.Pipeline;
state.CreateOrReplaceModelVersionUsed(context, null).Value = ModelVersion;
state.CreateOrReplaceConfidence(context, null).Value = 0.98;
state.CreateOrReplaceExplanationUri(context, null).Value = ExplanationUri;
BaseDataVariableState frame =
state.CreateOrReplaceFrame(context, null);
frame.Value = new VisionImageReferenceDataType
{
Uri = FormattableString.Invariant(
$"opcua-inline://test-cell/frames/{resultId}"),
Digest = ByteString.Empty,
DigestAlgorithm = string.Empty,
Format = VisionClipFormatEnum.Png,
PixelFormat = "Mono8",
Width = 640u,
Height = 480u,
SizeBytes = 0u,
Timestamp = timestamp
};
if (state.Detections != null)
{
state.Detections.Value = detections;
}
state.AddFrameId(context, NodeId.Null);
if (state.FrameId != null)
{
state.FrameId.Value = target.CameraFrameId;
}
state.NodeId = context.RequireNodeIdFactory().New(context, state);
context.AssignInstanceChildNodeIds(state, state.NodeId);
target.ResultsFolder.AddChild(state);
await target.NodeManager.AddPredefinedNodeAsync(state, cancellationToken).ConfigureAwait(false);
m_results[resultId] = state;
}
private const string ModelVersion = "test-groundtruth-1";
private const string ExplanationUri = "urn:opcfoundation:tests:vision:groundtruth";
private static readonly double[] s_identityOrientation = [0.0, 0.0, 0.0, 1.0];
private readonly TestBinWorld m_world;
private readonly ConcurrentDictionary m_results = new(StringComparer.Ordinal);
private TestInferenceTarget? m_target;
}
}