diff --git a/Directory.Packages.props b/Directory.Packages.props
index a79eb43ab4..bd5ca6dbc9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -66,6 +66,15 @@
+
+
@@ -135,17 +144,23 @@
+ view option, and by src/Opc.Ua.Vision.OpenUsd which renders the
+ Vision server's simulated camera views offscreen via Silk (D3D12/Vulkan).
+ Published on nuget.org. The runtime packages ship win-x64, linux-x64 and
+ osx-arm64; the RID-agnostic metapackages resolve the correct native asset
+ per RID, so no RID-conditional reference is required. All of these move
+ together: 0.11.0-alpha carries Storm child native ABI 8, and the managed
+ and RID-specific runtime packages have to match, so a partial bump fails
+ when the natives load rather than when the solution builds. -->
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 268765a3e6..a591c3f3cd 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,21 @@ Each sample has its own `README.md` with build and run instructions.
- [Intent Viewer Client](samples/Robotics/IntentViewerClient/README.md) — click a
target in an OpenUSD viewport and watch the arm execute the resulting intent;
also runs headless.
+- [Bin Picking Cell](samples/Robotics/BinPickingCell/README.md) — Robot Intent,
+ Vision, an eye-in-hand camera, and an in-address-space OpenUSD scene in one
+ reference cell.
+- [Bin Picking Client](samples/Robotics/BinPickingClient/README.md) — closes the
+ Vision-to-Robot-Intent loop, with optional MCP hosting and an OpenUSD viewport.
+- [Visual Inspection Cell](samples/Vision/VisualInspectionCell/README.md) — hosts
+ Vision, AI Model Management, ISA-95 Job Control V2, and an operator dialog for
+ deterministic machined-bracket inspection.
+- [Visual Inspection Agent](samples/Vision/VisualInspectionAgent/README.md) — drives
+ the inspection loop with typed clients, routes inference through deployment
+ `Invoke`, applies recipe verdicts, schedules allowlisted jobs, and records
+ operator ground truth.
+- [AI Model Management sample](samples/AI/README.md) — `ModelManagementServer`
+ publishes the draft AI Model Management catalogue and routes inference;
+ `ModelManagementClient` discovers deployments and exercises the Methods.
- [Minimal ISA-95 Server](samples/Isa95/MinimalIsa95Server/README.md) —
minimal server hosting the OPC-10030 ISA-95 Common Model together
with OPC-10031-4 Job Control V1 and V2, using the typed common-model
@@ -188,4 +203,3 @@ vulnerabilities via the process documented in
companion repository with more sample applications.
- [Preview NuGet feed](https://opcfoundation.visualstudio.com/opcua-netstandard/_packaging?_a=feed&feed=opcua-preview%40Local) —
prerelease builds from Azure DevOps.
-
diff --git a/UA.slnx b/UA.slnx
index 042e99b806..700487d9bb 100644
--- a/UA.slnx
+++ b/UA.slnx
@@ -8,8 +8,16 @@
+
+
+
+
+
+
+
+
@@ -103,6 +111,10 @@
+
+
+
+
@@ -122,6 +134,10 @@
+
+
+
+
@@ -272,6 +288,9 @@
+
+
+
@@ -300,6 +319,7 @@
+
@@ -328,6 +348,7 @@
+
diff --git a/docs/AiIntegration.md b/docs/AiIntegration.md
new file mode 100644
index 0000000000..d63e67d779
--- /dev/null
+++ b/docs/AiIntegration.md
@@ -0,0 +1,258 @@
+# AI Model Management developer guide
+
+This guide documents the `Opc.Ua.AI`, `Opc.Ua.AI.Server`,
+`Opc.Ua.AI.Client` and `Opc.Ua.AI.Inference` package family — the .NET
+implementation of the draft *OPC UA — AI Model Management and Inference*
+companion specification.
+
+> **Draft.** The namespace `http://opcfoundation.org/UA/AI/` and every NodeId
+> in it are provisional. The model is neither official nor endorsed by the OPC
+> Foundation until the working group publishes it.
+
+AI Model Management publishes model sources, models, datasets, deployments and
+inference methods through OPC UA. The control plane is OPC UA: clients discover
+what is available, read the provenance and trust-boundary metadata, call
+`Invoke` or `InvokeAsync`, and transfer large artefacts through the standard
+file-transfer types.
+
+## Packages
+
+| Package | What it gives you | Depends on |
+|---|---|---|
+| `OPCFoundation.NetStandard.Opc.Ua.AI` | Source-generated AI model — ObjectTypes, ReferenceTypes, DataTypes, enums, node states and model loader | `Opc.Ua.Core` |
+| `OPCFoundation.NetStandard.Opc.Ua.AI.Server` | `AINodeManager`, `AIOptions`, fallback reporting, transfer and job support, and `AddAI` hosting extensions | `Opc.Ua.AI`, `Opc.Ua.Server`, `Opc.Ua.AI.Inference` |
+| `OPCFoundation.NetStandard.Opc.Ua.AI.Client` | `AIClient`, `AIClientFactory` and `AddAIClient()` DI registration | `Opc.Ua.AI`, `Opc.Ua.Client` |
+| `OPCFoundation.NetStandard.Opc.Ua.AI.Inference` | `IInferenceBackend`, `ChatClientInferenceBackend`, `RestChatCompletionsBackend`, credential resolvers and backend options | `Opc.Ua.AI`, `Microsoft.Extensions.AI` |
+
+The AI libraries target modern .NET TFMs used by the sample (`net8.0`,
+`net9.0` and `net10.0`). The inference assembly intentionally has no Azure,
+OpenAI or other vendor SDK dependency.
+
+## Model
+
+The Server publishes one AI root below the Server object. Under it are model
+sources, deployments, models, datasets and jobs. A deployment describes where
+inference runs, whether egress is permitted, whether input may be retained, the
+maximum inline payload size, and the model source it uses.
+
+Two properties are especially important:
+
+- `ModelUsed` is returned with an inference result so a fallback cannot answer
+ silently. A caller can distinguish "the primary model answered" from "a
+ degraded fallback answered".
+- `CredentialReference` is a name only. The credential value is resolved inside
+ the Server process by an `ICredentialResolver` and is never placed in the
+ address space.
+
+Large payloads use the standard Part 5 `FileType` transfer flow. Asynchronous
+inference jobs use the Part 10 program lifecycle so clients can monitor state
+instead of polling a private API.
+
+## Minimal hosted server
+
+`AddAI` registers the node manager, options and default backend composition.
+The host supplies the `IChatClient` through `IChatClientFactory`; that keeps
+vendor packages in the host and out of `Opc.Ua.AI.Inference`.
+
+```csharp
+using Microsoft.Extensions.Hosting;
+using Opc.Ua.AI.Inference;
+using Opc.Ua.AI.Server;
+using Opc.Ua.AI.Server.Hosting;
+using Opc.Ua.Server.Fluent;
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+
+builder.Services.AddRestChatCompletionsAIChatClientFactory();
+
+builder.Services
+ .AddOpcUa()
+ .AddServer(options =>
+ {
+ options.ApplicationName = "AIServer";
+ options.ApplicationUri = "urn:localhost:OPCFoundation:AIServer";
+ options.AutoAcceptUntrustedCertificates = true;
+ options.EndpointUrls.Add("opc.tcp://localhost:62640/AIServer");
+ })
+ .AddAI(
+ ai => builder.Configuration.GetSection(AIOptions.SectionName).Bind(ai),
+ backend => builder.Configuration
+ .GetSection(InferenceBackendOptions.SectionName)
+ .Bind(backend),
+ fallback => builder.Configuration
+ .GetSection(InferenceBackendOptions.FallbackSectionName)
+ .Bind(fallback));
+
+using IHost app = builder.Build();
+await app.RunAsync().ConfigureAwait(false);
+```
+
+`AddRestChatCompletionsAIChatClientFactory()` is the sample-friendly factory:
+it creates an `IChatClient` over the configured OpenAI-compatible endpoint
+without adding a vendor SDK. A production host can instead register its own
+`IChatClientFactory` that creates `IChatClient` instances from Azure, OpenAI,
+Ollama or an on-device runtime package.
+
+The direct construction path remains available for hosts that do not use the
+generic hosting stack:
+
+```csharp
+var backends = new InferenceBackends(primaryBackend, fallbackBackend);
+var factory = new AINodeManagerFactory(
+ backends,
+ Options.Create(new AIOptions()),
+ Options.Create(new InferenceBackendOptions()));
+```
+
+## Hosting API
+
+The extension method on `IOpcUaServerBuilder` is:
+
+| Method | Purpose |
+|---|---|
+| `AddAI(Action?, Action?, Action?)` | Registers `AINodeManagerFactory`, `AIOptions`, primary and fallback `InferenceBackendOptions`, an `InferenceBackends` singleton, and the OPC UA node-manager registration |
+
+`AddAI` composes the backend from `InferenceBackendOptions.Kind`:
+
+- `ChatClient` (default) creates `ChatClientInferenceBackend` from
+ `IChatClientFactory`.
+- `RestChatCompletions` creates `RestChatCompletionsBackend` directly for
+ endpoints whose wire contract is the OpenAI-compatible REST shape.
+
+### `AIOptions`
+
+| Property | Purpose |
+|---|---|
+| `PrimaryDeploymentId` / `FallbackDeploymentId` | Deployment identifiers published in the address space |
+| `EnableFallback` | Publishes the fallback deployment and `FallsBackTo` reference |
+| `EnableCatalogue` | Publishes catalogue and import-job nodes |
+| `EnableLearningLoop` | Publishes a `LearningJobType` node for ground-truth sample accounting |
+| `TransferExpiry`, `MaxTransferSize`, `MaxConcurrentTransfers`, `TransferInferenceTimeout` | Bounds for chunked transfers |
+| `AsyncInferenceDelay`, `MaxRetainedJobs` | Bounds and timing for asynchronous inference jobs |
+| `SourceId` | Identifier of the model source |
+
+When `EnableLearningLoop` is true, `AINodeManager` publishes one
+`LearningJobType` under `LearningJobs`. Host-level coordinators report
+ground-truth corrections through `RecordLearningSampleAsync(sampleId,
+sampleKind)`. The stable `sampleId` makes retries idempotent, and
+`AILearningSampleKind.Negative` counts empty or retracted observations exactly
+as positive examples count.
+
+### `InferenceBackendOptions`
+
+| Property | Purpose |
+|---|---|
+| `Enabled` | Enables the backend; most useful for disabling fallback |
+| `Kind` | `ChatClient` or `RestChatCompletions` |
+| `EndpointUri`, `ChatCompletionsPath`, `ProbePath` | Endpoint and paths for REST-shaped clients |
+| `Authentication`, `CredentialReference`, `ApiKeyHeader`, `CredentialDirectory`, `TokenAudience` | Server-to-backend authentication |
+| `Site`, `DataJurisdiction`, `EgressPermitted`, `RetainsInput` | Trust-boundary metadata published to clients |
+| `MaxInlinePayloadSize` | Inline `Invoke` payload limit |
+| `Models` | Configured model catalogue entries |
+
+## Client surface
+
+`AddAIClient()` mirrors the other companion-family client extensions. It
+registers an `AIClientFactory` and a
+`Func>` over the managed session.
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Opc.Ua;
+using Opc.Ua.AI.Client;
+using Opc.Ua.Client;
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+
+builder.Services
+ .AddOpcUa()
+ .AddClient(options =>
+ {
+ options.ApplicationName = "AIClient";
+ options.ApplicationUri = "urn:localhost:OPCFoundation:AIClient";
+ options.AutoAcceptUntrustedCertificates = true;
+ })
+ .AddDiscoveryAndConnect(options =>
+ {
+ options.DiscoveryUrl = "opc.tcp://localhost:62640/ModelManagementServer";
+ options.SecurityMode = MessageSecurityMode.SignAndEncrypt;
+ options.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
+ })
+ .AddAIClient();
+
+using IHost app = builder.Build();
+await app.StartAsync().ConfigureAwait(false);
+
+var createClient = app.Services
+ .GetRequiredService>>();
+AIClient? client = await createClient(CancellationToken.None)
+ .ConfigureAwait(false);
+
+if (client is null)
+{
+ Console.WriteLine("The Server does not implement AI Model Management.");
+}
+```
+
+For one-off code, `AIClient.TryCreate(session)` is the direct fallback
+when you already own an `ISession`.
+
+## Inference backends
+
+`IInferenceBackend` is the server-side contract: list models, invoke a model
+and probe reachability. Two implementations ship:
+
+- `ChatClientInferenceBackend` wraps `Microsoft.Extensions.AI.IChatClient`.
+ This is the default because it lets the host choose any SDK or local runtime
+ that implements the abstraction without changing the OPC UA address space.
+- `RestChatCompletionsBackend` speaks the OpenAI-compatible REST
+ chat-completions contract directly. Use it when that REST shape is the actual
+ wire contract and no `IChatClient` is available.
+
+Both hosted and on-device deployments use the same OPC UA nodes. The difference
+is configuration: endpoint, credentials, data jurisdiction and egress.
+
+## Example
+
+The sample in
+[`samples/AI/ModelManagementServer`](../samples/AI/ModelManagementServer) hosts
+the node manager with `AddAI`. By default it uses the `ChatClient` path and the
+sample composition root supplies a small `IChatClient` over the
+OpenAI-compatible endpoint. `verify_backend.py` is a throwaway endpoint that
+speaks enough of that contract for local validation:
+
+```powershell
+python samples/AI/verify_backend.py 5273
+dotnet run --project samples/AI/ModelManagementServer
+dotnet run --project samples/AI/ModelManagementClient
+```
+
+Set `InferenceBackend__Kind=RestChatCompletions` when testing an endpoint that
+must be reached through the REST backend directly. Configure
+`FallbackInferenceBackend__Kind` independently when the fallback uses a
+different wire contract from the primary.
+
+## Limitations
+
+- The companion specification is a draft, so namespace URIs and NodeIds can
+ change.
+- The sample publishes a real `LearningJobType` instance and a real
+ `SamplesCollected` counter. Host-level code can call the server-side
+ accounting API when ground-truth corrections arrive, including empty or
+ retracted observations. Retraining, candidate generation and promotion are
+ deliberately not simulated.
+- `IChatClient` has no standard model-enumeration method, so hosts that need a
+ catalogue should configure `InferenceBackendOptions.Models`.
+- The libraries do not reference vendor SDKs. If a provider package is needed,
+ add it in the hosting application and expose it through `IChatClientFactory`.
+- Native AOT is disabled for the AI inference/sample projects because the
+ `Microsoft.Extensions.AI` ecosystem uses reflection in areas this repository
+ builds with warnings as errors.
+
+## See also
+
+- [AI sample README](../samples/AI/README.md)
+- [Developer guide](DeveloperGuide.md)
+- [Vision developer guide](Vision.md)
+- [Robotics developer guide](Robotics.md)
diff --git a/docs/McpServer.md b/docs/McpServer.md
index d2c556d667..56afe1cfaa 100644
--- a/docs/McpServer.md
+++ b/docs/McpServer.md
@@ -4,7 +4,7 @@ The OPC UA MCP Server exposes all OPC UA Part 4 service calls as [Model Context
## What It Does
-The MCP server wraps the OPC UA .NET Standard client library, translating between JSON-based MCP tool calls and OPC UA binary protocol operations. The server exposes tools through a [tool profile](#tool-profiles) — a named, bounded catalog selected at startup — rather than a single fixed tool count. The default `full` profile currently registers every tool below; running a narrower profile (`core`, `services`, `administration`, `pubsub`, or `diagnostics`) exposes only the subset relevant to that workflow. The tables below list the complete tool surface, organized by OPC UA Part 4 service set:
+The MCP server wraps the OPC UA .NET Standard client library, translating between JSON-based MCP tool calls and OPC UA binary protocol operations. The server exposes tools through a [tool profile](#tool-profiles) — a named, bounded catalog selected at startup — rather than a single fixed tool count. The default `full` profile currently registers every tool below; running a narrower profile (`core`, `services`, `administration`, `pubsub`, `diagnostics`, `robotics`, or `vision`) exposes only the subset relevant to that workflow. The tables below list the complete tool surface, organized by OPC UA Part 4 service set:
| Service Set | Tools | Description |
|---|---|---|
@@ -57,7 +57,8 @@ The server selects its tool catalog through a **tool profile** — a bounded set
| `administration` | Configuration, Connection, NodeSet Export, PKI Management | Certificate trust management and NodeSet export |
| `pubsub` | PubSub runtime, discovery, action, and capture tools (plus PubSub decode when diagnostics tools are enabled) | Part 14 PubSub publish/subscribe, discovery, and capture workflows |
| `diagnostics` | Connection, Packet Capture (plus decode/replay when diagnostics tools are enabled) | OPC UA-aware packet capture, offline decode, and replay |
-| `robotics` | Connection plus the Robot Intent discovery, monitoring, control and mission tools | Commanding and monitoring a Robot Intent controller |
+| `robotics` | Connection plus Robot Intent discovery, paged monitoring, control, mission and Vision-guided Pick tools | Commanding and monitoring a Robot Intent controller |
+| `vision` | Connection plus the Vision discovery, monitoring, seeing, inference, feedback and geometry tools | Perception-driven agents that need to see through a Vision server, compose poses across the §5.12 frame graph, run or submit inference, and (when composed with `robotics`) act on what they see — see the [Vision developer guide](Vision.md) |
| `full` (default) | Every tool class above | Unrestricted access; the current-major default so existing integrations keep working unchanged |
`full` is the default for the current major version — `core` and the other bounded profiles are opt-in. Select a profile with:
@@ -66,8 +67,40 @@ The server selects its tool catalog through a **tool profile** — a bounded set
- The `McpServer:ToolProfile` configuration value
- The `OPCUA_MCP_TOOL_PROFILE` environment variable
+**Profiles compose.** A `--profile` value can name more than one bounded profile at a time — the `BinPickingClient` sample runs `--profile vision,robotics` and exposes both catalogs from the same MCP host, deduplicating the shared `Connection` tools. The composed set uses the `WithOpcUaCoreTools(McpToolProfileSet)` / `WithOpcUaVisionTools(McpToolProfileSet)` / `WithOpcUaRoboticsTools(McpToolProfileSet)` overloads, and the core-tools overload owns the single `ConnectionTools` registration across every package that references the same MCP server. See the [Vision developer guide](Vision.md#mcp-tools) for the composed 64-tool example and the [BinPickingClient sample](../samples/Robotics/BinPickingClient) for the running catalog.
+
Because the exact number of tools in each profile (and in `full`) changes as tools are added or removed, this document intentionally does not hard-code a tool count. Use the tables above (or `tools/list`) to enumerate the tools actually exposed by a running server.
+### Vision-guided Robotics
+
+`robotics_vision_pick` closes the common perception-to-action path without
+making an agent copy a result NodeId and several resource NodeIds between tool
+calls. It runs one detection inference through `Opc.Ua.Vision.Client`, on the
+same named OPC UA session as the Robot Intent controller, then submits either
+one Pick or a two-step Pick/Place mission:
+
+```json
+{
+ "request": {
+ "controller": "BinPickingController",
+ "pipeline": "BinPickingPipeline",
+ "source": "Bin",
+ "tool": "ParallelGripper",
+ "destination": "Fixture",
+ "classLabel": "RedCube",
+ "minimumConfidence": 0.9,
+ "missionId": "place-red-cube"
+ }
+}
+```
+
+Detection selection is deterministic: exact DetectionId/ClassLabel filters and
+the confidence threshold are applied first, then highest confidence wins with
+ordinal DetectionId and original result order as tie-breakers. The result
+contains Vision result/pipeline/sensor/model/frame provenance, the selected
+pose, and the authoritative intent or mission handle. The helper does not
+request authority, wait, retry, cancel, or reinterpret a server refusal.
+
## Resources
The MCP server exposes connected sessions as **MCP resources**, enabling the LLM to discover, inspect, and subscribe to session state.
@@ -411,7 +444,7 @@ runtime and collect publisher responses):
## Architecture
-The MCP tools ship as five libraries plus the executable that composes them.
+The MCP tools ship as six libraries plus the executable that composes them.
The executable owns only transport, logging and CLI plumbing; every tool lives
in a library that an application can reference on its own.
@@ -445,12 +478,16 @@ tools/
│ └── Tools/{PacketCapture,PacketDecode,PacketReplay}Tools.cs
├── Opc.Ua.Mcp.PubSub.Diagnostics/ # PubSub capture and decode
│ └── Tools/{PubSubCapture,PubSubDecode}Tools.cs
-├── Opc.Ua.Mcp.Robotics/ # Robot Intent discovery, monitoring, control, missions
+├── Opc.Ua.Mcp.Robotics/ # Robot Intent plus same-session Vision-guided picking
│ ├── RoboticsIntentManager.cs
-│ └── Tools/Robotics{Discovery,Monitoring,Control,Mission}Tools.cs
+│ ├── VisionGuidedRoboticsManager.cs
+│ └── Tools/Robotics{Discovery,Monitoring,Control,Mission,Vision}Tools.cs
+├── Opc.Ua.Mcp.Vision/ # Vision discovery, monitoring, seeing, inference, feedback, geometry
+│ ├── VisionClientAccessor.cs
+│ └── Tools/Vision{Discovery,Monitoring,Seeing,Inference,Feedback,Geometry}Tools.cs
└── Opc.Ua.Mcp/ # .NET 10 project, packaged as dotnet tool
├── Program.cs # Entry point, stdio + Streamable HTTP transport (/mcp)
- ├── McpHostBuilder.cs # Composes the five libraries
+ ├── McpHostBuilder.cs # Composes the six libraries
├── Opc.Ua.Mcp.Config.xml # OPC UA client application config
└── .mcp/server.json # MCP server manifest for NuGet discovery
```
@@ -463,7 +500,8 @@ tools/
| `OPCFoundation.NetStandard.Opc.Ua.Mcp.PubSub` | PubSub runtime, actions, discovery | Core + `Opc.Ua.PubSub` |
| `OPCFoundation.NetStandard.Opc.Ua.Mcp.Diagnostics` | UA-TCP capture, decode, replay | Core + `Opc.Ua.Core.Diagnostics` |
| `OPCFoundation.NetStandard.Opc.Ua.Mcp.PubSub.Diagnostics` | PubSub capture, decode | Core + `Opc.Ua.PubSub.Diagnostics` |
-| `OPCFoundation.NetStandard.Opc.Ua.Mcp.Robotics` | Robot Intent discovery, monitoring, control, missions | Core + `Opc.Ua.Robotics.Client` |
+| `OPCFoundation.NetStandard.Opc.Ua.Mcp.Robotics` | Robot Intent discovery, typed control/missions, paged monitoring, Vision-guided Pick | Core + `Opc.Ua.Robotics.Client` + `Opc.Ua.Vision.Client` |
+| `OPCFoundation.NetStandard.Opc.Ua.Mcp.Vision` | Vision discovery, monitoring, seeing (`vision_get_frame` returns an MCP `ImageContentBlock`), inference, off-server feedback, §5.12 pose composition | Core + `Opc.Ua.Vision.Client` |
| `OPCFoundation.NetStandard.Opc.Ua.Mcp` | the ready-to-run `opcua-mcp` tool | all of the above |
The libraries multi-target `net8.0;net9.0;net10.0`; the executable targets
@@ -495,11 +533,13 @@ builder.Services.AddOpcUaMcpPubSub();
builder.Services.AddOpcUaMcpDiagnostics();
builder.Services.AddOpcUaMcpPubSubDiagnostics();
builder.Services.AddOpcUaMcpRobotics();
+builder.Services.AddOpcUaMcpVision();
mcp.WithOpcUaPubSubTools(profile)
.WithOpcUaDiagnosticsTools(profile, diagnosticsEnabled)
.WithOpcUaPubSubDiagnosticsTools(profile, diagnosticsEnabled)
- .WithOpcUaRoboticsTools(profile);
+ .WithOpcUaRoboticsTools(profile)
+ .WithOpcUaVisionTools(profile);
```
`WithOpcUaMcpFilters` registers the request and schema filters that make tool
@@ -512,6 +552,17 @@ nothing rather than failing, so the same profile value can be passed to every
package a host references — `Full` in a host that never referenced
`Opc.Ua.Mcp.Diagnostics` simply yields no capture tools.
+Bounded profiles that own their own connection tools — `Vision` in the
+example above — carry `ConnectionTools` themselves in the single-profile
+overload of `WithOpcUa...Tools`. When two or more bounded profiles are
+composed through the `McpToolProfileSet` overloads (for example the
+[`vision,robotics` composition](Vision.md#mcp-tools) the BinPickingClient
+sample runs), the corresponding `WithOpcUaCoreTools(McpToolProfileSet)`
+overload owns and deduplicates `ConnectionTools` across every package.
+Each Vision or Robotics package's `McpToolProfileSet` overload never
+registers `ConnectionTools` directly, so the composed catalog contains
+one Connection surface, not several.
+
The capture tool classes stay `internal` and are reachable only through their
registration extensions.
diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md
index 5aa5d80fe3..8a259303fb 100644
--- a/docs/MigrationGuide.md
+++ b/docs/MigrationGuide.md
@@ -43,6 +43,125 @@ Looking for the broader narrative (non-prescriptive overview of what
changed in a release)? See
[What's New in 2.0](WhatsNewIn2.0.md).
+## Migrating Robotics and Vision MCP requests
+
+The Robotics and Vision MCP tool names remain stable, but their request schemas
+are now strongly typed. Robotics tools no longer accept JSON encoded inside a
+string, and controller-scoped values can use an exact, unambiguous published
+name instead of copying every NodeId.
+
+For example, the old Pick request nested one JSON document inside another:
+
+```json
+{
+ "controllerId": "ns=3;s=7001_Controllers_BinPickingController",
+ "intentJson": "{\"intentId\":\"pick-red\",\"source\":\"ns=3;s=Bin\",\"tool\":\"ns=3;s=Gripper\",\"objectClass\":\"RedCube\"}"
+}
+```
+
+Pass the typed object directly now:
+
+```json
+{
+ "controller": "BinPickingController",
+ "input": {
+ "intentId": "pick-red",
+ "source": "Bin",
+ "tool": "ParallelGripper",
+ "objectClass": "RedCube"
+ }
+}
+```
+
+The same change applies to every `robotics_submit_*` tool. Motion poses,
+trajectory points, process attributes and program arguments are nested typed
+objects or arrays. Values that become OPC UA Variants use an explicit
+`dataType` plus `value`; they are never inferred through an `object`-typed API.
+
+Mission steps and transitions are arrays rather than stringified arrays. The
+intent `kind` is a closed discriminator and exactly one matching payload is
+required:
+
+```json
+{
+ "controller": "BinPickingController",
+ "missionId": "move-red",
+ "missionUpdateId": 1,
+ "steps": [
+ {
+ "stepId": "pick",
+ "released": true,
+ "intent": {
+ "kind": "Pick",
+ "pick": {
+ "source": "Bin",
+ "tool": "ParallelGripper",
+ "objectClass": "RedCube"
+ }
+ }
+ },
+ {
+ "stepId": "place",
+ "released": true,
+ "intent": {
+ "kind": "Place",
+ "place": {
+ "destination": "Fixture",
+ "tool": "ParallelGripper"
+ }
+ }
+ }
+ ],
+ "transitions": []
+}
+```
+
+`robotics_list_operations` and `robotics_list_missions` now return bounded
+pages. Their optional `query` selects active or terminal work, filters by
+identifier/state, chooses `Summary` or `Full`, and carries an opaque
+continuation cursor. Use `robotics_wait_mission` with the MissionId and mission
+operation NodeId returned by `robotics_submit_mission`; timeout returns the
+current snapshot with `completed=false`, just like
+`robotics_wait_operation`.
+
+One-shot Vision inference also uses one structured request. Replace the old
+`pipelineNodeId` scalar:
+
+```json
+{
+ "pipelineNodeId": "ns=3;s=Vision/Pipelines/BinPickingPipeline"
+}
+```
+
+with:
+
+```json
+{
+ "request": {
+ "pipeline": "BinPickingPipeline",
+ "expectedKind": "Detection",
+ "detail": "Summary",
+ "maxItems": 20
+ }
+}
+```
+
+The result still includes the ResultId and result NodeId, and now also includes
+authoritative result kind/provenance plus a bounded detection, inspection or
+segmentation summary. The `vision_read_*_result` tools remain available when a
+caller needs the complete result.
+
+Callers that previously chained inference, detection selection, Pick and Place
+can instead use `robotics_vision_pick`. Its one structured request names the
+controller, pipeline, source, tool and optional destination plus detection
+filters. The result carries the selected detection provenance and either the
+intent operation or mission operation needed by the corresponding bounded wait
+tool. Command authority is still requested separately.
+
+Name matching is exact and ordinal after trimming. A missing or ambiguous name
+is an error that lists the matching candidates and NodeIds; the MCP layer never
+chooses the first candidate or requests command authority as a side effect.
+
## Migrating code that used the exposed diagnostics locks
`IServerInternal`, `ISession` and `ISubscription` no longer expose their
diff --git a/docs/OpenUsd.md b/docs/OpenUsd.md
index 29ba2646cd..658769e465 100644
--- a/docs/OpenUsd.md
+++ b/docs/OpenUsd.md
@@ -32,6 +32,37 @@ requires the other.
| `OPCFoundation.NetStandard.Opc.Ua.OpenUsdScene` | Part 2: the source-generated companion model, the scene document model, the `.usda` reader/writer, and the value-type map. |
| `OPCFoundation.NetStandard.Opc.Ua.OpenUsdScene.Server` | Part 2: materializer, exporter, discovery, and Part 1 interop. |
+The runtime path is intentionally narrow: the connector is an OPC UA client that
+discovers binding descriptors, subscribes to source Variables, converts values to
+USD-shaped `Variant`s, and writes them into one or more sinks. The optional viewer
+is just another sink, so the on-disk override layer and the picture receive the
+same updates.
+
+```mermaid
+flowchart LR
+ Server["OPC UA Server
OpenUSD representations + bindings"]
+ Registry["Server/OpenUSD/Representations"]
+ Sources["Bound source Variables
telemetry + alarms"]
+ History["Historized source Variables"]
+ Connector["OpenUsdConnector
discover + subscribe + compose"]
+ Descriptor["RepresentationInfo + BindingInfo
prim path + property + conversion"]
+ Sink["IUsdSink"]
+ File["UsdFileSink
live.usda override layer"]
+ Viewer["Viewport sink
optional --view"]
+ Stage["USD stage assets
fetched and digest-verified"]
+
+ Server --> Registry --> Connector
+ Sources -->|"MonitoredItems"| Connector
+ History -->|"ReplayHistoryAsync"| Connector
+ Connector -->|"discovers"| Descriptor
+ Descriptor -.->|"selects prim + conversion"| Connector
+ Connector -->|"converted Variant"| Sink
+ Connector -->|"FetchServedAssetsAsync"| Stage
+ Sink --> File
+ Sink --> Viewer
+ Stage --> Viewer
+```
+
## The connector
`OpenUsdConnector` is a **client**: it discovers a server's `OpenUsdRepresentation` instances through the Part 1
@@ -240,7 +271,7 @@ and the callback fires when that target changes.
> The viewport requires .NET 10 and the OpenUSD packages (`OpenUsd`, `OpenUsd.Viewer`, `OpenUsd.Runtime.Imaging`),
> which are published on nuget.org, so a plain restore is enough. The RID-agnostic runtime metapackages resolve the
-> correct native payload per RID; `win-x64`, `linux-x64` and `osx-arm64` are all supported. With `0.7.0-alpha`, a
+> correct native payload per RID; `win-x64`, `linux-x64` and `osx-arm64` are all supported. With `0.8.0-alpha`, a
> RID-less build or publish on a supported host copies that host's OpenUSD native payload. Use an explicit RID when
> publishing for another platform. Publish the connector and the viewport into the *same* directory, substituting your
> own RID:
diff --git a/docs/README.md b/docs/README.md
index 2ee8711987..ed6965dec5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -40,6 +40,8 @@ Here is a list of available documentation for different topics:
* [Device Integration (DI) developer guide](DeviceIntegration.md) - End-to-end documentation for the `Opc.Ua.Di*` library trio: fluent `IDeviceBuilder`, device sub-type extensions (`AddSoftware`, `AddBlock`, `AddConfigurableObject`, `AddLifetimeIndication`, `WithSupportInfo`), hosting integration (`AddOpcUaDi` / `ConfigureDevicesFor`), lock service, software-update package store, and client helpers (`DiLockClient`, `DiTopologyClient`, `SoftwareUpdateClient`). Includes a section enumerating supported OPC 10000-100 features against the spec.
* [OpenUSD](OpenUsd.md) — bridge an OPC UA address space to an OpenUSD stage, in two parts. **Part 1 — bindings**: the generic domain-agnostic `OpenUsdConnector` (discovers `Server/OpenUSD/Representations`, subscribes, composes, verifies stage/asset digests, replays history), the `Variant`-based `IUsdSink` with `UsdFileSink` / `MockUsdSink`, the fluent/DI `AddOpenUsdConnector` extensions + `OpenUsdConnectorFactory` / `OpenUsdConnectorOptions`, server-side `UsdAssetDelivery`, and the optional `--view` viewport. **Part 2 — scene materialization**: materializes a composed USD stage *inside* the address space so the prim tree is the node hierarchy — the `Opc.Ua.OpenUsdScene` companion model plus scene document model, `.usda` reader/writer and the §6.2 `UsdValueTypeMap` (USD roles as DataTypes subtyping their built-in), and `Opc.Ua.OpenUsdScene.Server` with `MaterializeUsdStage` / `ExportUsdStage`, unknown-type fallbacks, Mode-A live attributes, portable Cesium georeference dual-authoring, discovery and Part 1 binding-target resolution. (Both companion models are currently drafts.)
* [Robotics developer guide](Robotics.md) — the `Opc.Ua.Robotics` / `Opc.Ua.Robotics.Server` / `Opc.Ua.Robotics.Client` trio for OPC 40010 Robotics 1.02 over OPC 40001-1 IA and OPC 10000-100 DI: source-generated models, `AddRobotics` / `AddRoboticsModel` / `ConfigureRobotics(For)` hosting, `IRoboticsModelProvider` / `IRoboticsConfigurator` / `IRoboticsBuildContext`, validated fluent topology builders (`AddMotionDeviceSystemAsync` down to axes, power trains, motors, gears, drives, safety states, and task controls), semantic references, `ArrayOf` snapshot contracts, and `RoboticsClient` discovery. It also covers [Robot Intent](Robotics.md#robot-intent), the draft task-level motion verbs OPC 40010 leaves undefined, including the MCP surface for LLM agents.
+* [Vision developer guide](Vision.md) — the `Opc.Ua.Vision` / `Opc.Ua.Vision.Server` / `Opc.Ua.Vision.Client` / `Opc.Ua.Vision.OpenUsd` package family for the draft *OPC UA — Vision* companion specification: source-generated Vision model, `AddVision` / `ConfigureVision` hosting with `IVisionMediaProvider` / `IVisionInferenceProvider` / `IVisionFeedbackSink`, the two perception paths behind one contract (`OnServer` deterministic detector vs `EdgeOffServer` agent submissions), fluent topology builders for frames, sensors, calibrations, media endpoints and inference pipelines, `VisionClient` discovery + `VisionFrameGraph` §5.12 pose composition + `VisionResultReader` streaming detections + `VisionFeedbackClient` for off-server VLM agents, the §6.4 media-gating states, the `NoRenderingBackend` degrade path, facet derivation, and the composed [`vision` MCP profile](McpServer.md) with the [BinPickingCell / BinPickingClient](../samples/Robotics/BinPickingCell) example.
+* [AI Model Management developer guide](AiIntegration.md) — the `Opc.Ua.AI` / `Opc.Ua.AI.Inference` / `Opc.Ua.AI.Server` / `Opc.Ua.AI.Client` package family for the draft *OPC UA — AI Model Management and Inference* companion specification over xRegistry: source-generated catalogues, datasets, deployments, inference endpoints and learning jobs, the `IInferenceBackend` contract with `Microsoft.Extensions.AI` `IChatClient` and OpenAI-compatible REST backends, `AINodeManagerFactory` hosting via `AddNodeManager`, `Invoke` routing, standard file-transfer artefact streaming, credential resolvers that keep secret material out of the address space, and the [ModelManagementServer / ModelManagementClient](../samples/AI/README.md) example.
* [Relative Spatial Location and Global Positioning](Positioning.md) — source-generated OPC 10000-210 RSL and OPC 10000-211 GPOS models, standalone/composed server hosting, provider contracts, high-level clients, frame-chain resolution, WGS84/ENU conversion, and ground-control-point fitting.
* [Generators (generating sets)](../samples/OpenUsd/GeneratorServer/Generators.md) — the draft Generators companion specification realised end to end by [GeneratorServer](../samples/OpenUsd/GeneratorServer): a datasheet-driven simulation in which load fraction is the only independent variable, DI + Machinery integration, and one independent OpenUSD twin per configured set. Includes [SiteCompositionServer](../samples/OpenUsd/SiteCompositionServer), a supervisory server that owns no devices and composes the pump and generator servers into a single scene through cross-server components.
* [ISA-95 developer guide](ISA95.md) - End-to-end documentation for the `Opc.Ua.ISA95*` library trio: the OPC-10030 Common Model and OPC-10031-4 Job Control V1/V2, the two transparently-documented normative NodeSet repairs, `AddIsa95Server`/`AddIsa95Client` hosting, the typed common-model builder, the shared Job Control state engine and its `Uncertain`/Annex-B `ReturnStatus` result model, the provider-backed `GeoSpatialLocationType` seam and planned Part 210/211 RSL/GPOS integration, and a conformance matrix distinguishing static NodeSet structure from runtime-tested behavior.
@@ -89,4 +91,3 @@ Starting with version 1.5.375.XX the Windows Forms reference client & reference
* [Role-Based Security](RoleBasedUserManagement.md) — Part 18 roles and claim-based identity-mapping rules.
* [Identity Providers](IdentityProviders.md) — server and client identity-provider architecture.
* [Dependency Injection](DependencyInjection.md) — dependency injection hosting and identity registration extensions.
-
diff --git a/docs/Robotics.md b/docs/Robotics.md
index ad04b4387c..42fe6eac28 100644
--- a/docs/Robotics.md
+++ b/docs/Robotics.md
@@ -11,9 +11,9 @@ dependency order.
> **Status: draft companion model.** The namespace `http://opcfoundation.org/UA/RobotIntent/` and every
> NodeId in it are **provisional**. This implements the working-group draft
-> [*OPC UA — Robot Intent*](https://github.com/marcschier/opcua-drafts/blob/main/metaverse-specs/robot-intent/OPC-UA-Robot-Intent.md);
-> nothing here is official or endorsed by the OPC Foundation. Do not deploy it on a production robot
-> and expect the identifiers to survive.
+> *OPC UA — Robot Intent*; nothing here is official or endorsed by the OPC
+> Foundation. Do not deploy it on a production robot and expect the identifiers
+> to survive.
OPC 40010 describes a robot in detail — its motion device system, its axes, its power trains, its
controller, its safety states — and defines **no motion verbs at all**. Its whole actuation surface is
@@ -40,6 +40,24 @@ Robot Intent without pulling in OPC 40010, OPC 10000-100 DI, or anything else.
| `OPCFoundation.NetStandard.Opc.Ua.Robotics.Server` | Stock Robotics node manager, Robot Intent node manager, model providers, hosting extensions (`AddRobotics`, `AddRobotIntent`, `ConfigureRobotics`, `ConfigureRobotIntent`), validated fluent topology builders, `IntentControllerHost`, safety binding, real-time channel declarations and facet calculation. |
| `OPCFoundation.NetStandard.Opc.Ua.Robotics.Client` | Continuation-safe, subtype-aware discovery of Robotics instances over the DI client, Robotics type classification, Robot Intent discovery, the awaitable operation handle, command authority, real-time-channel leases, missions and `RobotIntentBuilder`. |
+```mermaid
+graph TD
+ Di["Opc.Ua.Di
Device Integration base model"]
+ Model["Opc.Ua.Robotics
OPC 40010 + Robot Intent model"]
+ Server["Opc.Ua.Robotics.Server
node managers + builders + IntentControllerHost"]
+ Client["Opc.Ua.Robotics.Client
discovery + RobotIntentClient"]
+ Mcp["Opc.Ua.Mcp.Robotics
agent tools"]
+ Executor["IIntentExecutor"]
+ Safety["IRobotIntentSafetySource"]
+
+ Di --> Model
+ Model --> Server
+ Model --> Client
+ Client --> Mcp
+ Executor -.->|"executes admitted intents"| Server
+ Safety -.->|"guards admission"| Server
+```
+
Generated OPC 40010 model types stay in the specification namespaces `Opc.Ua.Robotics` and
`Opc.Ua.IA`; hand-written APIs compose the generated NodeStates, factories,
enums, and ObjectType clients instead of replacing or inheriting from them.
@@ -126,6 +144,35 @@ await host.Build().RunAsync();
`AddOpcUaDi()`; both register the shared `DiAddressSpaceOwnership` marker and the
second call throws with the name of the conflicting extension.
+The two address-space shapes are separate but composable. OPC 40010 Robotics
+instances live below the DI `DeviceSet`; Robot Intent lives below
+`Server/RobotIntent` and exposes the command surface a client or MCP agent uses.
+
+```mermaid
+flowchart TD
+ Objects["Objects"] --> DeviceSet["DeviceSet
OPC 10000-100 DI"]
+ DeviceSet --> Mds["MotionDeviceSystemType"]
+ Mds --> C40010["Controllers"]
+ Mds --> Motion["MotionDevices"]
+ Mds --> Safety40010["SafetyStates"]
+ Motion --> Axes40010["Axes"]
+ Motion --> Power["PowerTrains"]
+
+ ServerObj["Server"] --> Root["RobotIntent
RobotIntentRootType"]
+ Root --> IntentControllers["Controllers"]
+ IntentControllers --> Controller["IntentControllerType"]
+ Controller --> Frames["Frames"]
+ Controller --> Tools["Tools"]
+ Controller --> Locations["Locations"]
+ Controller --> Axes["Axes"]
+ Controller --> Intents["Intents
IntentOperationType instances"]
+ Controller --> Missions["Missions
MissionType instances"]
+ Controller --> Channels["RealTimeChannels"]
+ Controller --> Capabilities["Capabilities
SupportedIntents + facets"]
+ Frames -.->|"HasFrameParent tree"| Frames
+ Controller -.->|"optional HasIntentController"| Mds
+```
+
## Hosting API
| Method | Builder | Purpose |
@@ -420,7 +467,7 @@ model, and the configured instance namespace before returning. The manager's
must be thread-safe, must reserve unique NodeIds for unregistered nodes, and must
allocate Robotics instances in the configured instance namespace.
-[`MinimalRobotServer`](../samples/Robotics/MinimalRobotServer) is the worked example of
+[`MinimalRobotServer`](../samples/Robotics/MinimalRobotServer) is the example of
the custom-manager route: it composes Robotics, IA, DI, the draft OpenUSD
binding, and RSL/GPOS in one `DiNodeManager` subclass.
@@ -1361,12 +1408,53 @@ adds four tool groups:
* discovery: list controllers and read a controller's declared `SupportedIntents`, `SupportedFacets`
and lookup tables;
-* monitoring: read live state, list operations and missions, and wait for an operation with a bounded
- timeout;
+* monitoring: read live state, page/filter concise operation and mission summaries, and wait for an
+ operation or mission with a bounded timeout;
* direct control: request and release authority, cancel, pause, resume, retry, and submit one tool per
intent kind;
* missions: submit, update the horizon of, and cancel missions.
+Every controller argument is a selector: a NodeId remains valid, while an exact,
+unambiguous published name avoids copying NodeIds between calls. Tool, frame,
+location, output and program selectors inside an intent are resolved from the
+controller snapshot read for that call. Resolution is read-only and ambiguity
+is an error; it never requests command authority or submits exploratory work.
+
+Intent payloads are structured MCP objects rather than JSON text embedded in a
+string:
+
+```json
+{
+ "controller": "BinPickingController",
+ "input": {
+ "intentId": "pick-red",
+ "source": "Bin",
+ "tool": "ParallelGripper",
+ "objectClass": "RedCube"
+ }
+}
+```
+
+Mission `steps` and `transitions` are typed arrays. Each step carries a closed
+`kind` discriminator and exactly one matching payload. The server preserves a
+caller-supplied step IntentId, generates one only when omitted, and publishes
+the resulting `StepId -> IntentId -> operation NodeId -> state` correlation.
+`robotics_wait_mission` observes the mission operation NodeId returned by
+submission; it does not rediscover work through mutable active state.
+
+The list tools return bounded pages. Summary detail omits poses and full output
+payloads; request Full detail only for the page that needs it. A timeout from
+either wait tool is not a failure: it returns `completed=false` and a refreshed
+snapshot.
+
+When Vision and Robotics are composed, `robotics_vision_pick` runs one
+detection inference, applies exact class/detection filters and a confidence
+threshold, and deterministically chooses the highest-confidence candidate. It
+submits one Pick when no destination is supplied, or a two-step Pick/Place
+mission when a destination is supplied, then returns detection provenance and
+the authoritative submission handles. It never takes authority, retries,
+waits, cancels or converts a refusal into a success-shaped result.
+
The sample `IntentViewerClient --mcp` is one host for these tools. In headless mode it defaults to MCP
stdio. With `--view`, it automatically uses Streamable HTTP because MCP stdio carries protocol frames
on stdout and the in-process OpenUSD viewport shares that stream. An explicit `--transport stdio
@@ -1551,6 +1639,26 @@ it, because `Queued`, `Cancelling` and the three distinct terminal outcomes cann
| `Cancelled` | `Halted` | Terminal. Ended early because a cancel was accepted. |
| `Retriable` | `Halted` | Terminal for now; `Retry` may re-attempt it. |
+```mermaid
+stateDiagram-v2
+ [*] --> Accepted: SubmitIntent admitted
+ Accepted --> Queued: queued or buffered
+ Queued --> Executing: dispatch starts
+ Accepted --> Executing: no predecessor
+ Executing --> Succeeded: executor returns success
+ Executing --> Failed: executor fault or failure
+ Executing --> Retriable: executor returns retriable
+ Executing --> Cancelling: CancelIntent accepted
+ Queued --> Cancelled: CancelIntent accepted
+ Accepted --> Cancelled: CancelIntent on an admitted intent that is not current
+ Cancelling --> Cancelled: controlled stop completes
+ Cancelling --> Failed: executor fails while stopping
+ Retriable --> Accepted: Retry creates a new operation
+ Succeeded --> [*]
+ Failed --> [*]
+ Cancelled --> [*]
+```
+
`Cancelling` is **not** terminal. A client that treats acceptance of a cancel as the end of motion acts
too early.
diff --git a/docs/Vision.md b/docs/Vision.md
new file mode 100644
index 0000000000..4c900ed9f0
--- /dev/null
+++ b/docs/Vision.md
@@ -0,0 +1,1177 @@
+# Vision developer guide
+
+This guide documents the `Opc.Ua.Vision`, `Opc.Ua.Vision.Server`,
+`Opc.Ua.Vision.Client`, `Opc.Ua.Vision.OpenUsd` and `Opc.Ua.Mcp.Vision` package
+family — the .NET implementation of the working-group draft *OPC UA — Vision*
+companion specification, plus the OpenUSD offscreen capture adapter and the
+Model Context Protocol tool package that lets a language-model agent see through
+a Vision server and act on what it sees.
+
+> **Draft.** The namespace `http://opcfoundation.org/UA/Vision/` and every
+> NodeId in it are provisional. The model is a working-group draft and is
+> neither official nor endorsed by the OPC Foundation. The API is stable within
+> this repository but every ObjectType, DataType and BrowseName can still change
+> when the specification is published.
+
+Vision layers on top of the base OPC UA namespace only — it does not require
+Devices, Machinery or Robotics. It composes cleanly with Robotics, as shown in the
+[`samples/Robotics/BinPickingCell`](../samples/Robotics/BinPickingCell) and
+[`samples/Robotics/BinPickingClient`](../samples/Robotics/BinPickingClient)
+samples (`Vision` + `Robot Intent` in one server, `vision_*` +
+`robotics_*` MCP tools in one agent).
+
+## Packages
+
+| Package | What it gives you | Depends on |
+|---|---|---|
+| `OPCFoundation.NetStandard.Opc.Ua.Vision` | Source-generated Vision model — ObjectTypes, ReferenceTypes, DataTypes, enums, node states, typed client proxies, `AddOpcUaVision` model loader | `Opc.Ua.Core` |
+| `OPCFoundation.NetStandard.Opc.Ua.Vision.Server` | `VisionNodeManager`, `IVisionBuildContext`, fluent topology builders, `IVisionMediaProvider` / `IVisionInferenceProvider` / `IVisionFeedbackSink`, facet derivation, `AddVision` / `ConfigureVision` hosting extensions | `Opc.Ua.Vision`, `Opc.Ua.Server` |
+| `OPCFoundation.NetStandard.Opc.Ua.Vision.Client` | `VisionClient` discovery, `VisionSensorClient`, `VisionPipelineClient`, `VisionResultReader`, `VisionMediaClient`, `VisionFrameGraph`, `VisionFeedbackClient`, `session.Vision(...)` extension, `AddVisionClient()` DI | `Opc.Ua.Vision`, `Opc.Ua.Client` |
+| `OPCFoundation.NetStandard.Opc.Ua.Vision.OpenUsd` | `ISceneCameraCaptureProvider` implementation that renders a `UsdGeomCamera` offscreen and reports `NoRenderingBackend` gracefully when no graphics device is available | `Opc.Ua.Types`, native OpenUSD renderer payload (optional per-RID) |
+| `OPCFoundation.NetStandard.Opc.Ua.Mcp.Vision` | 22 MCP tools split across discovery, monitoring, seeing, inference, feedback and geometry, plus the `vision` bounded profile and composition entry point | `Opc.Ua.Mcp.Core`, `Opc.Ua.Vision.Client` |
+
+```mermaid
+graph TD
+ Model["Opc.Ua.Vision
source-generated model"]
+ Server["Opc.Ua.Vision.Server
VisionNodeManager + builders"]
+ Client["Opc.Ua.Vision.Client
typed discovery and readers"]
+ OpenUsd["Opc.Ua.Vision.OpenUsd
scene-camera capture provider"]
+ Mcp["Opc.Ua.Mcp.Vision
agent tools"]
+ Media["IVisionMediaProvider"]
+ Inference["IVisionInferenceProvider"]
+ Feedback["IVisionFeedbackSink"]
+
+ Model --> Server
+ Model --> Client
+ Client --> Mcp
+ OpenUsd -.->|captures frames for| Media
+ Media -.->|plugs into| Server
+ Inference -.->|plugs into| Server
+ Feedback -.->|plugs into| Server
+```
+
+`Opc.Ua.Vision.OpenUsd` deliberately sits outside that dependency chain: it
+depends on `Opc.Ua.Types` alone and knows nothing about the Vision server. It
+offers a camera, and a host writes the `IVisionMediaProvider` that hands the
+resulting frames to a pipeline — which is what
+[`BinPickingCell`](../samples/Robotics/BinPickingCell) does. A host with a real
+camera writes the same interface over its own SDK and never references OpenUSD
+at all.
+
+The runtime address space is rooted under the standard Server object.
+Configurators add frames, sensors and pipelines through the fluent builder;
+providers plug into the sensor or pipeline nodes that the builder creates.
+
+```mermaid
+flowchart TD
+ ServerObj["Server"] --> VisionRoot["Vision
VisionRootType"]
+ VisionRoot --> Sensors["Sensors"]
+ VisionRoot --> Frames["Frames"]
+ VisionRoot --> Pipelines["Pipelines"]
+
+ Frames --> Frame["CoordinateFrameType
FrameId + Transform"]
+ Sensors --> Sensor["VisionSensorType
ImageSensorType / Depth3DSensorType"]
+ Sensor --> Calibrations["Calibrations"]
+ Calibrations --> Intrinsic["IntrinsicCalibrationType"]
+ Calibrations --> Extrinsic["ExtrinsicCalibrationType"]
+ Sensor --> Media["Media
VisionMediaManagementType"]
+ Media --> Streams["StreamEndpoints"]
+ Media --> Clips["ClipEndpoints"]
+ Sensor -.->|MountedOn| Frame
+ Sensor -.->|HasScenePrim| Scene["OpenUSD scene prim"]
+
+ Pipelines --> Pipeline["InferencePipelineType"]
+ Pipeline --> Results["Results"]
+ Pipeline --> Feedback["Feedback
VisionFeedbackType"]
+ Pipeline -.->|Sensor| Sensor
+ Pipeline -.->|Deployment| Deployment["AI deployment NodeId"]
+```
+
+The libraries multi-target `net8.0;net9.0;net10.0` and `netstandard2.0`
+where applicable; the MCP tool package multi-targets `net8.0;net9.0;net10.0`.
+
+## Two perception paths behind one contract
+
+Every pipeline advertises exactly one of two inference locations, and a
+client reads a `DetectionResultType` identically regardless of which is in
+force:
+
+- **`InferenceLocation = OnServer`** — the Server holds an
+ `IVisionInferenceProvider` and computes results locally. This is the
+ deterministic path: it needs no model, no network and no GPU, and it is
+ the default for CI and offline validation. `RunInference`,
+ `StartContinuous` and `Stop` all delegate to the provider; the Server
+ publishes the resulting `DetectionResultType` / `InspectionResultType` /
+ `SegmentationResultType` under the pipeline's `Results` folder and
+ advertises `VIS-Inference-OnServer`.
+- **`InferenceLocation = EdgeOffServer`** — the pipeline exposes a
+ `VisionFeedbackType` object bound to an `IVisionFeedbackSink`; an
+ off-Server agent (a vision-language model over MCP, an edge inference
+ service, another Server) is expected to look at the current frame and
+ call `SubmitDetections` / `SubmitInspectionResult` /
+ `SubmitCorrection` / `SubmitImageReference`. The Server publishes those
+ results into the address space unchanged, and advertises
+ `VIS-Inference-OffServer`.
+
+Choose `OnServer` when a deterministic algorithm answers the question
+(vision-guided screwdriver alignment against a known fiducial; presence-or-
+absence in a clean scene) or when reproducibility on CI matters. Choose
+`EdgeOffServer` when a language model or a heavier out-of-process model is
+what actually sees the world — the Server publishes what it did not
+compute, and safety validation still applies (§9 refusals, class-label /
+box / pose / confidence checks; see [Feedback validation](#feedback-validation)).
+
+The two paths are exclusive per pipeline by design: mixing a running
+`OnServer` provider with a `SubmitDetections` sink would let a computed
+and a submitted result publish on the same pipeline out of any known
+order.
+
+```mermaid
+flowchart LR
+ Client["Client"]
+ Server["Vision Server"]
+ Provider["IVisionInferenceProvider"]
+ Edge["Off-server agent"]
+ Sink["IVisionFeedbackSink"]
+ Results["Pipeline Results folder"]
+
+ Client -->|RunInference| Server
+ Server -->|OnServer delegates| Provider
+ Provider -->|result id + result node| Results
+
+ Edge -->|reads frame| Server
+ Edge -->|SubmitDetections / SubmitInspectionResult| Server
+ Server -->|EdgeOffServer delegates| Sink
+ Sink -->|published result| Results
+
+ Client -->|reads same result types| Results
+```
+
+## Minimal hosted server
+
+The example below hosts a single simulated eye-in-hand camera, one
+inference pipeline, and a two-frame tree (`world` → `flange`). It is the
+smallest useful shape; the [`BinPickingCell`](../samples/Robotics/BinPickingCell)
+sample is the full end-to-end version.
+
+```csharp
+using Microsoft.Extensions.Hosting;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.Server;
+using Opc.Ua.Vision.Server.Builders;
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+builder.Services
+ .AddOpcUa()
+ .AddServer(options =>
+ {
+ options.ApplicationName = "VisionServer";
+ options.ApplicationUri = "urn:localhost:OPCFoundation:VisionServer";
+ options.AutoAcceptUntrustedCertificates = true;
+ options.EndpointUrls.Add("opc.tcp://localhost:62855/VisionServer");
+ })
+ .AddVision(options =>
+ {
+ options.InstanceNamespaceUri = "urn:example:vision:instances";
+ })
+ .AddVisionMediaProvider(sensorBrowseName: "Camera01")
+ .AddVisionInferenceProvider(
+ pipelineBrowseName: "Detector",
+ onServer: true)
+ .ConfigureVision((context, ct) =>
+ {
+ IVisionNodeBuilder nodes = context.Nodes;
+ // Nodes is the fluent address-space builder: everything it creates
+ // becomes real OPC UA nodes under Server/Vision that any client can
+ // browse. The full build context is described further down.
+
+ nodes.AddFrame("World", frame => frame
+ .WithFrameId("world")
+ .WithRole(VisionFrameRoleEnum.World));
+
+ nodes.AddFrame("Flange", frame => frame
+ .WithFrameId("flange")
+ .WithRole(VisionFrameRoleEnum.MechanicalInterface)
+ .WithParent("world"));
+
+ nodes.AddImageSensor("Camera01", sensor => sensor
+ .WithSensorId("cam-01")
+ .WithModality(VisionSensorModalityEnum.Area2D)
+ .WithRealityKind(VisionRealityKindEnum.Physical)
+ .WithFrameId("flange")
+ .WithResolution(1920u, 1080u)
+ .WithPixelFormat("Mono8")
+ .AddClipEndpoint("Clips", ep => ep
+ .WithEndpointId("clip-01")
+ .WithEndpointUri("opcua-inline://visionserver/clips")
+ .WithClipFormat(VisionClipFormatEnum.Png)
+ .WithResolution(1920u, 1080u)
+ .WithInlineDelivery(enabled: true, maxInlineClipSize: 8_388_608u)));
+
+ // The pipeline is bound to its inference provider by
+ // AddVisionInferenceProvider("Detector", ...)
+ // above; the ConfigureVision delegate just creates the pipeline
+ // node. A real cell resolves the sensor node the pipeline is
+ // reading from off Server/Vision/Sensors — see the BinPickingCell
+ // sample for the walk.
+ nodes.AddPipeline("Detector", pipe => pipe
+ .WithPipelineId("pipe-01")
+ .WithSensor(NodeId.Null));
+
+ return ValueTask.CompletedTask;
+ });
+
+using IHost app = builder.Build();
+await app.RunAsync().ConfigureAwait(false);
+```
+
+`AddVision` never modifies the Server's existing NodeManagers — it adds a
+standalone `VisionNodeManager` under the well-known `Server/Vision` object
+(§4.2). It also composes with `AddRobotIntent` and `AddRobotics`; both
+sample cells run all three side by side without any coupling in code.
+
+The `AddVisionMediaProvider` / `AddVisionInferenceProvider` /
+`AddVisionFeedbackSink` extensions resolve the provider from the DI
+container at build time and bind it to the sensor or pipeline whose
+BrowseName is passed. The equivalent `UseMediaProvider(provider)` /
+`UseInferenceProvider(provider, onServer)` / `UseFeedbackSink(sink)`
+methods on the fluent builders let a configurator bind a provider it
+holds directly.
+
+## Hosting API
+
+The extension methods on `IOpcUaServerBuilder` that make up the Vision
+hosting surface:
+
+| Method | Purpose |
+|---|---|
+| `AddVision(Action?)` | Registers the standalone `VisionNodeManager` and its factory; accepts an optional options delegate |
+| `AddVisionMediaProvider(string sensorBrowseName)` | Registers a media provider type — resolved from DI — for the sensor with the given BrowseName |
+| `AddVisionMediaProvider(string sensorBrowseName, IVisionMediaProvider provider)` | Registers a media provider instance for the sensor with the given BrowseName |
+| `AddVisionInferenceProvider(string pipelineBrowseName, bool onServer)` | Registers an inference provider type — resolved from DI — for the pipeline; `onServer` controls the advertised `VIS-Inference-OnServer` / `VIS-Inference-OffServer` facet (§8.2) |
+| `AddVisionInferenceProvider(string pipelineBrowseName, bool onServer, IVisionInferenceProvider provider)` | Registers an inference provider instance |
+| `AddVisionFeedbackSink(string pipelineBrowseName)` | Registers a feedback sink type — resolved from DI — for the pipeline's `Feedback` object |
+| `AddVisionFeedbackSink(string pipelineBrowseName, IVisionFeedbackSink sink)` | Registers a feedback sink instance |
+| `ConfigureVision(Func)` | Async configurator, run on server start against the standalone `VisionNodeManager` |
+| `ConfigureVision(Action)` | Sync configurator overload |
+| `ConfigureVisionFor(...)` | Configurator targeting a specific Vision node-manager type. Currently only `VisionNodeManager` is supported |
+
+The Robotics guide's `ConfigureFor<...>` pattern also applies here — any
+of these hosting extensions can be called from a class-based configurator
+that reads `IServiceProvider`, keeps its own logger, and does not put a
+lambda in `Program.cs`.
+
+### `VisionServerOptions`
+
+| Property | Purpose |
+|---|---|
+| `InstanceNamespaceUri` | The application-owned namespace URI used for the instances the configurator materialises. Must be distinct from the OPC UA base namespace and from `http://opcfoundation.org/UA/Vision/`. Defaults to `urn:opcfoundation:UA:Vision:Instances`. |
+| `SpecificationVersion` | The value the Server reports on `Vision.SpecificationVersion`. Defaults to `"0.1.0"`. |
+| `AdditionalFacets` | The facets the Server declares beyond those the facet calculator derives structurally — the escape hatch for facets whose requirements are behavioural (an interop facet that the host meets by contract). |
+
+## Build context
+
+`ConfigureVision(...)` receives an `IVisionBuildContext`. Here `Nodes` is
+the fluent address-space builder rooted at the well-known `Server/Vision`
+object; it creates the frame, sensor, calibration, media-endpoint and
+pipeline nodes that ordinary OPC UA clients browse and read.
+
+
+| Member | Purpose |
+|---|---|
+| `Nodes` | The fluent `IVisionNodeBuilder` rooted at the well-known `Server/Vision` object |
+| `Manager` | The active `AsyncCustomNodeManager`, for the rare case a configurator must fall back to raw node authoring |
+| `Context` | The active `ISystemContext` |
+| `Root` | The `VisionRootState` (§4.2) |
+| `InstanceNamespaceIndex` | The namespace index of `VisionServerOptions.InstanceNamespaceUri` |
+| `VisionNamespaceIndex` | The namespace index of `http://opcfoundation.org/UA/Vision/` |
+| `CancellationToken` | The startup cancellation token |
+| `GetRequiredService()` | Application-scoped DI resolution |
+
+Everything a Vision cell needs (frames, sensors, pipelines, calibrations,
+media endpoints) is authored through `Nodes`. The low-level members are
+present for interop with hand-written NodeManagers and for the vendor
+extension patterns the Robotics guide describes.
+
+### Without DI
+
+A `VisionNodeManager` created by hand exposes the same fluent surface
+through `ConfigureVisionAsync`:
+
+```csharp
+await manager.ConfigureVisionAsync(context =>
+{
+ context.Nodes.AddFrame("World", f => f
+ .WithFrameId("world")
+ .WithRole(VisionFrameRoleEnum.World));
+});
+```
+
+Prefer it over `CreateVisionBuildContext()`. The node manager indexes the
+Vision root when it creates the address space, so anything the builder
+grafts on afterwards has to be registered as well before it can be
+browsed or read **by its own NodeId** — which is how an ordinary client
+and the MCP discovery tools navigate. `ConfigureVisionAsync` does that
+registration when the delegate returns; a context obtained from
+`CreateVisionBuildContext()` never does, so nodes built through it stay
+reachable only by browsing forward from their parent.
+
+## Topology builders
+
+### Frames
+
+```csharp
+nodes.AddFrame("World", f => f
+ .WithFrameId("world")
+ .WithRole(VisionFrameRoleEnum.World));
+
+nodes.AddFrame("RobotBase", f => f
+ .WithFrameId("robot_base")
+ .WithRole(VisionFrameRoleEnum.Base)
+ .WithParent("world")
+ .WithTransform(new VisionPose3DDataType
+ {
+ FrameId = "world",
+ Position = new[] { 0.0, 0.0, 0.829 }.ToArrayOf(),
+ Orientation = new[] { 0.0, 0.0, 0.0, 1.0 }.ToArrayOf(),
+ Covariance = ArrayOf.Empty,
+ }));
+```
+
+The `Transform.FrameId` names the parent frame per the §5.12 frame-
+precedence rule. When the parent is added later in the configurator,
+`WithParent("world")` resolves at finalise time. Passing a `NodeId`
+overload (`WithParent(NodeId)`) skips the deferred resolution when the
+caller already has one.
+
+Roles: `World`, `Base`, `MechanicalInterface`, `Tool`, `Object`, `Station`,
+`Camera`, `Custom`. `Camera` is the non-ISO addition Vision introduces
+for the sensor's own frame.
+
+### Sensors
+
+`IVisionNodeBuilder` exposes three sensor entry points:
+
+- `AddImageSensor(browseName, configure)` for `ImageSensorType`;
+- `AddDepth3DSensor(browseName, configure)` for `Depth3DSensorType`;
+- `AddSensor(browseName, configure)` for the abstract `VisionSensorType`
+ when a vendor subtype is materialised through a `IVisionModelProvider`.
+
+All sensor builders share the members on `IVisionSensorBuilder`:
+identity (`WithSensorId`, `WithManufacturer`, `WithModel`,
+`WithSerialNumber`, `WithDeviceUri`), the frame binding (`WithFrameId`,
+`MountedOn`, `HasScenePrim`), the reality kind (`WithRealityKind`), the
+modality (`WithModality`), and the nested builders — `WithOptics(...)`,
+`WithIllumination(...)`, `AddIntrinsicCalibration(...)`,
+`AddExtrinsicCalibration(...)`, `AddStreamEndpoint(...)`,
+`AddClipEndpoint(...)`, `UseMediaProvider(...)`.
+
+`WithFrameId(frameId)` also adds a `MountedOn` reference to the
+`CoordinateFrameType` instance with the matching `FrameId` when one has
+been registered under `Vision/Frames`. This is the recommended way to
+attach a sensor to a frame; `MountedOn(NodeId)` and `HasScenePrim(NodeId)`
+are the fallbacks for mounts that are not vision frames.
+
+### Pipelines
+
+A pipeline is the Vision object that connects one sensor, an optional AI
+deployment reference, and the result/feedback methods for one perception task
+such as detection or inspection. Clients discover pipelines first, then run
+or observe the task through that pipeline.
+
+```csharp
+nodes.AddPipeline("Detector", pipe => pipe
+ .WithPipelineId("pipe-01")
+ .WithSensor(cameraNodeId)
+ .WithDeployment(deploymentNodeId)
+ .ProducedBy(controllerNodeId)
+ .UseInferenceProvider(inferenceProvider, onServer: true)
+ .UseFeedbackSink(feedbackSink));
+```
+
+`WithSensor(NodeId)` points the pipeline at the sensor that supplies the
+frames. `WithDeployment(NodeId)` then records which model deployment, if
+any, is responsible for the task. The specification deliberately keeps
+`Deployment` typed as `NodeId` so a Server never has to depend on the AI
+Model Management companion; a host that implements that companion can point
+at its deployment node, and a host that does not can leave the value null.
+`ProducedBy(NodeId)` adds the `ProducedBy` semantic reference to a controller
+or process instance.
+
+`UseFeedbackSink(sink)` is optional for `OnServer` pipelines. `OnServer`
+pipelines without a feedback sink still expose a `Feedback` object
+whose `Submit*` methods return `Bad_NotSupported` — a client cannot
+publish detections into a pipeline where nothing consumes them.
+`EdgeOffServer` pipelines almost always want both a provider (whose
+`RunInference` explains the mode with `Bad_NotSupported`) and a sink
+that receives the agent's submissions.
+
+### Providers
+
+The provider abstractions live in `Opc.Ua.Vision.Server`:
+
+- **`IVisionMediaProvider`** — supplies media without putting pixels on
+ OPC UA. `GetStreamAsync` returns a leased URI; `GetClipAsync` returns
+ a `VisionImageReferenceDataType` and, when the caller asked for it and
+ the encoded bytes fit the effective inline limit, an inline
+ `ByteString`. Providers implement the by-reference default (§6.4) —
+ Servers keep pixel bytes off the OPC UA wire.
+- **`IVisionInferenceProvider`** — binds a pipeline to whatever actually
+ computes results. The Server publishes the result nodes and applies
+ the spec's method conventions regardless of whether the provider runs
+ a deterministic detector, a GPU inference engine, an in-process
+ simulation or refuses everything with `Bad_NotSupported` (the
+ `EdgeOffServer` case).
+- **`IVisionFeedbackSink`** — receives `SubmitDetections`,
+ `SubmitInspectionResult`, `SubmitCorrection` and
+ `SubmitImageReference`. Off-Server agents publish through this path
+ and the Server records what it did not compute.
+
+Every provider is registered as a DI singleton and constructed with the
+same lifetime as the server host — the framework never re-creates them
+per call.
+
+## §5.12 conventions
+
+Vision inherits and adds a small number of numerical conventions that are
+silently wrong if misread. Every client, provider and configurator in
+this repository respects them, and public API points that carry pose or
+image geometry document them explicitly:
+
+- **Quaternion order is `(x, y, z, w)`.** Every `Orientation` array in
+ `VisionPose3DDataType` is a unit quaternion ordered `(x, y, z, w)`. The
+ frame graph checks `‖q‖ = 1` within tolerance `1e-6` and refuses a
+ zero-norm quaternion with `Bad_InvalidArgument`.
+- **Positions are metres.** Every `Position` array is a 3-vector in
+ metres. `MinDepth`, `MaxDepth`, `Baseline`, `WorkingDistance` and every
+ distance-typed member are metres.
+- **The principal point is corner-datum.** `VisionIntrinsicsDataType.Cx`
+ and `Cy` are measured from the top-left corner of the image (pixel
+ centre `(0.5, 0.5)`). A client bridging to a library that uses
+ centre-datum coordinates subtracts `0.5` from `Cx` and `Cy`.
+- **An empty covariance array is the sentinel for "not reported".** A
+ pose that reports no covariance uses `Covariance = ArrayOf.Empty`
+ — not a 6×6 zero matrix, which would misrepresent the pose as having
+ been measured with perfect certainty.
+
+The `VisionFrameGraph` composes transforms strictly per these rules:
+right-handed frames, `(x, y, z, w)`-ordered quaternions, no
+substitutions.
+
+## §6.4 media gating
+
+Vision separates the by-reference default path (a `VisionImageReference`
+descriptor with URI, timestamp and digest) from the optional inline
+delivery of encoded still image bytes. §6.4 fixes what a Server returns
+in each state, and `VisionMediaClient` classifies the raw `StatusCode`
+into a `VisionInlineClipState` enum so a caller can branch cleanly:
+
+| State | `StatusCode` | Meaning |
+|---|---|---|
+| `Available` | `Good` | The encoded image fits the inline limit and is returned in `VisionInlineClipReading.Bytes` |
+| `NotYetAvailable` | `Bad_NoDataAvailable` | The Server has not published a clip yet — §6.4 rule 5 requires this before the first acquisition |
+| `InlineDisabled` | `Bad_NotSupported` | `InlineDeliveryEnabled = false` on the endpoint; §6.4 rule 5 requires this exact code |
+| `Overflow` | `Bad_EncodingLimitsExceeded` | The last acquisition exceeded the effective inline size limit; §6.4 rule 3 requires no truncation |
+| `Faulted` | Other | The endpoint reported a different error |
+
+`GetClip` is always the safe path — it returns the by-reference
+descriptor, and returns the inline bytes as well when the caller passes
+`requestInline: true` and the still fits. `LatestClip` and
+`LatestClipMetadata` are the "read the latest" variant of the same
+contract. Crucially, `LatestClipMetadata` remains readable even when
+`LatestClip` reports `Bad_NotSupported` — the metadata carries the URI,
+timestamp, digest and pixel format the caller needs to walk the still
+out of band, and reporting `Bad_NotSupported` on the metadata read would
+be wrong.
+
+```mermaid
+stateDiagram-v2
+ [*] --> NotYetAvailable: no acquisition yet
+ NotYetAvailable --> Available: clip captured and fits
+ Available --> Overflow: next clip exceeds limit
+ Overflow --> Available: later clip fits
+ Available --> InlineDisabled: inline delivery disabled
+ InlineDisabled --> Available: inline delivery enabled
+ Available --> Faulted: provider error
+ Overflow --> Faulted: provider error
+ Faulted --> Available: provider recovers
+```
+
+## Rendering without pixels
+
+`Opc.Ua.Vision.OpenUsd` renders a `UsdGeomCamera` from a USD stage to an
+encoded still, and is the reference `ISceneCameraCaptureProvider`
+implementation the sample cell registers with
+`services.AddOpenUsdSceneCameraCaptureProvider()`. When rendering is not
+possible it reports the unavailable backend and leaves the address space
+walkable instead of throwing from browse/read paths. It supports the
+following behaviour explicitly:
+
+- On a machine with the native OpenUSD renderer payload present and a
+ usable graphics device, it renders normally and returns encoded PNG /
+ JPEG bytes.
+- On a machine with no graphics device — the normal case on CI — it
+ reports `SceneCameraCaptureBackend.NoRenderingBackend` on
+ `Backend.UnavailableReason` and returns `Bad_NoDataAvailable` for
+ every capture. The sensor still exists in the address space and every
+ browse still works; only the pixel bytes are absent.
+
+The intent is that a client can rely on the address space always being
+walkable, even when the process has no way to produce pixels. The
+`--demo` client path in the sample skips its compose step gracefully
+when the frame is unavailable rather than falsely reporting a rendering
+bug.
+
+## Facets supported
+
+`VisionServerOptions.AdditionalFacets` is additive on top of the facets
+the address-space calculator derives structurally. **Supported** means the
+stock builder and calculator can claim the facet from the materialised address
+space. **Partial** means the model surface exists, but the host must attest the
+facet through `AdditionalFacets` because the calculator cannot verify the
+behaviour or provider-owned result nodes. **Not supported** means the stock
+server does not currently claim that facet.
+
+| Facet | Support | Structural requirement or limitation |
+|---|---|---|
+| `VIS-Base` | Supported | A registered sensor contributes the base Vision server shape. |
+| `VIS-Sensor-Params` | Supported | A sensor includes manufacturer, model or serial-number parameters. |
+| `VIS-Optics` | Supported | A sensor has an `Optics` child. |
+| `VIS-Media-Rtsp` | Supported | A stream endpoint uses `VisionStreamProtocolEnum.Rtsp`. |
+| `VIS-Media-Jpeg` | Supported | A clip endpoint uses `VisionClipFormatEnum.Jpeg`. |
+| `VIS-Media-Inline` | Supported | A clip endpoint has `InlineDeliveryEnabled = true`. |
+| `VIS-Media-DataChannel` | Not supported | There is no stock data-channel endpoint builder or calculator rule. |
+| `VIS-Endpoint-Config` | Supported | At least one stream endpoint is materialised. |
+| `VIS-Calibration` | Supported | An intrinsic or extrinsic calibration is materialised. |
+| `VIS-Result-Detection` | Partial | Providers and sinks can publish detection results, but result ownership is provider-side and not structurally derived. |
+| `VIS-Result-Inspection` | Partial | `SubmitInspectionResult` exists, but the calculator does not infer inspection-result publication. |
+| `VIS-Result-Segmentation` | Not supported | The stock conformance URI list and feedback surface do not claim a segmentation-result facet. |
+| `VIS-Feedback` | Supported | A pipeline has a `Feedback` object bound to a sink. |
+| `VIS-Inference-OnServer` | Supported | A pipeline was registered with `onServer: true`. |
+| `VIS-Inference-OffServer` | Supported | A pipeline was registered with `onServer: false`. |
+| `VIS-Simulation` | Supported | A sensor has `RealityKind = Simulated` or `Hybrid`. |
+| `VIS-Learning` | Partial | Learning jobs are modelled by reference; the host owns the job and any `SamplesCollected` accounting. |
+| `VIS-Interop-Scene` | Supported | A sensor carries a `HasScenePrim` reference. |
+| `VIS-Interop-40100` | Partial | The host must attest cross-model behaviour through `AdditionalFacets`. |
+| `VIS-Interop-RobotIntent` | Partial | The host must attest cross-model behaviour through `AdditionalFacets`. |
+
+The Server publishes the composed set on
+`Server.ServerCapabilities.ServerProfileArray`.
+
+## Using the client libraries
+
+### Registration
+
+The client hosting extensions register a `VisionClientFactory` and the
+factory function downstream services request:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using Opc.Ua.Client;
+using Opc.Ua.Vision.Client;
+
+builder.Services
+ .AddOpcUa()
+ .AddClient(options => { /* endpoint and application options */ })
+ .AddVisionClient();
+```
+
+`AddVisionClient()` requires `AddClient(...)` to have been called first
+so the shared `ManagedSession` factory is available.
+
+Without DI, a `VisionClient` can be opened directly from any connected
+`ISession`:
+
+```csharp
+using Opc.Ua;
+using Opc.Ua.Client;
+using Opc.Ua.Vision.Client;
+
+VisionClient vision = session.Vision(telemetry);
+if (!vision.IsVisionNamespaceAvailable)
+{
+ // The Server does not implement the Vision companion.
+ return;
+}
+```
+
+### Discovery
+
+```csharp
+await foreach (VisionNodeEntry sensor in vision.EnumerateSensorsAsync(ct))
+{
+ Console.WriteLine($"{sensor.BrowseName} ({sensor.TypeDefinition})");
+}
+
+await foreach (VisionNodeEntry pipeline in vision.EnumeratePipelinesAsync(ct))
+{
+ Console.WriteLine($"{pipeline.BrowseName} ({pipeline.TypeDefinition})");
+}
+```
+
+Both `EnumerateSensorsAsync` and `EnumeratePipelinesAsync` are subtype
+aware: a Server that specialises `ImageSensorType` with a vendor
+subtype, or `DetectionResultType` with a domain result subtype, is
+enumerated as an instance of the closest declared Vision base type. The
+lower-level `DiscoverSensorsAsync` / `DiscoverPipelinesAsync` /
+`DiscoverFramesAsync` return `ArrayOf` for callers that already
+know how to render the picker themselves.
+
+### Reading a detection
+
+```csharp
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.Client;
+
+NodeId pipelineNodeId = /* discovered above */;
+VisionPipelineClient pipe = vision.Pipeline(pipelineNodeId);
+
+VisionPipelineSnapshot snapshot = await pipe.ReadAsync(ct);
+string runId = await pipe.RunInferenceAsync(cancellationToken: ct);
+
+await foreach (VisionNodeEntry result in pipe.EnumerateResultsAsync(ct))
+{
+ VisionDetectionResultSnapshot detection = await vision
+ .Result(result.NodeId)
+ .ReadDetectionAsync(ct);
+
+ Console.WriteLine(
+ $"result={detection.ResultId} frame={detection.FrameId} " +
+ $"count={detection.Detections.Count}");
+ for (int ii = 0; ii < detection.Detections.Count; ii++)
+ {
+ VisionDetectionDataType d = detection.Detections[ii];
+ Console.WriteLine($" [{ii}] {d.ClassLabel} conf={d.Confidence:0.###}");
+ }
+}
+```
+
+`VisionPipelineClient.RunInferenceAsync(default, ct)` lets the Server
+acquire the frame "now"; passing a non-default `DateTimeUtc` requests a
+specific acquisition timestamp — the Server may honour it or refuse per
+§8.
+
+### Composing a pose
+
+The Vision-side calibrations and frame names match the Robotics-side
+frame ids by convention — a client can walk from the vision-side pose to
+the robot-side world frame without any translation table:
+
+```csharp
+VisionFrameGraph frames = vision.Frames();
+
+NodeId cameraFrameId = /* from EnumerateFramesAsync, matching FrameId "camera_eih" */;
+NodeId worldFrameId = /* likewise, matching "world" */;
+
+VisionDetectionResultSnapshot detection =
+ await vision.Result(resultNodeId).ReadDetectionAsync(ct);
+
+for (int ii = 0; ii < detection.Detections.Count; ii++)
+{
+ VisionDetectionDataType d = detection.Detections[ii];
+ if (!d.HasPose)
+ {
+ continue;
+ }
+
+ // Compose the detection's pose (expressed in the camera frame) into
+ // the world frame; the frame graph walks the parent chain and
+ // multiplies transforms per §5.12.
+ VisionPose3DDataType inWorld = await frames.ComposeAsync(
+ d.Pose, cameraFrameId, worldFrameId, ct);
+
+ Console.WriteLine(
+ $"{d.ClassLabel}: pos=[{inWorld.Position[0]:0.###}, " +
+ $"{inWorld.Position[1]:0.###}, {inWorld.Position[2]:0.###}]");
+}
+```
+
+`ComposeAsync` walks up to 32 frames from each side, throws
+`Bad_NoMatch` when the two frames share no common ancestor, and refuses
+a non-unit quaternion within tolerance `1e-6` — none of which are
+substituted with an identity transform, because a silent substitution
+would make an incorrect pose look correct.
+
+### Submitting feedback
+
+```csharp
+VisionFeedbackClient? feedback = await pipe.OpenFeedbackAsync(ct);
+if (feedback is null)
+{
+ // The pipeline does not expose a Feedback object — nothing to submit into.
+ return;
+}
+
+ArrayOf detections = new[]
+{
+ new VisionDetectionDataType
+ {
+ ClassLabel = "RedCube",
+ Confidence = 0.94,
+ HasBoundingBox2D = true,
+ BoundingBox2D = new VisionBoundingBox2DDataType
+ {
+ CenterX = 812.0, CenterY = 604.0, Width = 96.0, Height = 96.0,
+ },
+ HasPose = true,
+ Pose = new VisionPose3DDataType
+ {
+ FrameId = "camera_eih",
+ Position = new[] { 0.031, -0.017, 0.412 }.ToArrayOf(),
+ Orientation = new[] { 0.0, 0.0, 0.0, 1.0 }.ToArrayOf(),
+ Covariance = ArrayOf.Empty,
+ },
+ },
+}.ToArrayOf();
+
+await feedback.SubmitDetectionsAsync(
+ VisionFeedbackPurposeEnum.Reconciliation,
+ detections,
+ frameReference: null,
+ inlineImage: ByteString.Empty,
+ cancellationToken: ct);
+```
+
+The `Purpose` values `Overlay`, `Reconciliation`, `GroundTruthLabel`
+and `Trigger` are all defined; a Server refuses the ones it does not
+permit with `Bad_NotSupported`. `SubmitCorrection` accepts *at most
+one* of the two `corrected*` arrays non-empty — passing both is an
+argument error (§9.5). `SubmitInspectionResult` requires at least one
+characteristic.
+
+> **Reporting an empty scene, and retracting a false positive.**
+> `SubmitDetections` takes a `SceneIsEmpty` flag and `SubmitCorrection`
+> a `RetractAll` flag. They exist because an empty observation is a real
+> one: "I examined this frame and there is nothing in it" is the
+> terminating condition of a bin-picking task and a valid negative
+> training label, and a false positive is corrected by asserting that
+> nothing replaces it. Neither statement can be made by submitting an
+> array, because both *are* the empty array.
+>
+> The pairing is checked in both directions. An empty `Detections`
+> without `SceneIsEmpty` is refused — the flag is what distinguishes a
+> deliberate observation from a lost payload — and `SceneIsEmpty` with
+> detections attached is refused because it asserts two contradictory
+> things about one frame. `RetractAll` behaves the same way against the
+> corrected arrays. §9.4 requires a Server to count a negative example
+> in `SamplesCollected` exactly as it counts one carrying geometry —
+> that counter belongs to the learning job, so see
+> [Limitations](#limitations) for where this implementation draws the
+> line and what it hands the host instead.
+
+### Streaming detections
+
+`VisionResultReader.ObserveDetectionsAsync(IStreamingSubscription, ct)`
+publishes each `DetectionResultType` change as it arrives — either a new
+result or a mutation of an existing one. Use it when you need to react
+to detections rather than poll:
+
+```csharp
+using Opc.Ua.Client;
+
+// A ManagedSession exposes a shared default IStreamingSubscription
+// that a caller can hand to any Observe*Async method.
+IStreamingSubscription streaming = session.DefaultStreaming;
+
+VisionResultReader reader = vision.Result(resultNodeId);
+await foreach (VisionDetectionResultSnapshot snapshot in
+ reader.ObserveDetectionsAsync(streaming, ct))
+{
+ Console.WriteLine($"result {snapshot.ResultId}: {snapshot.Detections.Count} detections");
+}
+```
+
+`ObserveInspectionAsync` and `ObserveSegmentationAsync` do the same for
+inspection and segmentation results.
+
+## MCP tools
+
+The `Opc.Ua.Mcp.Vision` package contributes 22 tools split across six
+categories, and the bounded `vision` profile in `docs/McpServer.md`
+carries them plus the four `ConnectionTools` — every Vision tool
+resolves a named OPC UA session, and only the connection tools can open
+one.
+
+- Discovery — `vision_list_sensors`, `vision_list_pipelines`,
+ `vision_list_frames`, `vision_list_calibrations`.
+- Monitoring — `vision_read_sensor`, `vision_read_extrinsic_calibration`,
+ `vision_read_pipeline`, `vision_read_detection_result`,
+ `vision_read_inspection_result`, `vision_read_segmentation_result`.
+- Seeing — `vision_get_frame` (returns the encoded still as an MCP
+ `ImageContentBlock` with the correct MIME type, so a model actually
+ sees pixels rather than a description of them), `vision_get_frame_metadata`.
+- Inference — `vision_run_inference`, `vision_start_continuous_inference`,
+ `vision_stop_inference`.
+- Feedback — `vision_submit_detections`, `vision_submit_inspection_result`,
+ `vision_submit_correction`, `vision_submit_image_reference`.
+- Geometry — `vision_read_frame`, `vision_compose_pose`,
+ `vision_compose_transform`.
+
+`vision_run_inference` accepts one structured `request`. Its `pipeline`
+selector is an exact BrowseName, DisplayName or NodeId; `expectedKind` can
+require a Detection, Inspection or Segmentation result; and `detail=Summary`
+returns a bounded typed summary together with the ResultId, result NodeId,
+sensor, requested/published pipeline, model and frame provenance. Use
+`detail=HandleOnly` when only the addressable result is needed, or call a
+`vision_read_*_result` tool for the complete payload.
+
+Profiles compose. A host that wires the `Vision` and `Robotics` profile
+sets together — the [BinPickingClient sample](../samples/Robotics/BinPickingClient)
+does this — exposes 64 tools in total, measured as
+`22 Vision + 4 Connection + 42 Robotics − 4 shared Connection = 64`.
+The `WithOpcUaVisionTools(McpToolProfileSet)` overload never registers
+`ConnectionTools` directly; the corresponding
+`WithOpcUaCoreTools(McpToolProfileSet)` overload owns and deduplicates
+that registration across every OPC UA MCP package a host references.
+
+See [`docs/McpServer.md`](McpServer.md) for the full profile table and
+composition rules.
+
+## Sample: bin-picking
+
+[`samples/Robotics/BinPickingCell`](../samples/Robotics/BinPickingCell) is
+the reference from the *OPC UA Robotics-Vision Addendum*: a
+branch-stable four-axis palletizer with a parallel gripper, an
+eye-in-hand camera parented to the flange, a bin of five parts, a
+fixture, and the frame tree
+`world → robot_base → flange → gripper_tcp` with `camera_eih` on the
+flange. It hosts `Robot Intent`, the Vision companion and the
+`OpenUsdScene` companion side by side, and either the on-server
+deterministic detector (`--inferenceLocation OnServer`, the default)
+or the off-server agent path (`--inferenceLocation EdgeOffServer`).
+The selected detection identifies the workpiece and preserves its pose as
+provenance. The Pick carries the selected class plus the source Location and
+tool; the cell's executor resolves that workpiece at its observed world pose
+rather than substituting the Location centre.
+
+[`samples/Robotics/BinPickingClient`](../samples/Robotics/BinPickingClient)
+is the paired client: `--demo` runs the whole loop without an agent,
+`--mcp` exposes the composed 64-tool MCP catalogue for a language-model
+agent, and `--view` opens the in-process OpenUSD viewport so a human
+sees the same scene the agent sees.
+
+The composed Robotics package also contributes `robotics_vision_pick`. It runs
+one inference through the Vision client, selects one detection
+deterministically, and submits either a Pick operation or a two-step Pick/Place
+mission. It returns the selected detection provenance and the authoritative
+operation or mission handles. Command authority remains explicit: the helper
+never requests ownership, retries, waits, cancels or hides a refusal.
+
+### Scene lighting
+
+The scene is lit by a single `DomeLight` with `intensity = 1000`.
+Do **not** reintroduce a `DistantLight` or any other bright directional or
+point light for a Vision demo: at any intensity that shows geometry, a
+`DistantLight` blows every surface to pure white regardless of
+`displayColor`, and any agent looking at the frame sees a uniform
+white blur. Under a `DomeLight` the five sample parts measure
+`red (220, 37, 37)`, `green (37, 208, 49)`, `blue (37, 73, 233)` — distinct
+enough for a vision-language model to reason about ("pick the red
+cube"). The `Cell.usda` header records this contract explicitly.
+Anyone authoring their own scene from scratch needs to know this or
+their agent will see nothing they can act on.
+
+### Feedback validation
+
+When the cell runs in `EdgeOffServer` mode the agent sends detections
+through `SubmitDetections`. The sample's feedback sink refuses malformed
+submissions with `Bad_InvalidArgument` and a message the agent can act
+on:
+
+- **Unknown class label** — refused with the exact list of parts that
+ do exist. `Detection 0 class 'PurplePyramid' is not a part in this cell.
+ Known classes: RedCube, GreenCylinder, BlueSphere, YellowSlab, OrangeBrick.`
+- **Confidence outside `[0, 1]`** — refused with the observed value.
+- **Bounding box outside the image** — refused with the box coordinates
+ and the image dimensions the box was measured against.
+- **Zero-norm quaternion or pose with fewer than three position
+ components** — refused with the detection index.
+
+An **empty** detection set is refused too, with `Bad_InvalidArgument`,
+because §9.5 states it plainly: "`Detections` empty" is an argument
+error. So is a `SubmitCorrection` whose corrected arrays are both empty
+or both populated — §9.5 requires *exactly one* to be non-empty.
+
+That is worth dwelling on, because it means two useful statements cannot
+be made at all. An agent that has emptied the bin cannot report "I looked
+and there is nothing there"; it must either invent a detection or say
+nothing. And a false positive — the model saw something that was not
+there — cannot be retracted by correcting the result down to an empty
+set, which is one of the more valuable labels a correction could carry.
+The implementation conforms rather than deviating, and the gap is raised
+against the draft; see [Limitations](#limitations).
+
+## Limitations
+
+- **The Vision specification is a draft.** The namespace URI and every
+ NodeId are provisional; every ObjectType and BrowseName can change
+ when the working group publishes.
+- **No vendor drivers ship.** The reference `IVisionMediaProvider`
+ covered here renders an OpenUSD stage offscreen. There is no GigE
+ Vision, USB3 Vision, GenICam or vendor-native driver in the box; a
+ host implementing `IVisionMediaProvider` for a real camera is the
+ supported extension point.
+- **`ConfigureVisionFor` only accepts `VisionNodeManager`.**
+ Custom Vision node-manager types are not yet supported; vendor
+ extension follows the same class-based-configurator pattern the
+ Robotics guide describes.
+- **Learning jobs are modelled but not driven, and `SamplesCollected` is
+ not counted here.** `InferencePipelineType` carries a `LearningJob`
+ optional child and the facet calculator publishes `VIS-Learning` when
+ one is bound, but the standalone `VisionNodeManager` does not itself
+ run training — a host provides a learning-job provider through the
+ extension pattern. This has one named conformance consequence: §9.4
+ requires a Server to count a negative example (`SceneIsEmpty` or
+ `RetractAll` carrying a `GroundTruthLabel`) in `SamplesCollected`
+ exactly as it counts one carrying geometry, and `VisionNodeManager`
+ does not do that counting. It cannot: `SamplesCollected` is a property
+ of `LearningJobType`, which the *AI Model Management* companion
+ defines, and Vision reaches it through a `NodeId` value rather than a
+ Reference precisely so this model takes no dependency on the model
+ that defines the job. What the Server does guarantee is that the
+ negative example survives the hop intact — `SceneIsEmpty` and
+ `RetractAll` are carried verbatim on
+ `VisionSubmitDetectionsRequest` / `VisionSubmitCorrectionRequest`, so
+ a host that binds a learning job has everything it needs to satisfy
+ §9.4 on the counter it owns.
+- **AOT compatibility of the OpenUSD capture provider depends on the
+ native renderer payload.** The managed layer is AOT-friendly; the
+ native payload ships per-RID and its presence at runtime is what
+ distinguishes a rendering capture from a `NoRenderingBackend` one.
+
+## Visual inspection: a cross-companion cell
+
+Inspection is the part of the specification that composes with the most
+other models, so it is documented here in full rather than in isolation.
+The [Vision samples](../samples/Vision) build a cell where a camera
+photographs a machined bracket, a model measures it, deterministic code
+judges those measurements against a recipe, the verdict drives an ISA-95 job
+order, and anything the machine cannot decide escalates to an operator whose
+answer is captured as ground truth and counted as a learning sample. The
+sample READMEs cover how to run it; what follows is why it is built this way.
+
+### The model never decides
+
+The central safety property is that model output is evidence, not authority. A
+model may be involved in producing measured characteristics and a confidence,
+but deterministic code applies the recipe tolerances and computes the verdict.
+That matters because a plant that let a language model schedule production from
+what it thought it saw in a photograph would have an image-shaped path straight
+into job control. In this sample, image content can influence the measured
+values, but it cannot become a free-form job-control instruction.
+
+The sample also routes inference through the AI companion's deployment
+`Invoke` method instead of letting the agent call a model privately in its own
+process. That keeps the deployment node, model version, and usage accounting in
+the address space and in the provenance trail. A private model call would make
+the most important part of the loop invisible.
+
+### Address-space composition
+
+`VisualInspectionCell` composes four companion areas in one server process:
+
+- Vision publishes `BracketFixtureCamera`, `FixtureImages`,
+ `BracketInspectionPipeline`, inspection results, and feedback.
+- AI Model Management publishes the primary deployment
+ `visual-inspection-primary`, its model metadata, and the learning job whose
+ `SamplesCollected` value is incremented by host code.
+- ISA-95 Job Control V2 publishes the fixed job-order catalogue and the V2
+ Methods the agent calls.
+- Alarms & Conditions publishes the `OperatorDispositionDialog` condition.
+
+`BracketInspectionPipeline` points at the AI deployment through `Deployment` and
+at the learning job through `LearningJob`. The Vision companion deliberately
+uses `NodeId` values for those bindings; it does not take a compile-time
+dependency on the AI companion. This sample is the host that binds both models
+and can therefore satisfy the learning-job counter semantics that standalone
+Vision cannot.
+
+### Recipe and verdict rule
+
+The inspected part is a machined bracket with three dimensional characteristics
+in millimetres:
+
+| Characteristic | Nominal | Tolerance |
+|---|---:|---:|
+| `BoreDiameter` | 12.00 | ± 0.20 |
+| `SlotWidth` | 8.00 | ± 0.15 |
+| `EdgeOffset` | 20.00 | ± 0.25 |
+
+For each characteristic, the rule builds an interval from the measured value and
+physical uncertainty:
+
+```text
+measurement interval = actual ± uncertainty
+tolerance interval = [nominal - lowerTol, nominal + upperTol]
+```
+
+Then it classifies the characteristic:
+
+- wholly inside the tolerance interval -> `Ok`
+- wholly outside the tolerance interval -> `NotOk`
+- straddling either tolerance limit -> `NotDecidable`
+
+The part verdict is the worst characteristic verdict, with `NotOk` worse than
+`NotDecidable`, and `NotDecidable` worse than `Ok`.
+
+```mermaid
+flowchart TD
+ Measurement["Measured characteristic
actual and uncertainty"] --> Interval["Build actual +/- uncertainty"]
+ Interval --> Compare{"Compare with tolerance interval"}
+ Compare -->|"wholly inside"| Ok["Ok"]
+ Compare -->|"wholly outside"| NotOk["NotOk"]
+ Compare -->|"straddles a limit"| NotDecidable["NotDecidable"]
+ Ok --> Worst["Part takes worst characteristic verdict"]
+ NotOk --> Worst
+ NotDecidable --> Worst
+```
+
+### Why uncertainty is physical
+
+The fixture images are 800 x 600 pixels at 10 px/mm. A feature edge can only land
+on a pixel boundary, so a dimensional measurement carries one-pixel quantisation
+uncertainty: 0.10 mm. That is the camera's pixel pitch. It is what makes
+`VisionCharacteristicDataType.Uncertainty` meaningful, and it is why
+`NotDecidable` arises naturally instead of being contrived.
+
+The three fixtures exercise all branches:
+
+| Fixture | Decisive characteristic | Interval | Verdict |
+|---|---|---|---|
+| `bracket-ok.png` | `BoreDiameter = 12.00` | `[11.90, 12.10]` is wholly inside `[11.80, 12.20]` | `Ok` |
+| `bracket-not-ok.png` | `BoreDiameter = 12.60` | `[12.50, 12.70]` is wholly outside the bore tolerance | `NotOk` |
+| `bracket-ambiguous.png` | `SlotWidth = 8.10` | `[8.00, 8.20]` straddles the 8.15 upper limit | `NotDecidable` |
+
+The ambiguous fixture is intentionally mundane: the intended 8.15 mm slot is
+81.5 pixels at 10 px/mm and cannot be drawn exactly. The raster image therefore
+measures as 8.10 mm, and one pixel of uncertainty crosses the tolerance limit.
+That is precisely the case the Vision `NotDecidable` value exists for.
+
+### Inspection loop
+
+The agent drives the process from outside the server. It discovers the Vision
+pipeline, opens the media endpoint, follows the pipeline's `Deployment` to the
+AI companion, discovers ISA-95 V2 endpoints, and finds the operator dialog.
+
+```mermaid
+flowchart TD
+ Discover["Discover pipeline, media, deployment, jobs, dialog"] --> Capture["Get fixture PNG"]
+ Capture --> Measure["Measure bracket geometry"]
+ Measure --> Invoke["Call AI deployment Invoke"]
+ Invoke --> Judge["Apply recipe rule"]
+ Judge --> Submit["Submit inspection result to Vision Feedback"]
+ Submit --> Verdict{"Verdict"}
+ Verdict -->|"Ok"| CloseOk["Start, stop, and clear inspection job"]
+ CloseOk --> Next["StoreAndStart inspection order"]
+ Verdict -->|"NotOk"| CloseBad["Start, stop, and clear inspection job"]
+ CloseBad --> Reject["StoreAndStart rework/reject order"]
+ Verdict -->|"NotDecidable"| Hold["Hold for operator"]
+```
+
+The important separation is that quality outcome and job execution state are
+separate facts. A defective part does not mean the inspection job failed.
+
+| Verdict | Inspection job | Next job |
+|---|---|---|
+| `Ok` | complete, close | schedule next inspection |
+| `NotOk` | complete, close | schedule rework/reject order |
+| `NotDecidable` | hold | none until the operator answers |
+
+Scheduling selects an order from a fixed allowlisted catalogue and calls V2
+`StoreAndStart`. `InspectionJobControlProvider` accepts only
+`VIS-INSP-BRACKET-001` and `VIS-REWORK-REJECT-001`; the agent never invents a
+job payload.
+
+### Escalation and ground truth
+
+`NotDecidable` activates the human path. The design dispositions are
+`AcceptAsOk`, `AcceptAsNotOk`, `Reinspect`, and `Stop`. The implementation maps
+those dispositions onto the dialog response and a bounded timeout: it holds or
+stops, but it does not auto-approve and does not block forever.
+
+```mermaid
+sequenceDiagram
+ participant Agent as VisualInspectionAgent
+ participant Vision as Vision Feedback
+ participant Dialog as Operator Dialog
+ participant Operator as Human Operator
+ participant AI as AI Learning Job
+
+ Agent->>Vision: SubmitInspectionResult NotDecidable
+ Agent->>Dialog: Wait for disposition
+ Dialog->>Operator: Request Accept, Reinspect, Reject, or Stop
+ Operator-->>Dialog: Disposition
+ Dialog-->>Agent: Response or timeout
+ alt Accept as ground truth
+ Agent->>Vision: SubmitCorrection GroundTruthLabel
+ Vision->>AI: RecordLearningSampleAsync
+ AI-->>Vision: Idempotent count result
+ else Reinspect
+ Agent->>Vision: No correction
+ Agent->>Agent: Schedule inspection order
+ else Stop or timeout
+ Agent->>Agent: Hold or stop without approval
+ end
+```
+
+The operator answer becomes a Vision §9 ground-truth correction. The cell's
+feedback sink calls `AiNodeManager.RecordLearningSampleAsync` and uses a stable
+sample id, so a retry does not count the same label twice. A negative example is
+still a learning sample: it counts exactly once even when it carries no geometry.
+
+This closes a limitation called out in the [Vision developer guide](Vision.md#limitations):
+`SamplesCollected` belongs to the AI companion's `LearningJobType`, while Vision
+only names the learning job by `NodeId`. A standalone Vision node manager cannot
+increment a counter owned by another companion. A host binding Vision and AI
+Model Management together can, and this cell is that host. See also the
+[AI Model Management developer guide](AiIntegration.md) for the deployment
+and learning-job model.
+
+### Modes
+
+`VisualInspectionAgent` supports three modes:
+
+- `scripted` — deterministic analyser, scripted operator policy, and a finite
+ `--cycles N`. This is the unattended path.
+- `live-ai` — a real model path. It requires `--ai-endpoint`; if no endpoint is
+ configured, the agent exits before creating any job and never silently falls
+ back to the simulated analyser.
+- `human` — a real dialog subscriber path with a bounded
+ `--operator-timeout`.
+
+The no-silent-fallback rule is part of the sample's safety story. A sample that
+quietly degrades can look green while proving neither model connectivity nor the
+provenance path it claims to demonstrate.
+
+### What is deliberately not implemented
+
+The sample does not implement retraining or model promotion. It records learning
+samples honestly and increments the AI learning-job count, but it does not fake
+an MLOps workflow. A simulated retraining integration that appeared to work
+would mislead readers about the one part of the specification a sample cannot
+honestly demonstrate. The [AI Model Management sample](../samples/AI/README.md)
+takes the same line.
+
+## See also
+
+- [Robotics developer guide](Robotics.md) — the sibling companion
+ implementation Vision composes with; the `BinPickingCell` sample is
+ the cross-companion example.
+- [OpenUSD guide](OpenUsd.md) — the OpenUSD connector and scene
+ materialisation used to bind the Vision sample cell to a live USD
+ stage.
+- [MCP Server guide](McpServer.md) — the `vision` MCP profile and its
+ composition with `robotics`.
+- [AI Model Management developer guide](AiIntegration.md) — the
+ companion that owns the model, dataset and deployment a Vision
+ pipeline's `Deployment` and `LearningJob` point at, and the counter
+ §9.4 asks a learning job to keep.
+- [Dependency Injection](DependencyInjection.md) — the `AddOpcUa()`
+ builder surface that hosts `AddVision`, `AddVisionClient`, and every
+ other component.
\ No newline at end of file
diff --git a/docs/WhatsNewIn2.0.md b/docs/WhatsNewIn2.0.md
index ade36ef101..5918e9802e 100644
--- a/docs/WhatsNewIn2.0.md
+++ b/docs/WhatsNewIn2.0.md
@@ -194,6 +194,22 @@ server- and client-side implementations:
gears, drives, safety states, and task controls with the correct
companion-spec references. See [Robotics](Robotics.md), including the draft
Robot Intent task-level command model.
+- **OPC UA — Vision** (draft): the `Opc.Ua.Vision` /
+ `Opc.Ua.Vision.Server` / `Opc.Ua.Vision.Client` /
+ `Opc.Ua.Vision.OpenUsd` package family, with a source-generated Vision model,
+ `AddVision` / `ConfigureVision` hosting, media/inference/feedback providers,
+ fluent frame/sensor/calibration/pipeline builders, typed `VisionClient`
+ discovery, result streaming, off-server feedback, facet derivation, and
+ OpenUSD camera capture. See [Vision](Vision.md), including the Robotics +
+ Vision bin-picking example.
+- **OPC UA — AI Model Management and Inference** (draft): the `Opc.Ua.AI` /
+ `Opc.Ua.AI.Inference` / `Opc.Ua.AI.Server` / `Opc.Ua.AI.Client` package
+ family over xRegistry, with a source-generated catalogue/deployment/inference
+ model, the `IInferenceBackend` contract, `Microsoft.Extensions.AI`
+ `IChatClient` and OpenAI-compatible REST backends, `AINodeManagerFactory`
+ hosting through `AddNodeManager`, `Invoke` routing,
+ learning jobs, and standard file-transfer artefact streaming. See
+ [AI Model Management](AiIntegration.md).
- **OPC 10100-1 — WoT Connectivity**: model, server, and client libraries
for surfacing OPC UA servers as Web of Things Thing Descriptions, with
the `WoTAssetConnectionManagement` server methods gated by a
@@ -509,6 +525,8 @@ coverage service; see
[Alias Names](AliasNames.md),
[Device Integration](DeviceIntegration.md),
[Relative Spatial Location and Global Positioning](Positioning.md),
+ [Vision](Vision.md),
+ [AI Model Management](AiIntegration.md),
[Software Update](SoftwareUpdate.md),
[WoT Connectivity](WoTConnectivity.md),
[Subscriptions and Monitored Items](Subscriptions.md),
diff --git a/docs/migrate/2.0.x/packages.md b/docs/migrate/2.0.x/packages.md
index f7d604e7b6..3df2c7f4bb 100644
--- a/docs/migrate/2.0.x/packages.md
+++ b/docs/migrate/2.0.x/packages.md
@@ -33,6 +33,7 @@ The minimum SDK is the **.NET 10 SDK**, and projects compile with **`LangVersion
| `Microsoft.CodeAnalysis.Analyzers` 4.14.0 | Added (pinned) | Centralised pin only, no direct reference; holds the analyzer closure on the `roslyn.props` band |
| `Microsoft.CodeAnalysis.Common` 5.0.0 | Added | `tools/SourceGeneratorVariant.targets`, `tools/MigrationAnalyzerVariant.targets` |
| `Microsoft.CodeAnalysis.CSharp` 5.0.0 | Added | `tools/SourceGeneratorVariant.targets`, `tools/MigrationAnalyzerVariant.targets` |
+| `Microsoft.Extensions.Caching.Abstractions` 10.0.10 | Added (pinned) | Introduced as a transitive dependency by the ModelContextProtocol 2.x SDK |
| `Microsoft.Extensions.Configuration.Abstractions` 10.0.10 | Added | `src/Opc.Ua.Client.ComplexTypes`, `src/Opc.Ua.PubSub` |
| `Microsoft.Extensions.Diagnostics` 10.0.10 | Added | `src/Opc.Ua.Core/Opc.Ua.Core.csproj` |
| `Microsoft.Extensions.Hosting` 10.0.10 | Added | Samples and tools that host a server or client |
diff --git a/samples/AI/ModelManagementClient/AiScenarioRunner.cs b/samples/AI/ModelManagementClient/AiScenarioRunner.cs
new file mode 100644
index 0000000000..bf28dde757
--- /dev/null
+++ b/samples/AI/ModelManagementClient/AiScenarioRunner.cs
@@ -0,0 +1,290 @@
+/* ========================================================================
+ * 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.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua;
+using Opc.Ua.AI.Client;
+using Opc.Ua.Client;
+
+namespace Opc.Ua.AI.Client
+{
+ internal sealed class AIScenarioRunner
+ {
+ private AIScenarioRunner(AIClient client)
+ {
+ m_client = client;
+ }
+
+ public static AIScenarioRunner? TryCreate(ISession session, ITelemetryContext telemetry)
+ {
+ var client = new AIClient(session, telemetry);
+ return client.IsAINamespaceAvailable ? new AIScenarioRunner(client) : null;
+ }
+
+ public async Task RunAsync(CancellationToken ct)
+ {
+ Console.WriteLine("AI root: {0}", m_client.AIRootId);
+ Console.WriteLine();
+ Console.WriteLine("--- model catalogue");
+ await ReportModelsAsync(ct).ConfigureAwait(false);
+
+ ArrayOf deployments = await m_client.DiscoverDeploymentsAsync(ct)
+ .ConfigureAwait(false);
+ if (deployments.Count == 0)
+ {
+ Console.WriteLine("No deployments are published.");
+ return;
+ }
+
+ for (int ii = 0; ii < deployments.Count; ii++)
+ {
+ await DescribeDeploymentAsync(deployments[ii], ct).ConfigureAwait(false);
+ }
+
+ AIDeploymentClient deployment = m_client.Deployment(deployments[0]);
+ await RunCapabilitiesAsync(deployment, ct).ConfigureAwait(false);
+ await RunInlineInferenceAsync(deployment, ct).ConfigureAwait(false);
+ await RunTransferAsync(deployment, ct).ConfigureAwait(false);
+ await RunAsynchronousInferenceAsync(deployment, ct).ConfigureAwait(false);
+ await RunSourceAsync(ct).ConfigureAwait(false);
+ }
+
+ private async Task ReportModelsAsync(CancellationToken ct)
+ {
+ await foreach (AINodeEntry entry in m_client.EnumerateModelsAsync(ct).ConfigureAwait(false))
+ {
+ AIModelSnapshot model = await m_client.Model(entry.NodeId).ReadAsync(ct)
+ .ConfigureAwait(false);
+ Console.WriteLine(
+ " {0} {1} {2} ({3})",
+ model.ModelId,
+ model.Name,
+ model.Version,
+ model.NodeId);
+ }
+ }
+
+ private async Task DescribeDeploymentAsync(NodeId deploymentNodeId, CancellationToken ct)
+ {
+ AIDeploymentClient deployment = m_client.Deployment(deploymentNodeId);
+ AIDeploymentSnapshot snapshot = await deployment.ReadAsync(ct).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("--- deployment {0}", snapshot.NodeId);
+ Console.WriteLine(" DeploymentId {0}", snapshot.DeploymentId);
+ Console.WriteLine(" InferenceLocation {0}", snapshot.InferenceLocation);
+ Console.WriteLine(" State {0}", snapshot.State);
+ Console.WriteLine(" DataJurisdiction {0}", snapshot.DataJurisdiction);
+ Console.WriteLine(" EgressPermitted {0}", snapshot.EgressPermitted);
+ Console.WriteLine(" MaxInlinePayloadSize {0}", snapshot.MaxInlinePayloadSize);
+ Console.WriteLine(" EndpointUri {0}", snapshot.EndpointUri);
+
+ AIModelClient? model = await deployment.OpenModelAsync(ct).ConfigureAwait(false);
+ if (model is not null)
+ {
+ AIModelSnapshot modelSnapshot = await model.ReadAsync(ct).ConfigureAwait(false);
+ Console.WriteLine(
+ " uses model {0} ({1})",
+ modelSnapshot.ModelId,
+ modelSnapshot.NodeId);
+ Console.WriteLine(
+ " digest {0}",
+ modelSnapshot.Digest.Length > 0
+ ? Convert.ToHexString(modelSnapshot.Digest.Span)
+ : "(none declared)");
+ }
+
+ if (!snapshot.FallbackDeploymentId.IsNull)
+ {
+ Console.WriteLine(" falls back to {0}", snapshot.FallbackDeploymentId);
+ }
+ }
+
+ private static async Task RunCapabilitiesAsync(AIDeploymentClient deployment, CancellationToken ct)
+ {
+ Console.WriteLine();
+ Console.WriteLine("--- GetCapabilities");
+ ArrayOf capabilities = await deployment.GetCapabilitiesAsync(ct)
+ .ConfigureAwait(false);
+ for (int ii = 0; ii < capabilities.Count; ii++)
+ {
+ Console.WriteLine(
+ " {0}: {1}",
+ capabilities[ii].Name,
+ capabilities[ii].Supported);
+ }
+ }
+
+ private static async Task RunInlineInferenceAsync(AIDeploymentClient deployment, CancellationToken ct)
+ {
+ Console.WriteLine();
+ Console.WriteLine("--- Invoke");
+ ByteString payload = ByteString.From(Encoding.UTF8.GetBytes(
+ "{\"messages\":[{\"role\":\"user\",\"content\":\"Summarise the last shift.\"}]}"));
+ AIInvokeResult result = await deployment.InvokeAsync(
+ payload,
+ "application/json",
+ ArrayOf.Empty,
+ 5000,
+ cancellationToken: ct).ConfigureAwait(false);
+ ReportInvokeOutputs(result);
+ }
+
+ private async Task RunTransferAsync(AIDeploymentClient deployment, CancellationToken ct)
+ {
+ Console.WriteLine();
+ Console.WriteLine("--- BeginTransfer");
+ ByteString payload = ByteString.From(Encoding.UTF8.GetBytes(
+ "{\"messages\":[{\"role\":\"user\",\"content\":\"" +
+ new string('x', 4096) +
+ "\"}]}"));
+ AIBeginTransferResult begun = await deployment.BeginTransferAsync(
+ "application/json", (ulong)payload.Length, ct).ConfigureAwait(false);
+ if (!begun.Accepted)
+ {
+ Console.WriteLine(" refused");
+ return;
+ }
+ Console.WriteLine(" transfer {0}", begun.TransferId);
+
+ AIInferenceTransferClient transfer = m_client.Transfer(begun.TransferId);
+ await transfer.WriteRequestAsync(payload, cancellationToken: ct).ConfigureAwait(false);
+ bool accepted = await transfer.ExecuteAsync(ct).ConfigureAwait(false);
+ AITransferSnapshot snapshot = await transfer.ReadAsync(ct).ConfigureAwait(false);
+ Console.WriteLine(" accepted {0}", accepted);
+ Console.WriteLine(" state {0}", snapshot.State);
+ Console.WriteLine(" ModelUsed {0}", snapshot.ModelUsed);
+
+ ByteString answer = await transfer.ReadResponseAsync(cancellationToken: ct)
+ .ConfigureAwait(false);
+ if (answer.Length > 0)
+ {
+ Console.WriteLine(" response {0}", Encoding.UTF8.GetString(answer.Span));
+ }
+ }
+
+ private async Task RunAsynchronousInferenceAsync(AIDeploymentClient deployment, CancellationToken ct)
+ {
+ Console.WriteLine();
+ Console.WriteLine("--- InvokeAsync");
+ ByteString payload = ByteString.From(Encoding.UTF8.GetBytes(
+ "{\"messages\":[{\"role\":\"user\",\"content\":\"Explain the trend.\"}]}"));
+ NodeId jobId = await deployment.InvokeAsyncAsync(
+ payload,
+ "application/json",
+ ArrayOf.Empty,
+ cancellationToken: ct).ConfigureAwait(false);
+ if (jobId.IsNull)
+ {
+ return;
+ }
+ Console.WriteLine(" job {0}", jobId);
+
+ AIInferenceJobClient job = m_client.InferenceJob(jobId);
+ AIInferenceJobSnapshot snapshot = new();
+ for (int attempt = 0; attempt < 50; attempt++)
+ {
+ snapshot = await job.ReadAsync(ct).ConfigureAwait(false);
+ if (snapshot.ResponsePayload.Length > 0 || !snapshot.ModelUsed.IsNull)
+ {
+ break;
+ }
+ await Task.Delay(200, ct).ConfigureAwait(false);
+ }
+ if (snapshot.ResponsePayload.Length > 0)
+ {
+ Console.WriteLine(" Response {0}", Encoding.UTF8.GetString(snapshot.ResponsePayload.Span));
+ }
+ Console.WriteLine(" ModelUsed {0}", snapshot.ModelUsed);
+ Console.WriteLine(" FinishReason {0}", snapshot.FinishReason);
+ }
+
+ private async Task RunSourceAsync(CancellationToken ct)
+ {
+ ArrayOf sourceIds = await m_client.DiscoverSourcesAsync(ct).ConfigureAwait(false);
+ if (sourceIds.Count == 0)
+ {
+ return;
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("--- model source");
+ AIModelSourceClient source = m_client.Source(sourceIds[0]);
+ AIModelSourceSnapshot snapshot = await source.ReadAsync(ct).ConfigureAwait(false);
+ Console.WriteLine(" SourceId {0}", snapshot.SourceId);
+ Console.WriteLine(" EndpointUri {0}", snapshot.EndpointUri);
+ Console.WriteLine(" ApiDialect {0}", snapshot.ApiDialect);
+ Console.WriteLine(" AuthenticationKind {0}", snapshot.AuthenticationKind);
+ Console.WriteLine(" CredentialReference {0}", snapshot.CredentialReference);
+
+ AISourceConnectionResult connection = await source.TestConnectionAsync(ct)
+ .ConfigureAwait(false);
+ Console.WriteLine(" reachable {0} ({1})", connection.Reachable, connection.Detail);
+
+ AISourceModelListResult models = await source.ListModelsAsync(maxResults: 20, cancellationToken: ct)
+ .ConfigureAwait(false);
+ for (int ii = 0; ii < models.Models.Count; ii++)
+ {
+ ModelReferenceDataType model = models.Models[ii];
+ Console.WriteLine(
+ " offers {0}/{1}/{2}",
+ model.Publisher,
+ model.Name,
+ model.Version);
+ }
+ }
+
+ private static void ReportInvokeOutputs(AIInvokeResult result)
+ {
+ if (result.ResponsePayload.Length > 0)
+ {
+ Console.WriteLine(" response {0}", Encoding.UTF8.GetString(result.ResponsePayload.Span));
+ }
+ Console.WriteLine(" ModelUsed {0}", result.ModelUsed);
+ if (result.Usage is not null)
+ {
+ Console.WriteLine(
+ " Usage {0} in, {1} out {2}",
+ result.Usage.InputUnits,
+ result.Usage.OutputUnits,
+ result.Usage.UnitKind);
+ }
+ Console.WriteLine(" FinishReason {0}", result.FinishReason);
+ if (result.TransferRequired)
+ {
+ Console.WriteLine(" payload too large; transfer at {0}", result.TransferId);
+ }
+ }
+
+ private readonly AIClient m_client;
+ }
+}
diff --git a/samples/AI/ModelManagementClient/AssemblyInfo.cs b/samples/AI/ModelManagementClient/AssemblyInfo.cs
new file mode 100644
index 0000000000..bb6d2173b8
--- /dev/null
+++ b/samples/AI/ModelManagementClient/AssemblyInfo.cs
@@ -0,0 +1,33 @@
+/* ========================================================================
+ * 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;
+
+// The OPC UA surface this builds on is not CLS compliant.
+[assembly: CLSCompliant(false)]
diff --git a/samples/AI/ModelManagementClient/ModelManagementClient.csproj b/samples/AI/ModelManagementClient/ModelManagementClient.csproj
new file mode 100644
index 0000000000..55f96c092b
--- /dev/null
+++ b/samples/AI/ModelManagementClient/ModelManagementClient.csproj
@@ -0,0 +1,26 @@
+
+
+ Exe
+ ModelManagementClient
+
+ net10.0;net9.0;net8.0
+ $(CustomTestTarget)
+ net10.0
+ true ModelManagement.Client
+ $(NoWarn);CS1591;CS0108
+ enable
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AI/ModelManagementClient/Program.cs b/samples/AI/ModelManagementClient/Program.cs
new file mode 100644
index 0000000000..d4365506fe
--- /dev/null
+++ b/samples/AI/ModelManagementClient/Program.cs
@@ -0,0 +1,136 @@
+/* ========================================================================
+ * 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.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.AI.Client;
+using Opc.Ua.Client;
+
+try
+{
+ string endpoint = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
+ ? args[0]
+ : "opc.tcp://localhost:62640/ModelManagementServer";
+
+ bool insecure = Array.IndexOf(args, "--insecure") >= 0;
+
+ Console.WriteLine("OPC UA AI Model Management client");
+ Console.WriteLine("Endpoint: {0}", endpoint);
+
+ if (insecure)
+ {
+ Console.Error.WriteLine(
+ "WARNING: --insecure selects an endpoint without message security.");
+ }
+
+ Console.WriteLine();
+
+ HostApplicationBuilder builder = Host.CreateApplicationBuilder();
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConsole();
+ builder.Logging.SetMinimumLevel(LogLevel.Warning);
+
+ builder.Services
+ .AddOpcUa()
+ .AddClient(options =>
+ {
+ const string applicationName = "ModelManagementClient";
+ options.ApplicationName = applicationName;
+ options.ApplicationUri = "urn:localhost:OPCFoundation:ModelManagementClient";
+ options.ProductUri = "uri:opcfoundation.org:ModelManagementClient";
+ options.PkiRoot = Path.Combine(
+ Path.GetTempPath(), "OPC Foundation", applicationName, "pki");
+ // Sample convenience only; never auto-accept in production.
+ options.AutoAcceptUntrustedCertificates = true;
+ options.RejectSHA1SignedCertificates = true;
+ options.MinimumCertificateKeySize = 2048;
+ options.Session = new ManagedSessionOptions
+ {
+ SessionName = applicationName,
+ SessionTimeout = TimeSpan.FromSeconds(60)
+ };
+ })
+ .AddDiscoveryAndConnect(options =>
+ {
+ options.DiscoveryUrl = endpoint;
+ options.SecurityMode = insecure
+ ? MessageSecurityMode.None
+ : MessageSecurityMode.SignAndEncrypt;
+ options.SecurityPolicyUri = insecure
+ ? SecurityPolicies.None
+ : SecurityPolicies.Basic256Sha256;
+ })
+ .AddAIClient();
+
+ using IHost host = builder.Build();
+ await host.StartAsync(CancellationToken.None).ConfigureAwait(false);
+
+ try
+ {
+ Func> connect = host.Services
+ .GetRequiredService>>();
+
+ ManagedSession session = await connect(CancellationToken.None).ConfigureAwait(false);
+
+ await using (session)
+ {
+ Console.WriteLine("Connected.");
+ Console.WriteLine();
+
+ ITelemetryContext telemetry = host.Services.GetRequiredService();
+ AIScenarioRunner? runner = AIScenarioRunner.TryCreate(session, telemetry);
+
+ if (runner is null)
+ {
+ Console.Error.WriteLine(
+ "This Server does not implement OPC UA - AI Model Management.");
+ return 2;
+ }
+
+ await runner.RunAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ await host.StopAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ return 0;
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine(ex);
+ return 1;
+}
diff --git a/samples/AI/ModelManagementServer/AssemblyInfo.cs b/samples/AI/ModelManagementServer/AssemblyInfo.cs
new file mode 100644
index 0000000000..be83f31ace
--- /dev/null
+++ b/samples/AI/ModelManagementServer/AssemblyInfo.cs
@@ -0,0 +1,35 @@
+/* ========================================================================
+ * 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;
+
+// The OPC UA stack surface this builds on is not CLS compliant (unsigned
+// integers appear throughout the specification's own data types), so claiming
+// compliance here would be false.
+[assembly: CLSCompliant(false)]
diff --git a/samples/AI/ModelManagementServer/Dockerfile b/samples/AI/ModelManagementServer/Dockerfile
new file mode 100644
index 0000000000..249a579055
--- /dev/null
+++ b/samples/AI/ModelManagementServer/Dockerfile
@@ -0,0 +1,108 @@
+# ModelManagementServer container image.
+#
+# Multi-stage, framework-dependent build on the .NET AzureLinux 3 base
+# images, following samples/DI/PumpDeviceIntegrationServer/Dockerfile.
+#
+# IMPORTANT: the build context MUST be the repository ROOT (not this
+# folder), because the project pulls in the whole source tree. Build it
+# like this:
+#
+# # from the repository root:
+# docker build -f samples/AI/ModelManagementServer/Dockerfile \
+# -t modelmanagementserver:local .
+#
+# The final image runs as a non-root user and listens on 0.0.0.0:62640
+# (override host/port via the `host` / `port` environment variables).
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0-azurelinux3.0 AS build
+WORKDIR /src
+
+# JIT is disabled for the source generators unless W^X is turned off on
+# some kernels; match the existing sample Dockerfiles.
+ENV DOTNET_EnableWriteXorExecute=0
+
+# Copy the full repository. The project pulls in many transitive
+# ProjectReferences, a source-generator analyzer, and an AdditionalFiles
+# NodeSet2 XML input, so a per-csproj copy list would be fragile.
+COPY . .
+
+# Fail fast with a clear message when the build context is not the
+# repository root (UA.slnx is the root marker).
+RUN test -f UA.slnx || { \
+ echo ''; \
+ echo 'ERROR: build the repository ROOT as the Docker context, not this folder.'; \
+ echo ' The image needs the full source tree (src/, tools/).'; \
+ echo ''; \
+ echo ' From the repository root run:'; \
+ echo ' docker build -f samples/AI/ModelManagementServer/Dockerfile -t modelmanagementserver .'; \
+ echo ''; \
+ exit 1; \
+ }
+
+# The project imports use lower-case tools/, but the repository directory is
+# Tools/. Windows hides the difference; the Linux container does not.
+RUN if [ ! -d tools ] && [ -d Tools ]; then ln -s Tools tools; fi
+
+# MSBuild in the Linux SDK needs a Unix separator and stable logical names here
+# so the source generator embeds the design resources it reads during publish.
+RUN sed -i 's###' \
+ Tools/Opc.Ua.SourceGeneration.Core/Opc.Ua.SourceGeneration.Core.csproj
+
+ARG Version
+ARG SimpleVersion
+ARG InformationalVersion
+
+RUN dotnet restore "samples/AI/ModelManagementServer/ModelManagementServer.csproj" \
+ --force -p:NoHttps=true
+
+# PublishAot is off for this sample. The workload-identity credential path
+# resolves tokens through a library that serialises by reflection, which the
+# AOT analyzer warns on and this repository builds warnings as errors. Nothing
+# in the OPC UA surface needs AOT; see the sample README.
+RUN dotnet publish "samples/AI/ModelManagementServer/ModelManagementServer.csproj" \
+ --no-restore \
+ -c Release \
+ -f net10.0 \
+ -p:PublishAot=false \
+ -p:NoHttps=true \
+ -p:Dockerbuild=true \
+ -p:Version=${Version} \
+ -p:AssemblyVersion=${SimpleVersion} \
+ -p:FileVersion=${Version} \
+ -p:InformationalVersion=${InformationalVersion} \
+ -o /app/publish
+
+FROM mcr.microsoft.com/dotnet/runtime:10.0-azurelinux3.0 AS final
+
+# AzureLinux forces the SymCrypt OpenSSL provider as the default for all
+# EVP operations. SymCrypt's ECDSA SignHash path rejects the self-signing
+# call the OPC UA stack makes when creating its ECC application-instance
+# certificate. Clearing the forced default lets the standard OpenSSL
+# default provider handle ECDSA.
+RUN sed -i 's/^default_properties = .*/default_properties = ""/' /etc/pki/tls/openssl.cnf
+
+WORKDIR /app
+COPY --from=build /app/publish .
+
+# The .NET base images ship a predefined non-root user `app` (UID 1654,
+# exposed via APP_UID). The Server creates its certificate store under
+# /app at runtime.
+#
+# /var/run/secrets/ai is where a mounted credential is read from. It is
+# created empty so the anonymous path works without a Secret, and so the
+# directory permissions are right when one is mounted.
+ENV HOME=/app
+RUN mkdir -p /app/pki /var/run/secrets/ai \
+ && chown -R $APP_UID:0 /app /var/run/secrets/ai \
+ && chmod -R g=u /app /var/run/secrets/ai
+
+USER $APP_UID
+
+# Bind to all interfaces so the endpoint is reachable from outside the
+# container. Override with `-e host=...` / `-e port=...`.
+ENV host=0.0.0.0 \
+ port=62640
+
+EXPOSE 62640
+
+ENTRYPOINT ["dotnet", "ModelManagementServer.dll"]
diff --git a/samples/AI/ModelManagementServer/ModelManagementServer.csproj b/samples/AI/ModelManagementServer/ModelManagementServer.csproj
new file mode 100644
index 0000000000..6a629e6ee0
--- /dev/null
+++ b/samples/AI/ModelManagementServer/ModelManagementServer.csproj
@@ -0,0 +1,30 @@
+
+
+ Exe
+ ModelManagementServer
+
+ net10.0;net9.0;net8.0
+ $(CustomTestTarget)
+ net10.0
+ true ModelManagement.Server
+ $(NoWarn);CS1591;CS0108
+ enable
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AI/ModelManagementServer/Program.cs b/samples/AI/ModelManagementServer/Program.cs
new file mode 100644
index 0000000000..4a0da2cd92
--- /dev/null
+++ b/samples/AI/ModelManagementServer/Program.cs
@@ -0,0 +1,81 @@
+/* ========================================================================
+ * 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.IO;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua.AI.Inference;
+using Opc.Ua.AI.Server;
+using Opc.Ua.AI.Server.Hosting;
+using Opc.Ua.Server.Fluent;
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+
+builder.Logging.ClearProviders();
+builder.Logging.AddConsole();
+
+int port = int.TryParse(builder.Configuration["port"], out int p) ? p : 62640;
+
+// 0.0.0.0 so the Server is reachable from outside a container. Override with
+// --host for local-only development.
+string host = builder.Configuration["host"] is { Length: > 0 } h ? h : "0.0.0.0";
+
+builder.Services.AddRestChatCompletionsAIChatClientFactory();
+
+// InferenceBackend:Kind defaults to ChatClient, the Microsoft.Extensions.AI
+// path. Set it to RestChatCompletions only for endpoints where the host cannot
+// supply an IChatClient and the OpenAI-compatible REST contract is the wire
+// contract itself.
+builder.Services
+ .AddOpcUa()
+ .AddServer(o =>
+ {
+ o.ApplicationName = "ModelManagementServer";
+ o.ApplicationUri = "urn:localhost:OPCFoundation:ModelManagementServer";
+ o.ProductUri = "uri:opcfoundation.org:ModelManagementServer";
+ // Sample convenience only; never auto-accept untrusted certificates in
+ // production.
+ o.AutoAcceptUntrustedCertificates = true;
+ o.PkiRoot = Path.Combine(AppContext.BaseDirectory, "pki");
+ o.RejectSHA1Certificates = true;
+ o.MinCertificateKeySize = 2048;
+ o.EndpointUrls.Add($"opc.tcp://{host}:{port}/ModelManagementServer");
+ })
+ .AddAI(
+ ai => builder.Configuration.GetSection(AIOptions.SectionName).Bind(ai),
+ backend => builder.Configuration.GetSection(InferenceBackendOptions.SectionName).Bind(backend),
+ fallback => builder.Configuration
+ .GetSection(InferenceBackendOptions.FallbackSectionName)
+ .Bind(fallback));
+
+await builder.Build().RunAsync().ConfigureAwait(false);
+return 0;
diff --git a/samples/AI/README.md b/samples/AI/README.md
new file mode 100644
index 0000000000..04cddb1de6
--- /dev/null
+++ b/samples/AI/README.md
@@ -0,0 +1,282 @@
+# AI Model Management sample
+
+An OPC UA Server that exposes AI models — hosted somewhere else, or running on
+the machine — through the draft companion specification *OPC UA — AI Model
+Management and Inference*. Plus a client that exercises it, and a Helm chart
+that runs it on Kubernetes.
+
+The point of the sample is the claim the specification makes: **where inference
+runs should not change how it is called.** A client discovers a deployment,
+calls `Invoke`, and gets an answer together with the identity of the model that
+produced it — whether that model was a hosted service in another jurisdiction or
+a quantized copy on the same box.
+
+> The specification is a **draft**. Namespace URIs and NodeIds are provisional
+> and nothing here is endorsed by the OPC Foundation.
+
+## What is in here
+
+The reusable parts live under `src/` as the `Opc.Ua.AI` package family, in the
+same shape as the Robotics and Vision families: a model assembly, a server-side
+assembly, a client-side assembly, and the inference backends. What remains here
+is the two sample applications that compose them.
+
+| | |
+|---|---|
+| `../../src/Opc.Ua.AI/` | The companion specification's NodeSet, source-generated at build time |
+| `../../src/Opc.Ua.AI.Inference/` | Reaching a model: the `IInferenceBackend` contract, a `Microsoft.Extensions.AI` `IChatClient` backend, an OpenAI-compatible REST backend, credential resolution |
+| `../../src/Opc.Ua.AI.Server/` | The node manager: publishes the address space and serves the specification's Methods |
+| `../../src/Opc.Ua.AI.Client/` | Discovery, typed reads, Method calls and artefact transfer |
+| `ModelManagementServer/` | The Server sample. Hosts the node manager and picks a backend |
+| `ModelManagementClient/` | A console client that browses from the entry point and exercises what it finds |
+| `deploy/helm/` | The chart, its tests, and a cluster smoke test |
+| `../../tests/Opc.Ua.AI.Tests/` | Unit and integration tests against a fake backend |
+
+**No cloud-vendor SDK is referenced anywhere.** The default backend goes
+through `Microsoft.Extensions.AI`, which a hosted service and an on-device
+runtime both implement, and workload identity is read from the token the
+platform projects — the mechanism every platform implements underneath its own
+SDK. A sample that only ran against one vendor would not be demonstrating a
+platform-independent Server. `RestChatCompletionsBackend` remains available
+when the endpoint only exposes the OpenAI-compatible REST contract and the host
+cannot supply an `IChatClient`; set `InferenceBackend:Kind` (or
+`FallbackInferenceBackend:Kind`) to `RestChatCompletions` for that wire
+contract.
+
+## Running it
+
+The Server needs something to infer with. The quickest is the throwaway endpoint
+in `verify_backend.py`, which speaks just enough of the OpenAI-compatible
+contract to answer:
+
+```powershell
+python samples/AI/verify_backend.py 5273
+```
+
+Then, in two more terminals:
+
+```powershell
+dotnet run --project samples/AI/ModelManagementServer
+dotnet run --project samples/AI/ModelManagementClient
+```
+
+The client browses to the AI root under the Server Object, walks every
+deployment, follows `UsesModel` to the model and its digest, and then calls
+`GetCapabilities`, `Invoke`, `BeginTransfer`, `InvokeAsync` and the source's
+`TestConnection` and `ListModels`.
+
+Point it at a real endpoint by configuration:
+
+```powershell
+$env:InferenceBackend__Kind = "ChatClient"
+$env:InferenceBackend__EndpointUri = "https://.services.ai.azure.com/openai/"
+$env:InferenceBackend__Authentication = "ApiKey"
+$env:InferenceBackend__CredentialReference = "inference-api-key"
+$env:InferenceBackend__Site = "Cloud"
+$env:InferenceBackend__EgressPermitted = "true"
+```
+
+`ChatClient` is the default. This sample registers a small `IChatClient` over
+the OpenAI-compatible endpoint so no vendor package is needed. A production
+host can replace `IChatClientFactory` with one that creates the chat client
+from its own SDK or local runtime. Use `RestChatCompletions` for deployments
+whose contract is the REST shape itself rather than the
+`Microsoft.Extensions.AI` abstraction.
+
+## The control plane is OPC UA
+
+There is no second management API, and that is a decision rather than an
+omission. The specification defines Methods for the things an operator needs to
+do — test a source, list what it offers, invoke a model, start a job, promote a
+candidate — so adding an HTTP surface beside them would create two ways to do
+the same thing that could disagree.
+
+Everything else is startup configuration: which endpoint to reach, which
+deployments to publish, and which credential to present.
+
+## What the address space says, and why
+
+A few members are worth reading carefully, because they answer questions that
+are asked *before* a call rather than after one.
+
+**`ModelUsed`** names the model that actually produced a result. It exists for
+the fallback case: a caller that cannot see which model answered cannot tell a
+degraded answer from a good one, and a fallback that answers silently looks
+exactly like a healthy primary. This is the single thing in the sample most
+worth getting right, and the one the tests press hardest on.
+
+**`EgressPermitted`, `DataJurisdiction`, `RetainsInput`** say where the data
+goes. Egress is not made false by encryption — that answers who can read data in
+flight, not where the data went.
+
+**`MaxInlinePayloadSize`** is published before a client calls, rather than
+discovered from a rejection, because the real bound is the smallest of several
+limits a client can see none of.
+
+**`CredentialReference`** names the credential; it never carries one. A client
+is entitled to know *which* credential is configured so it can tell whether the
+right one is. A client that could read the value could use it.
+
+**`Digest`** is empty when the backend declares none. A hosted endpoint that
+will not say which weights answered cannot be made to say so by hashing its
+name, and a digest that looks like an artefact digest but is not one is worse
+than none, because something will eventually compare it.
+
+## What is real and what is not
+
+Being clear about this matters more than it looks, because a sample is the thing
+people copy.
+
+**Real.** The address space, the Methods, the provenance references, the
+chunked transfer over Part 5 `FileType`, the asynchronous job on the Part 10
+program lifecycle, the fallback and its reporting, the credential handling, and
+the HTTP client that reaches an OpenAI-compatible endpoint. The learning job
+node is also real: the Server publishes a `LearningJobType` instance, and
+`SamplesCollected` is incremented only when host-level code records a distinct
+ground-truth sample. Empty or retracted observations count the same way as
+samples carrying geometry. All of the inference pieces have been run end to end
+against a live endpoint, in a container, and in a Kubernetes cluster.
+
+**Not real.** There is no retraining loop. A sample cannot retrain a model, and
+`PromoteModel` is not wired to a simulated MLOps integration — faking candidate
+generation or timed promotion would mislead a reader about the one part of the
+specification a sample cannot honestly demonstrate.
+
+**A test double, not a provider.** `FakeInferenceBackend` lives in the test
+project and is not configurable from the Server. It exists so CI needs no
+inference service; if it shipped as a supported option the sample could look
+healthy while never having reached a model.
+
+## Kubernetes
+
+```powershell
+docker build -f samples/AI/ModelManagementServer/Dockerfile `
+ -t modelmanagementserver:local .
+
+helm install ai samples/AI/deploy/helm/ai-model-management `
+ --set image.repository=modelmanagementserver `
+ --set image.tag=local --set image.pullPolicy=Never
+```
+
+The defaults describe an on-device runtime on loopback with no credential,
+because that is the shape that installs and runs without anything else existing.
+`deploy/helm/values-cloud.yaml` is the hosted shape: a remote endpoint with a
+mounted credential, and a local fallback.
+
+**Supply the credential as a Secret you manage**, not through Helm:
+
+```powershell
+kubectl create secret generic inference-credentials `
+ --from-literal=inference-api-key=
+
+helm install ai ./ai-model-management -f values-cloud.yaml `
+ --set credentials.existingSecret=inference-credentials
+```
+
+`credentials.create` exists for local clusters and puts the value in the release
+history, where `helm get values` will show it. The chart says so when you use it.
+
+### The chart refuses some configurations
+
+Each refusal corresponds to a deployment that would come up green and describe
+itself wrongly, which is worse than one that fails to start because nobody
+investigates a healthy pod:
+
+- `ApiKey` authentication with no credential mounted.
+- A fallback deployment with no fallback endpoint — it would always fail.
+- A fallback pointing at the primary's endpoint — that is a retry, and it fails
+ for every reason the primary just failed for.
+- `EgressPermitted: false` with a backend that is not on the machine — the
+ Server would publish a promise it does not keep.
+
+### Probes
+
+The probes are TCP against the OPC UA port. That proves the listener accepts
+connections; it does **not** prove the Server is serving the address space. An
+exec probe running a real OPC UA client would prove it and costs a process
+launch every period. The trade is deliberate, and it is the reason the smoke
+test drives a real session rather than trusting readiness.
+
+### Testing the chart
+
+```powershell
+helm lint samples/AI/deploy/helm/ai-model-management
+python samples/AI/deploy/helm/chart_tests.py
+
+# slow, needs Docker and kind; creates and deletes a cluster
+pwsh samples/AI/deploy/helm/smoke-test.ps1
+```
+
+`chart_tests.py` renders the chart and asserts what the manifests must and must
+not say — including that a credential passed inline appears exactly once, inside
+the Secret it belongs in. Every refusal is asserted to fire, because a guardrail
+that never triggers is indistinguishable from one that does not work.
+
+`smoke-test.ps1` builds the image, creates a cluster, deploys a stub endpoint
+alongside, installs the chart, and opens a real OPC UA session from outside. It
+is the slowest and most fragile check here — image build, image load, chart
+install and a live session — so run it deliberately rather than letting it gate
+everything else.
+
+## Why AOT is disabled
+
+`PublishAot` is off for this sample. The workload-identity credential path
+resolves tokens through a library that serialises by reflection, which the AOT
+and trimming analyzers warn on, and this repository builds warnings as errors.
+Nothing in the OPC UA surface requires AOT, and disabling it for one sample is
+preferable to weakening the analyzer settings for the whole repository.
+
+## Notes for anyone extending this
+
+Six things cost real time to find. All six produce a Server that builds, starts,
+and is wrong in a way nothing reports:
+
+1. **Dynamic NodeIds must not be numeric here.** The model occupies
+ `ns=2;i=1001`…`ns=2;i=7001`, and a counter issuing numeric ids in the same
+ namespace walks straight into it. The predefined-node index takes the last
+ writer, so a Server that had served a few hundred transfers would quietly have
+ replaced `AiRootType` with an inference job's `FinishedAt` property. String
+ identifiers cannot collide with numeric ones at all, which is a stronger
+ guarantee than any seed value.
+
+2. **The NodeSet may already declare the entry point.** This one declares
+ `ns=2;i=7001` parented to the Server Object. Building another leaves two
+ Objects with the same BrowseName under the Server — one populated, one empty —
+ and which a client finds depends on browse order.
+
+3. **A NodeSet is not necessarily in supertype order.** NodeIds are assigned in
+ declaration order, so a model that gains an abstract base after its first
+ concrete subtype carries a higher NodeId for the base — and the type table
+ refuses a type whose supertype it has not seen.
+
+4. **Passing a parent to a `NodeState` constructor and then calling `AddChild`
+ leaves `ReferenceTypeId` null**, because `AddChild` only assigns it when the
+ parent actually changes. The node is indexed, readable by NodeId, and
+ invisible to `Browse`.
+
+5. **A child materialised by `CreateChild` is never initialised**, so a Method
+ has no `InputArguments`. It browses, accepts a call, and rejects it with
+ `BadTooManyArguments` whatever is passed. `FindChild` does not create at all,
+ so an optional Method reached that way is simply absent.
+
+6. **A member — or a folder — created after `AddPredefinedNode` is not
+ published.** The lazily created `Jobs` folder appeared in a Browse of the root
+ while returning `BadNodeIdUnknown` when browsed itself, because it was on the
+ NodeState tree and not in the index.
+
+The common thread is that none of them fails. Run a real client against a real
+Server before believing a green build — every one of these was found that way,
+and three of them only after a review agent browsed the running Server.
+
+### Concurrency
+
+Method calls are **not** serialised by the Server, so two handlers can run at
+once on the same node. Two consequences worth knowing:
+
+- The transfer buffers are owned by `StreamFileManager` and reached only through
+ `Snapshot`/`Replace`. Holding the node manager's own lock while touching them
+ would look careful and guarantee nothing, because the FileType methods take a
+ different lock.
+- A transfer can be aborted or expire while its inference is in flight. The
+ completing call re-checks that the transfer is still live under the same lock
+ that removal takes, and drops the answer if it is not.
diff --git a/samples/AI/deploy/helm/ai-model-management/Chart.yaml b/samples/AI/deploy/helm/ai-model-management/Chart.yaml
new file mode 100644
index 0000000000..09de5b0b20
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/Chart.yaml
@@ -0,0 +1,15 @@
+apiVersion: v2
+name: ai-model-management
+description: |
+ An OPC UA Server that exposes AI models - hosted or on-device - through the
+ OPC UA AI Model Management and Inference companion specification.
+type: application
+version: 0.1.0
+appVersion: "0.2.0"
+keywords:
+ - opcua
+ - ai
+ - inference
+home: https://github.com/OPCFoundation/UA-.NETStandard
+sources:
+ - https://github.com/OPCFoundation/UA-.NETStandard
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/NOTES.txt b/samples/AI/deploy/helm/ai-model-management/templates/NOTES.txt
new file mode 100644
index 0000000000..9e81c6394a
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/NOTES.txt
@@ -0,0 +1,39 @@
+The AI Model Management sample Server is deploying.
+
+ Release: {{ .Release.Name }}
+ Namespace: {{ .Release.Namespace }}
+ Endpoint: opc.tcp://{{ include "ai-model-management.fullname" . }}.{{ .Release.Namespace }}.svc:{{ .Values.service.port }}/ModelManagementServer
+
+Reach it from your machine:
+
+ kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "ai-model-management.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
+ dotnet run --project samples/AI/ModelManagementClient -- opc.tcp://localhost:{{ .Values.service.port }}/ModelManagementServer
+
+What this Server will tell a client about itself:
+
+ Inference runs {{ .Values.backend.site }}
+ Jurisdiction {{ .Values.backend.dataJurisdiction }}
+ Egress permitted {{ .Values.backend.egressPermitted }}
+ Input retained {{ .Values.backend.retainsInput }}
+{{- if .Values.ai.enableFallback }}
+ Fallback published, reaching {{ .Values.fallbackBackend.endpointUri }}
+{{- else }}
+ Fallback none; a failed inference fails
+{{- end }}
+
+{{- if not (include "ai-model-management.hasCredential" .) }}
+
+No credential is mounted. That is correct for an anonymous endpoint or for
+workload identity, and wrong for anything else.
+{{- end }}
+{{- if .Values.credentials.create }}
+
+WARNING: credentials.create put the secret through Helm, so it is in the release
+history and in `helm get values`. Use credentials.existingSecret for anything
+that is not a local cluster.
+{{- end }}
+{{- if not .Values.persistence.enabled }}
+
+WARNING: persistence is off, so the Server generates a new application instance
+certificate on every restart and every client has to trust it again.
+{{- end }}
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/_helpers.tpl b/samples/AI/deploy/helm/ai-model-management/templates/_helpers.tpl
new file mode 100644
index 0000000000..6d742ced23
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/_helpers.tpl
@@ -0,0 +1,69 @@
+{{/*
+Chart name and fullname, per the standard Helm conventions.
+*/}}
+{{- define "ai-model-management.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{- define "ai-model-management.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{- define "ai-model-management.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{- define "ai-model-management.labels" -}}
+helm.sh/chart: {{ include "ai-model-management.chart" . }}
+{{ include "ai-model-management.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{- define "ai-model-management.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "ai-model-management.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{- define "ai-model-management.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "ai-model-management.fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
+
+{{/*
+The Secret holding the credential, whichever way it was supplied.
+
+Returns an empty string when there is none, which is what the anonymous and
+workload-identity paths want: neither stores a secret at all, so mounting an
+empty volume for them would only invite someone to fill it.
+*/}}
+{{- define "ai-model-management.credentialSecretName" -}}
+{{- if .Values.credentials.existingSecret }}
+{{- .Values.credentials.existingSecret }}
+{{- else if .Values.credentials.create }}
+{{- printf "%s-credentials" (include "ai-model-management.fullname" .) }}
+{{- end }}
+{{- end }}
+
+{{/*
+Whether a credential is mounted at all.
+*/}}
+{{- define "ai-model-management.hasCredential" -}}
+{{- if include "ai-model-management.credentialSecretName" . -}}
+true
+{{- end }}
+{{- end }}
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/deployment.yaml b/samples/AI/deploy/helm/ai-model-management/templates/deployment.yaml
new file mode 100644
index 0000000000..2736cc47eb
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/deployment.yaml
@@ -0,0 +1,176 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "ai-model-management.fullname" . }}
+ labels:
+ {{- include "ai-model-management.labels" . | nindent 4 }}
+spec:
+ replicas: {{ .Values.replicaCount }}
+ selector:
+ matchLabels:
+ {{- include "ai-model-management.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ labels:
+ {{- include "ai-model-management.selectorLabels" . | nindent 8 }}
+ annotations:
+ {{- with .Values.podAnnotations }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- if and .Values.credentials.create .Values.credentials.value }}
+ # Rolls the pods when the credential changes. The annotation carries a
+ # digest of the credential's NAME and reference, never of the value:
+ # Pod annotations are readable by anyone with `get pods`, which is granted
+ # far more widely than `get secrets`, and a digest of a low-entropy secret
+ # is recoverable offline. The name changes when the release does, which is
+ # the case this exists to cover on the demo-only inline path.
+ checksum/credentials: {{ printf "%s/%s" (include "ai-model-management.credentialSecretName" .) .Values.backend.credentialReference | sha256sum }}
+ {{- end }}
+ spec:
+ serviceAccountName: {{ include "ai-model-management.serviceAccountName" . }}
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ securityContext:
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ containers:
+ - name: server
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ ports:
+ - name: opcua
+ containerPort: {{ .Values.server.port }}
+ protocol: TCP
+ env:
+ - name: host
+ value: {{ .Values.server.host | quote }}
+ - name: port
+ value: {{ .Values.server.port | quote }}
+
+ # What this Server publishes.
+ - name: AiModelManagement__PrimaryDeploymentId
+ value: {{ .Values.ai.primaryDeploymentId | quote }}
+ - name: AiModelManagement__FallbackDeploymentId
+ value: {{ .Values.ai.fallbackDeploymentId | quote }}
+ - name: AiModelManagement__EnableFallback
+ value: {{ .Values.ai.enableFallback | quote }}
+ - name: AiModelManagement__EnableCatalogue
+ value: {{ .Values.ai.enableCatalogue | quote }}
+ - name: AiModelManagement__SourceId
+ value: {{ .Values.ai.sourceId | quote }}
+
+ # The endpoint the primary deployment reaches. Note what is NOT here:
+ # the credential. Only its name travels, and the value is read from a
+ # mounted file by the one component that needs it.
+ - name: InferenceBackend__Site
+ value: {{ .Values.backend.site | quote }}
+ - name: InferenceBackend__EndpointUri
+ value: {{ .Values.backend.endpointUri | quote }}
+ - name: InferenceBackend__ChatCompletionsPath
+ value: {{ .Values.backend.chatCompletionsPath | quote }}
+ - name: InferenceBackend__ProbePath
+ value: {{ .Values.backend.probePath | quote }}
+ - name: InferenceBackend__Authentication
+ value: {{ .Values.backend.authentication | quote }}
+ - name: InferenceBackend__CredentialReference
+ value: {{ .Values.backend.credentialReference | quote }}
+ - name: InferenceBackend__ApiKeyHeader
+ value: {{ .Values.backend.apiKeyHeader | quote }}
+ - name: InferenceBackend__CredentialDirectory
+ value: {{ .Values.credentials.mountPath | quote }}
+ - name: InferenceBackend__DataJurisdiction
+ value: {{ .Values.backend.dataJurisdiction | quote }}
+ - name: InferenceBackend__EgressPermitted
+ value: {{ .Values.backend.egressPermitted | quote }}
+ - name: InferenceBackend__RetainsInput
+ value: {{ .Values.backend.retainsInput | quote }}
+ - name: InferenceBackend__MaxInlinePayloadSize
+ value: {{ .Values.backend.maxInlinePayloadSize | quote }}
+ {{- range $index, $model := .Values.backend.models }}
+ - name: InferenceBackend__Models__{{ $index }}__Publisher
+ value: {{ $model.publisher | quote }}
+ - name: InferenceBackend__Models__{{ $index }}__Name
+ value: {{ $model.name | quote }}
+ - name: InferenceBackend__Models__{{ $index }}__Version
+ value: {{ $model.version | quote }}
+ {{- if $model.taskKind }}
+ - name: InferenceBackend__Models__{{ $index }}__TaskKind
+ value: {{ $model.taskKind | quote }}
+ {{- end }}
+ {{- end }}
+
+ # The fallback's own endpoint, which is the point of it.
+ - name: FallbackInferenceBackend__Enabled
+ value: {{ .Values.fallbackBackend.enabled | quote }}
+ {{- if .Values.fallbackBackend.enabled }}
+ - name: FallbackInferenceBackend__Site
+ value: {{ .Values.fallbackBackend.site | quote }}
+ - name: FallbackInferenceBackend__EndpointUri
+ value: {{ .Values.fallbackBackend.endpointUri | quote }}
+ - name: FallbackInferenceBackend__Authentication
+ value: {{ .Values.fallbackBackend.authentication | quote }}
+ - name: FallbackInferenceBackend__DataJurisdiction
+ value: {{ .Values.fallbackBackend.dataJurisdiction | quote }}
+ - name: FallbackInferenceBackend__EgressPermitted
+ value: {{ .Values.fallbackBackend.egressPermitted | quote }}
+ - name: FallbackInferenceBackend__RetainsInput
+ value: {{ .Values.fallbackBackend.retainsInput | quote }}
+ {{- end }}
+ {{- with .Values.extraEnv }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- if .Values.probes.liveness.enabled }}
+ livenessProbe:
+ tcpSocket:
+ port: opcua
+ initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
+ periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
+ failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
+ {{- end }}
+ {{- if .Values.probes.readiness.enabled }}
+ readinessProbe:
+ tcpSocket:
+ port: opcua
+ initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
+ periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
+ failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
+ {{- end }}
+ resources:
+ {{- toYaml .Values.resources | nindent 12 }}
+ volumeMounts:
+ - name: pki
+ mountPath: /app/pki
+ {{- if include "ai-model-management.hasCredential" . }}
+ - name: credentials
+ mountPath: {{ .Values.credentials.mountPath }}
+ readOnly: true
+ {{- end }}
+ volumes:
+ - name: pki
+ {{- if .Values.persistence.enabled }}
+ persistentVolumeClaim:
+ claimName: {{ include "ai-model-management.fullname" . }}-pki
+ {{- else }}
+ emptyDir: {}
+ {{- end }}
+ {{- if include "ai-model-management.hasCredential" . }}
+ - name: credentials
+ secret:
+ secretName: {{ include "ai-model-management.credentialSecretName" . }}
+ defaultMode: 0400
+ {{- end }}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/pvc.yaml b/samples/AI/deploy/helm/ai-model-management/templates/pvc.yaml
new file mode 100644
index 0000000000..476d9ceb6f
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/pvc.yaml
@@ -0,0 +1,17 @@
+{{- if .Values.persistence.enabled }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ include "ai-model-management.fullname" . }}-pki
+ labels:
+ {{- include "ai-model-management.labels" . | nindent 4 }}
+spec:
+ accessModes:
+ - {{ .Values.persistence.accessMode }}
+ {{- if .Values.persistence.storageClass }}
+ storageClassName: {{ .Values.persistence.storageClass | quote }}
+ {{- end }}
+ resources:
+ requests:
+ storage: {{ .Values.persistence.size }}
+{{- end }}
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/secret.yaml b/samples/AI/deploy/helm/ai-model-management/templates/secret.yaml
new file mode 100644
index 0000000000..68cd33df90
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/secret.yaml
@@ -0,0 +1,16 @@
+{{- if and .Values.credentials.create (not .Values.credentials.existingSecret) }}
+{{- if not .Values.credentials.value }}
+{{- fail "credentials.create is set but credentials.value is empty. Supply the value, or point credentials.existingSecret at a Secret you manage." }}
+{{- end }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "ai-model-management.fullname" . }}-credentials
+ labels:
+ {{- include "ai-model-management.labels" . | nindent 4 }}
+type: Opaque
+stringData:
+ # The key is the name the address space publishes as CredentialReference, so
+ # a client can tell WHICH credential is configured without being able to read it.
+ {{ .Values.backend.credentialReference }}: {{ .Values.credentials.value | quote }}
+{{- end }}
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/service.yaml b/samples/AI/deploy/helm/ai-model-management/templates/service.yaml
new file mode 100644
index 0000000000..295fe59478
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/service.yaml
@@ -0,0 +1,19 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "ai-model-management.fullname" . }}
+ labels:
+ {{- include "ai-model-management.labels" . | nindent 4 }}
+ {{- with .Values.service.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: opcua
+ protocol: TCP
+ name: opcua
+ selector:
+ {{- include "ai-model-management.selectorLabels" . | nindent 4 }}
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/serviceaccount.yaml b/samples/AI/deploy/helm/ai-model-management/templates/serviceaccount.yaml
new file mode 100644
index 0000000000..3e80421a3d
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/serviceaccount.yaml
@@ -0,0 +1,12 @@
+{{- if .Values.serviceAccount.create }}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include "ai-model-management.serviceAccountName" . }}
+ labels:
+ {{- include "ai-model-management.labels" . | nindent 4 }}
+ {{- with .Values.serviceAccount.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+{{- end }}
diff --git a/samples/AI/deploy/helm/ai-model-management/templates/validate.yaml b/samples/AI/deploy/helm/ai-model-management/templates/validate.yaml
new file mode 100644
index 0000000000..114d6960b7
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/templates/validate.yaml
@@ -0,0 +1,35 @@
+{{/*
+Refuses configurations that would deploy something misleading.
+
+Each of these is a real mistake that produces a Server which looks healthy and
+answers questions wrongly - which is worse than one that fails to start, because
+nobody investigates a green pod.
+*/}}
+
+{{- if and (ne .Values.backend.authentication "Anonymous") (not (include "ai-model-management.hasCredential" .)) }}
+{{- fail "backend.authentication expects a credential but none is supplied. Set credentials.existingSecret, or use authentication: Anonymous for an endpoint that needs none. This applies to BearerToken and WorkloadIdentity as much as ApiKey: without a credential the Server sends no Authorization header at all, while the address space still says it authenticates." }}
+{{- end }}
+
+{{- if and (ne .Values.backend.authentication "Anonymous") (not .Values.backend.credentialReference) }}
+{{- fail "backend.credentialReference is empty, so the Server would authenticate with nothing while publishing AuthenticationKind as though it did. Name the key within the Secret (or the token scope, for WorkloadIdentity)." }}
+{{- end }}
+
+{{- if and .Values.fallbackBackend.enabled (ne .Values.fallbackBackend.site "OnServer") (not .Values.fallbackBackend.egressPermitted) }}
+{{- fail "fallbackBackend.egressPermitted is false but its site is not OnServer, so the fallback deployment would publish EgressPermitted=false while sending payloads off the machine." }}
+{{- end }}
+
+{{- if and .Values.credentials.create .Values.credentials.existingSecret }}
+{{- fail "Set either credentials.existingSecret or credentials.create, not both." }}
+{{- end }}
+
+{{- if and .Values.ai.enableFallback (not .Values.fallbackBackend.enabled) }}
+{{- fail "ai.enableFallback publishes a fallback deployment, but fallbackBackend.enabled is false, so it has nothing to reach. A fallback that always fails is worse than none: set fallbackBackend.enabled, or turn ai.enableFallback off." }}
+{{- end }}
+
+{{- if and .Values.fallbackBackend.enabled (eq .Values.fallbackBackend.endpointUri .Values.backend.endpointUri) }}
+{{- fail "The fallback endpoint is the same as the primary. That is a retry, not a fallback: it fails for every reason the primary just failed for." }}
+{{- end }}
+
+{{- if and (not .Values.backend.egressPermitted) (ne .Values.backend.site "OnServer") }}
+{{- fail "backend.egressPermitted is false but backend.site is not OnServer, so this Server would publish EgressPermitted=false while sending payloads off the machine. Set egressPermitted, or point the backend at a local runtime." }}
+{{- end }}
diff --git a/samples/AI/deploy/helm/ai-model-management/values.yaml b/samples/AI/deploy/helm/ai-model-management/values.yaml
new file mode 100644
index 0000000000..77e8b01bee
--- /dev/null
+++ b/samples/AI/deploy/helm/ai-model-management/values.yaml
@@ -0,0 +1,156 @@
+# Values for the AI Model Management sample.
+#
+# The two shapes worth knowing about are a hosted endpoint reached with a
+# credential, and an on-device runtime reached over loopback with none. Both are
+# the same chart with a different `backend` block; see README.md.
+
+replicaCount: 1
+
+image:
+ repository: ghcr.io/opcfoundation/modelmanagementserver
+ tag: latest
+ pullPolicy: IfNotPresent
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+server:
+ # Port the OPC UA endpoint listens on.
+ port: 62640
+ # 0.0.0.0 so the endpoint is reachable from outside the pod.
+ host: "0.0.0.0"
+
+service:
+ type: ClusterIP
+ port: 62640
+ annotations: {}
+
+# What this Server publishes.
+ai:
+ primaryDeploymentId: primary
+ fallbackDeploymentId: fallback
+ # A fallback deployment and the FallsBackTo reference from the primary to it.
+ # Off by default because the default backend is already on the machine: there
+ # is nowhere useful to fall back TO, and a fallback that always fails is worse
+ # than none. Turn it on together with fallbackBackend.enabled.
+ enableFallback: false
+ enableCatalogue: true
+ sourceId: model-source
+
+# The endpoint the primary deployment reaches.
+#
+# The default is an on-device runtime on loopback with no credential, because
+# that is the shape that installs and runs without anything else existing. For a
+# hosted endpoint see values-cloud.yaml.
+backend:
+ # OnServer, EdgeOffServer or Cloud. This is what a client reads from
+ # InferenceLocation, so it should describe where inference actually runs -
+ # it is an operational fact, not a label.
+ site: OnServer
+ endpointUri: "http://localhost:5273/"
+ chatCompletionsPath: "v1/chat/completions"
+ probePath: "v1/models"
+ # Anonymous, ApiKey, BearerToken or WorkloadIdentity.
+ authentication: Anonymous
+ # Names the key WITHIN the mounted Secret - or, for WorkloadIdentity, the token
+ # scope being requested. Never the secret itself: this value is published in the
+ # address space so a client can tell whether the right credential is configured,
+ # and a client that could read the value could use it.
+ credentialReference: ""
+ apiKeyHeader: api-key
+ # Where the data goes. Egress is not made false by encryption, which answers
+ # who can read data in flight and not where the data went.
+ dataJurisdiction: "on-premises"
+ egressPermitted: false
+ retainsInput: false
+ # Published before a client calls rather than discovered from a rejection.
+ maxInlinePayloadSize: 65536
+ models: []
+ # - publisher: contoso
+ # name: weld-inspect
+ # version: 2.1.0
+ # taskKind: classification
+
+# The fallback deployment's own endpoint. Deliberately a separate block: a
+# fallback reached through the same endpoint and credentials as the primary is a
+# retry, not a fallback, and fails for every reason the primary just failed for.
+fallbackBackend:
+ enabled: false
+ site: OnServer
+ endpointUri: "http://localhost:5273/"
+ authentication: Anonymous
+ dataJurisdiction: "on-premises"
+ egressPermitted: false
+ retainsInput: false
+
+# The credential, mounted as a file.
+#
+# `existingSecret` is the option to use in anything real: the value never passes
+# through Helm, so it is not in the release history, not in `helm get values`,
+# and not in whatever holds the values file.
+credentials:
+ existingSecret: ""
+ # Creates a Secret from `value`. For local clusters and demonstrations only.
+ create: false
+ value: ""
+ mountPath: /var/run/secrets/ai
+
+# The certificate store. A Server that regenerates its application instance
+# certificate on every restart has to be re-trusted on every restart.
+persistence:
+ enabled: true
+ size: 1Gi
+ storageClass: ""
+ accessMode: ReadWriteOnce
+
+# A TCP probe proves the listener accepts connections. It does NOT prove the
+# Server is serving the address space - only an OPC UA client can do that, and
+# an exec probe running one costs a process launch per probe. The trade is
+# deliberate; see README.md.
+probes:
+ liveness:
+ enabled: true
+ initialDelaySeconds: 20
+ periodSeconds: 20
+ failureThreshold: 3
+ readiness:
+ enabled: true
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ failureThreshold: 3
+
+resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ memory: 1Gi
+
+# UID 1654 is the `app` user the .NET base images ship.
+podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1654
+ fsGroup: 1654
+ fsGroupChangePolicy: OnRootMismatch
+
+securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: false
+ capabilities:
+ drop:
+ - ALL
+
+nodeSelector: {}
+tolerations: []
+affinity: {}
+podAnnotations: {}
+
+serviceAccount:
+ create: true
+ name: ""
+ # Workload identity is annotated here rather than configured in the app,
+ # because which identity a pod runs as is the platform's business.
+ annotations: {}
+
+extraEnv: []
diff --git a/samples/AI/deploy/helm/chart_tests.py b/samples/AI/deploy/helm/chart_tests.py
new file mode 100644
index 0000000000..be3860e61f
--- /dev/null
+++ b/samples/AI/deploy/helm/chart_tests.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""Renders the chart and asserts what the rendered manifests must and must not say.
+
+`helm lint` proves a chart is well formed. It does not prove the chart deploys
+what it claims to, and the mistakes worth catching here all produce a valid
+manifest: a Server that reports the wrong data residency, a credential that
+reaches the address space, a fallback pointing at the endpoint it is supposed to
+cover for.
+
+Each negative case is also asserted to FAIL, because a guardrail that never
+fires is indistinguishable from one that does not work.
+
+ python chart_tests.py [path-to-helm]
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+CHART = Path(__file__).resolve().parent / "ai-model-management"
+HELM = sys.argv[1] if len(sys.argv) > 1 else shutil.which("helm")
+if HELM is None:
+ print("helm was not found on PATH. Pass path-to-helm as the first argument.", file=sys.stderr)
+ sys.exit(1)
+
+failures = []
+checks = 0
+
+
+def render(*args, values=None):
+ """Runs `helm template` and returns (ok, output)."""
+ command = [HELM, "template", "test", str(CHART)]
+ if values:
+ command += ["-f", str(Path(__file__).resolve().parent / values)]
+ command += list(args)
+ result = subprocess.run(
+ command, capture_output=True, text=True, check=False
+ )
+ return result.returncode == 0, result.stdout + result.stderr
+
+
+def check(name, condition, detail=""):
+ global checks
+ checks += 1
+ if condition:
+ print(f" ok {name}")
+ else:
+ print(f" FAIL {name}{(': ' + detail) if detail else ''}")
+ failures.append(name)
+
+
+def expect_render(name, *args, values=None):
+ ok, output = render(*args, values=values)
+ if not ok:
+ check(name, False, output.strip().splitlines()[-1] if output else "")
+ return ""
+ return output
+
+
+def expect_refusal(name, fragment, *args, values=None):
+ """Asserts the chart refuses a configuration, and refuses it for the stated reason."""
+ ok, output = render(*args, values=values)
+ check(name, not ok and fragment in output,
+ "rendered successfully" if ok else "refused for a different reason")
+
+
+print("Default values")
+out = expect_render("renders")
+check("names the OPC UA port", "containerPort: 62640" in out)
+check("runs as a non-root user", "runAsNonRoot: true" in out)
+check("drops all capabilities", "- ALL" in out)
+check("keeps the certificate store on a volume",
+ "kind: PersistentVolumeClaim" in out)
+check("mounts no credential when none is configured",
+ "secretName:" not in out)
+check("publishes on-premises residency",
+ 'value: "on-premises"' in out)
+check("publishes egress as false",
+ "InferenceBackend__EgressPermitted" in out and 'value: "false"' in out)
+check("publishes no fallback by default",
+ 'name: FallbackInferenceBackend__Enabled' in out and
+ out.count('value: "false"') >= 1)
+
+print()
+print("Cloud values")
+out = expect_render("renders", "--set", "credentials.existingSecret=my-secret",
+ values="values-cloud.yaml")
+check("mounts the named Secret", "secretName: my-secret" in out)
+check("mounts it read only", "readOnly: true" in out)
+check("mounts it unreadable to the group", "defaultMode: 400" in out or
+ "defaultMode: 0400" in out or "defaultMode: 256" in out)
+check("publishes the credential REFERENCE, not a value",
+ "InferenceBackend__CredentialReference" in out)
+check("publishes egress as true when the payload leaves the machine",
+ 'value: "true"' in out)
+check("reaches the fallback at a different endpoint",
+ "FallbackInferenceBackend__EndpointUri" in out)
+
+print()
+print("The credential never appears in a rendered manifest")
+out = expect_render("renders with an inline credential",
+ "--set", "credentials.create=true",
+ "--set", "credentials.value=super-secret-value",
+ "--set", "backend.authentication=ApiKey",
+ "--set", "backend.credentialReference=inference-api-key")
+# It appears exactly once, in the Secret it belongs in, and nowhere else -
+# not in an env var, not in an annotation, not in a ConfigMap.
+check("appears only inside the Secret",
+ out.count("super-secret-value") == 1)
+check("is not passed as an environment variable",
+ "value: \"super-secret-value\"" not in out)
+check("rolls the pods when it changes",
+ "checksum/credentials:" in out)
+
+# A digest of a low-entropy secret is recoverable offline, and Pod annotations
+# are readable by anyone with `get pods` - which is granted far more widely than
+# `get secrets`. So the annotation must not be a digest OF THE VALUE.
+import hashlib # noqa: E402
+value_digest = hashlib.sha256(b"super-secret-value").hexdigest()
+check("the annotation is not a digest of the value",
+ value_digest not in out)
+
+print()
+print("Refusals")
+expect_refusal(
+ "an ApiKey endpoint with no credential",
+ "expects a credential but none is supplied",
+ "--set", "backend.authentication=ApiKey")
+expect_refusal(
+ "a BearerToken endpoint with no credential",
+ "expects a credential but none is supplied",
+ "--set", "backend.authentication=BearerToken")
+expect_refusal(
+ "a WorkloadIdentity endpoint with no credential",
+ "expects a credential but none is supplied",
+ "--set", "backend.authentication=WorkloadIdentity")
+expect_refusal(
+ "a mounted Secret with no key named",
+ "credentialReference is empty",
+ "--set", "backend.authentication=ApiKey",
+ "--set", "credentials.existingSecret=some-secret",
+ "--set", "backend.credentialReference=")
+expect_refusal(
+ "a fallback claiming no egress while calling off the machine",
+ "fallbackBackend.egressPermitted is false",
+ "--set", "ai.enableFallback=true",
+ "--set", "fallbackBackend.enabled=true",
+ "--set", "fallbackBackend.site=Cloud",
+ "--set", "fallbackBackend.egressPermitted=false")
+expect_refusal(
+ "a fallback with nowhere to fall back to",
+ "nothing to reach",
+ "--set", "ai.enableFallback=true")
+expect_refusal(
+ "a fallback pointing at the primary's endpoint",
+ "That is a retry, not a fallback",
+ "--set", "ai.enableFallback=true",
+ "--set", "fallbackBackend.enabled=true",
+ "--set", "fallbackBackend.endpointUri=http://localhost:5273/",
+ "--set", "backend.endpointUri=http://localhost:5273/")
+expect_refusal(
+ "claiming no egress while calling off the machine",
+ "would publish EgressPermitted=false",
+ "--set", "backend.site=Cloud",
+ "--set", "backend.egressPermitted=false",
+ "--set", "backend.authentication=Anonymous")
+expect_refusal(
+ "both credential sources at once",
+ "not both",
+ "--set", "credentials.create=true",
+ "--set", "credentials.value=x",
+ "--set", "credentials.existingSecret=y")
+expect_refusal(
+ "creating a Secret with no value",
+ "credentials.value is empty",
+ "--set", "credentials.create=true",
+ "--set", "backend.authentication=Anonymous")
+
+print()
+if failures:
+ print(f"{len(failures)} of {checks} checks failed:")
+ for name in failures:
+ print(f" - {name}")
+ sys.exit(1)
+
+print(f"All {checks} checks passed.")
diff --git a/samples/AI/deploy/helm/smoke-test.ps1 b/samples/AI/deploy/helm/smoke-test.ps1
new file mode 100644
index 0000000000..dca4fec7b0
--- /dev/null
+++ b/samples/AI/deploy/helm/smoke-test.ps1
@@ -0,0 +1,157 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Builds the image, deploys the chart to a kind cluster, and drives it with a
+ real OPC UA client.
+
+.DESCRIPTION
+ The chart tests prove the manifests say the right things. They cannot prove
+ the image runs, that the Server comes up inside a pod, or that an OPC UA
+ session survives the network between them - and every one of those has
+ failed for reasons no template rendering would reveal.
+
+ A stub inference endpoint is deployed alongside, so the round trip completes
+ without a hosted service or a local model runtime. It is a test fixture and
+ lives outside the chart: a stub shipped in the chart would eventually be
+ deployed by someone who thought it was a feature.
+
+ This is the slowest and most fragile check in the suite - image build, image
+ load, chart install and a live session. Run it deliberately, and quarantine
+ it rather than letting it gate everything else.
+
+.PARAMETER Cluster
+ Name of the kind cluster to create. Deleted on exit unless -Keep is given.
+
+.PARAMETER Keep
+ Leaves the cluster running, for investigating a failure.
+
+.EXAMPLE
+ pwsh deploy/helm/smoke-test.ps1
+#>
+[CmdletBinding()]
+param(
+ [string]$Cluster = 'ai-sample-smoke',
+ [switch]$Keep
+)
+
+$ErrorActionPreference = 'Stop'
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$repoRoot = Resolve-Path (Join-Path $here '..' '..' '..' '..')
+
+function Step($message) {
+ Write-Host ''
+ Write-Host "==> $message" -ForegroundColor Cyan
+}
+
+function Require($tool) {
+ if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) {
+ throw "$tool is required and was not found on PATH."
+ }
+}
+
+Require docker
+Require kind
+Require kubectl
+Require helm
+
+$image = 'modelmanagementserver:smoke'
+$release = 'ai-smoke'
+$created = $false
+
+try {
+ Step "Building $image"
+ docker build `
+ -f (Join-Path $repoRoot 'samples/AI/ModelManagementServer/Dockerfile') `
+ -t $image `
+ $repoRoot
+ if ($LASTEXITCODE -ne 0) { throw 'The image build failed.' }
+
+ Step "Creating the kind cluster '$Cluster'"
+ kind create cluster --name $Cluster --wait 180s
+ if ($LASTEXITCODE -ne 0) { throw 'The cluster did not come up.' }
+ $created = $true
+
+ Step 'Loading the image into the cluster'
+ kind load docker-image $image --name $Cluster
+ if ($LASTEXITCODE -ne 0) { throw 'The image did not load.' }
+
+ Step 'Deploying the stub inference endpoint'
+ kubectl apply -f (Join-Path $here 'test-fixtures/stub-backend.yaml')
+ kubectl wait --for=condition=available deployment/stub-inference-backend --timeout=180s
+ if ($LASTEXITCODE -ne 0) { throw 'The stub endpoint did not become available.' }
+
+ Step 'Installing the chart'
+ helm install $release (Join-Path $here 'ai-model-management') `
+ --set image.repository=modelmanagementserver `
+ --set image.tag=smoke `
+ --set image.pullPolicy=Never `
+ --set backend.endpointUri=http://stub-inference-backend:5273/ `
+ --set backend.site=EdgeOffServer `
+ --set backend.egressPermitted=true `
+ --wait --timeout 240s
+ if ($LASTEXITCODE -ne 0) { throw 'The chart did not install.' }
+
+ Step 'Opening a session from outside the cluster'
+ $service = "svc/$release-ai-model-management"
+ $forward = Start-Process kubectl `
+ -ArgumentList 'port-forward', $service, '62640:62640' `
+ -NoNewWindow -PassThru
+ try {
+ Start-Sleep -Seconds 6
+
+ $output = dotnet run `
+ --project (Join-Path $repoRoot 'samples/AI/ModelManagementClient') `
+ -f net10.0 -- opc.tcp://localhost:62640/ModelManagementServer 2>&1 |
+ Out-String
+ }
+ finally {
+ Stop-Process -Id $forward.Id -Force -ErrorAction SilentlyContinue
+ }
+
+ Write-Host $output
+
+ Step 'Checking what came back'
+
+ # Each of these is a distinct claim, and a run that satisfies some and not
+ # others is a more useful report than a single pass or fail.
+ $checks = [ordered]@{
+ 'the Server published an AI root' = 'AI root: '
+ 'the inference reached the endpoint' = 'SMOKE-TEST-OK'
+ 'the result named the model that ran' = 'ModelUsed ns='
+ 'the chunked transfer completed' = 'transfer ns='
+ 'the asynchronous job produced a result' = 'job ns='
+ 'the source reported itself reachable' = 'reachable True'
+ }
+
+ $failed = @()
+
+ foreach ($check in $checks.GetEnumerator()) {
+ if ($output -match [regex]::Escape($check.Value)) {
+ Write-Host " ok $($check.Key)" -ForegroundColor Green
+ }
+ else {
+ Write-Host " FAIL $($check.Key)" -ForegroundColor Red
+ $failed += $check.Key
+ }
+ }
+
+ if ($failed.Count -gt 0) {
+ Write-Host ''
+ kubectl logs "deployment/$release-ai-model-management" --tail=60
+ throw "$($failed.Count) of $($checks.Count) checks failed."
+ }
+
+ Write-Host ''
+ Write-Host "All $($checks.Count) checks passed." -ForegroundColor Green
+}
+finally {
+ if ($created -and -not $Keep) {
+ Step "Deleting the cluster '$Cluster'"
+ kind delete cluster --name $Cluster | Out-Null
+ }
+ elseif ($created) {
+ Write-Host ''
+ Write-Host "The cluster '$Cluster' was left running. Delete it with:" -ForegroundColor Yellow
+ Write-Host " kind delete cluster --name $Cluster"
+ }
+}
diff --git a/samples/AI/deploy/helm/test-fixtures/stub-backend.yaml b/samples/AI/deploy/helm/test-fixtures/stub-backend.yaml
new file mode 100644
index 0000000000..7d6dcb2a80
--- /dev/null
+++ b/samples/AI/deploy/helm/test-fixtures/stub-backend.yaml
@@ -0,0 +1,95 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: stub-inference-backend
+ labels:
+ app.kubernetes.io/name: stub-inference-backend
+data:
+ # A minimal OpenAI-compatible endpoint. This is a TEST FIXTURE, not part of the
+ # sample: it exists so a cluster smoke test can prove the whole path - OPC UA
+ # client, Server, backend, HTTP and back - without a hosted service or a local
+ # model runtime. Nothing here should be copied into anything real.
+ server.py: |
+ import json
+ from http.server import BaseHTTPRequestHandler, HTTPServer
+
+ class Handler(BaseHTTPRequestHandler):
+ def _send(self, payload):
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ self._send({"data": [{"id": "smoke-model", "object": "model",
+ "owned_by": "smoke"}]})
+
+ def do_POST(self):
+ self.rfile.read(int(self.headers.get("Content-Length", "0")))
+ self._send({
+ "id": "chatcmpl-smoke",
+ "model": "smoke-model",
+ "choices": [{"index": 0, "finish_reason": "stop",
+ "message": {"role": "assistant",
+ "content": "SMOKE-TEST-OK"}}],
+ "usage": {"prompt_tokens": 3, "completion_tokens": 4,
+ "total_tokens": 7},
+ })
+
+ def log_message(self, fmt, *args):
+ pass
+
+ HTTPServer(("0.0.0.0", 5273), Handler).serve_forever()
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: stub-inference-backend
+ labels:
+ app.kubernetes.io/name: stub-inference-backend
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: stub-inference-backend
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: stub-inference-backend
+ spec:
+ containers:
+ - name: stub
+ image: python:3.13-alpine
+ command: ["python", "/app/server.py"]
+ ports:
+ - name: http
+ containerPort: 5273
+ volumeMounts:
+ - name: script
+ mountPath: /app
+ readinessProbe:
+ httpGet:
+ path: /v1/models
+ port: http
+ initialDelaySeconds: 2
+ periodSeconds: 2
+ volumes:
+ - name: script
+ configMap:
+ name: stub-inference-backend
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: stub-inference-backend
+ labels:
+ app.kubernetes.io/name: stub-inference-backend
+spec:
+ ports:
+ - port: 5273
+ targetPort: http
+ name: http
+ selector:
+ app.kubernetes.io/name: stub-inference-backend
diff --git a/samples/AI/deploy/helm/values-cloud.yaml b/samples/AI/deploy/helm/values-cloud.yaml
new file mode 100644
index 0000000000..5ec566d32e
--- /dev/null
+++ b/samples/AI/deploy/helm/values-cloud.yaml
@@ -0,0 +1,41 @@
+# A hosted endpoint, with a fallback on the machine.
+#
+# helm install ai ./ai-model-management -f values-cloud.yaml \
+# --set backend.endpointUri=https://.services.ai.azure.com/openai/ \
+# --set credentials.existingSecret=inference-credentials
+#
+# The credential is NOT here. Create the Secret separately and name it, so the
+# value never passes through Helm and never reaches the release history:
+#
+# kubectl create secret generic inference-credentials \
+# --from-literal=inference-api-key=
+
+backend:
+ site: Cloud
+ endpointUri: "https://example-resource.services.ai.azure.com/openai/"
+ authentication: ApiKey
+ credentialReference: inference-api-key
+ # The payload leaves the machine. Saying so is the point of the member: a
+ # client refusing to send process data off-site needs to know before it calls,
+ # and encryption does not make this false.
+ egressPermitted: true
+ retainsInput: false
+ dataJurisdiction: "eu-west"
+
+# A local model to fall back to when the link or the service is unavailable.
+# Reached separately from the primary on purpose - through the same endpoint and
+# credentials it would be a retry, and would fail for the same reason.
+ai:
+ enableFallback: true
+
+fallbackBackend:
+ enabled: true
+ site: OnServer
+ endpointUri: "http://localhost:5273/"
+ authentication: Anonymous
+ egressPermitted: false
+ retainsInput: false
+ dataJurisdiction: "on-premises"
+
+credentials:
+ existingSecret: "inference-credentials"
diff --git a/samples/AI/verify_backend.py b/samples/AI/verify_backend.py
new file mode 100644
index 0000000000..1214450005
--- /dev/null
+++ b/samples/AI/verify_backend.py
@@ -0,0 +1,67 @@
+"""A minimal OpenAI-compatible endpoint, for verifying the sample end to end.
+
+This is not part of the sample. It exists so that a developer without access to a
+hosted inference service, and without a local runtime installed, can still see the
+whole path work: OPC UA client, Server, backend, HTTP, and back.
+
+ python verify_backend.py 5273
+"""
+
+import json
+import sys
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+
+class Handler(BaseHTTPRequestHandler):
+ def _send(self, payload):
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ if self.path.rstrip("/").endswith("models"):
+ self._send(
+ {
+ "data": [
+ {"id": "verify-model", "object": "model"},
+ ]
+ }
+ )
+ else:
+ self.send_error(404)
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length", "0"))
+ self.rfile.read(length)
+ self._send(
+ {
+ "id": "chatcmpl-verify",
+ "model": "verify-model",
+ "choices": [
+ {
+ "index": 0,
+ "finish_reason": "stop",
+ "message": {
+ "role": "assistant",
+ "content": "The last shift ran without incident.",
+ },
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 11,
+ "completion_tokens": 8,
+ "total_tokens": 19,
+ },
+ }
+ )
+
+ def log_message(self, fmt, *args):
+ sys.stderr.write("stub: " + (fmt % args) + "\n")
+
+
+if __name__ == "__main__":
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 5273
+ HTTPServer(("127.0.0.1", port), Handler).serve_forever()
diff --git a/samples/OpenUsd/README.md b/samples/OpenUsd/README.md
new file mode 100644
index 0000000000..3cabb01e8c
--- /dev/null
+++ b/samples/OpenUsd/README.md
@@ -0,0 +1,9 @@
+# OpenUSD samples
+
+Runnable samples that publish OpenUSD representations from OPC UA servers.
+
+| Sample | What it is | What it shows |
+|---|---|---|
+| [`GeneratorServer`](GeneratorServer) | A server for simulated generating sets | Generators companion modelling, datasheet-driven simulation, and one independent OpenUSD twin per configured set |
+
+See [OpenUSD](../../docs/OpenUsd.md) for the binding model, connector tool and viewport.
diff --git a/samples/Robotics/BinPickingCell/Assets/Cell.usda b/samples/Robotics/BinPickingCell/Assets/Cell.usda
new file mode 100644
index 0000000000..7127030e0e
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/Assets/Cell.usda
@@ -0,0 +1,417 @@
+#usda 1.0
+(
+ doc = """Bin-picking work cell for the OPC UA Vision guided-picking sample.
+
+ The scene uses a branch-stable four-axis palletizer sized for this cell and
+ the parallel gripper shared with IntentEnabledRobot. It adds a bin of mixed
+ parts, a fixture to place them onto, and an eye-in-hand camera parented to
+ the flange so the view moves with the arm. This is the worked example
+ reproduced from the OPC UA Robotics-Vision Addendum: frame tree
+ world -> robot_base -> flange -> gripper_tcp with camera_eih on the flange.
+
+ Rendering contract:
+
+ The scene is lit by a single DomeLight (intensity 1000). Do NOT reintroduce
+ a DistantLight or any other bright directional/point light: at intensities
+ that show geometry a DistantLight blows every surface to white regardless of
+ displayColor, and the demo relies on the agent being able to pick "the red
+ part" by looking at the frame. Under a DomeLight the same materials measure
+ red (220, 37, 37), green (37, 208, 49), blue (37, 73, 233) - distinct enough
+ for a language model to reason about.
+
+ Frame tree contract:
+
+ - /World is the WorldFrame origin.
+ - /World/Robot/Palletizer/Base is the robot_base frame.
+ - /World/Robot/Palletizer/Base/J1/J2/J3/Leveling/J4/Flange is the flange
+ (mechanical interface).
+ - /World/Robot/Palletizer/.../Flange/Camera is the eye-in-hand camera prim
+ (UsdGeomCamera) that the Vision sensor renders from.
+ - /World/Robot/Palletizer/.../Flange/Gripper/Tcp is the gripper_tcp frame.
+
+ The camera is a child of the flange with no stack reset, so it travels with
+ the arm as a real eye-in-hand sensor does: rotateY = -90 turns its view axis
+ onto the flange +X tool direction. The authored joint angles put the flange
+ over the bin at the sample's start pose, so it opens looking into the bin.
+ """
+ defaultPrim = "World"
+ metersPerUnit = 1
+ upAxis = "Z"
+)
+
+def Xform "World"
+{
+ # A fixed observer for the viewport, distinct from the eye-in-hand sensor on the
+ # flange. Opening the viewer on /World/Robot/.../Camera shows what the tool sees; this
+ # one shows the cell working, which is what someone watching the robot wants.
+ #
+ # It sits on the -Y side so +X reads left to right, which is what puts the fixture on
+ # the left and the bin on the right. This is deliberately a low, centred front view:
+ # the table surface is nearly edge-on, its legs stay visible, and the complete wrist,
+ # gripper, bin and stack can be judged against one another throughout a cycle.
+ #
+ # The numbers were fitted to a reference framing and then corrected against what the
+ # viewer actually rendered, because the analytic placement and the rendered result did
+ # not agree - the framing came out offset and over-scaled. Treat them as measured
+ # rather than derived: change one and the framing has to be re-checked against a
+ # capture, not just recomputed.
+ def Camera "ObserverCamera"
+ {
+ token projection = "perspective"
+ float focalLength = 31.6
+ float horizontalAperture = 20.955
+ float verticalAperture = 16.989
+ float2 clippingRange = (0.1, 100.0)
+ double3 xformOp:translate = (0.500, -5.400, 1.300)
+ double xformOp:rotateX = 75.50
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateX"]
+ }
+
+ def Xform "Lights"
+ {
+ def DomeLight "Sky"
+ {
+ float inputs:intensity = 1000
+ color3f inputs:color = (1.0, 1.0, 1.0)
+ }
+ }
+
+ def Xform "Robot"
+ {
+ def Cylinder "PedestalRiser"
+ {
+ uniform token axis = "Z"
+ double height = 0.2000
+ double radius = 0.1650
+ color3f[] primvars:displayColor = [(0.16, 0.17, 0.18)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0, 0, 0.8200)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+
+ def Xform "Palletizer" (
+ references = @palletizer-arm.usda@
+ )
+ {
+ double3 xformOp:translate = (0, 0, 0.9200)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+
+ over "Base"
+ {
+ over "J1"
+ {
+ double xformOp:rotateZ = 0.000
+ over "J2"
+ {
+ double xformOp:rotateY = 31.513
+ over "J3"
+ {
+ double xformOp:rotateY = -65.890
+ over "Leveling"
+ {
+ double xformOp:rotateY = 124.377
+ over "J4"
+ {
+ double xformOp:rotateX = 90.000
+ over "Flange"
+ {
+ def Xform "Gripper" (
+ references = @palletizer-gripper.usda@
+ )
+ {
+ }
+
+ def Camera "Camera"
+ {
+ token projection = "perspective"
+ float focalLength = 12.24
+ float horizontalAperture = 14.688
+ float verticalAperture = 12.288
+ float2 clippingRange = (0.02, 5.0)
+ double3 xformOp:translate = (0.0200, 0.0000, 0.1600)
+ double xformOp:rotateY = -90
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateY"]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ def Xform "Floor"
+ {
+ def Cube "Slab"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.36, 0.37, 0.40)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0, 0, -0.0250)
+ double3 xformOp:scale = (2.4000, 1.8000, 0.0250)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+ }
+
+ def Xform "Bench"
+ {
+ def Cube "Top"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.58, 0.60, 0.60)] (
+ interpolation = "constant"
+ )
+ # The work surface is z = 0.720. The robot base is independently raised
+ # to z = 0.920 on PedestalRiser, so the wrist no longer has to fold back
+ # on itself to reach parts lying close to the table.
+ double3 xformOp:translate = (0, 0, 0.6850)
+ double3 xformOp:scale = (1.4000, 0.9000, 0.0700)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "LegA"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.22, 0.23, 0.24)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0.5800, 0.3300, 0.3250)
+ double3 xformOp:scale = (0.0250, 0.0250, 0.6500)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "LegB"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.22, 0.23, 0.24)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (-0.5800, 0.3300, 0.3250)
+ double3 xformOp:scale = (0.0250, 0.0250, 0.6500)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "LegC"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.22, 0.23, 0.24)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0.5800, -0.3300, 0.3250)
+ double3 xformOp:scale = (0.0250, 0.0250, 0.6500)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "LegD"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.22, 0.23, 0.24)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (-0.5800, -0.3300, 0.3250)
+ double3 xformOp:scale = (0.0250, 0.0250, 0.6500)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+ }
+
+ def Xform "Bin"
+ {
+ def Cube "BinFloor"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.42, 0.32, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0.6000, 0.0000, 0.7160)
+ double3 xformOp:scale = (0.2800, 0.2400, 0.0060)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "BinWallN"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.42, 0.32, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0.6000, 0.1170, 0.7330)
+ double3 xformOp:scale = (0.2800, 0.0060, 0.0400)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "BinWallS"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.42, 0.32, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0.6000, -0.1170, 0.7330)
+ double3 xformOp:scale = (0.2800, 0.0060, 0.0400)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "BinWallE"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.42, 0.32, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0.7370, 0.0000, 0.7330)
+ double3 xformOp:scale = (0.0060, 0.2400, 0.0400)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cube "BinWallW"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.42, 0.32, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (0.4630, 0.0000, 0.7330)
+ double3 xformOp:scale = (0.0060, 0.2400, 0.0400)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+ }
+
+ def Xform "Fixture"
+ {
+ def Cube "Base"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.20, 0.20, 0.22)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (-0.6000, 0.0000, 0.7200)
+ double3 xformOp:scale = (0.1400, 0.1400, 0.0180)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ def Cylinder "PegA"
+ {
+ uniform token axis = "Z"
+ double height = 0.0400
+ double radius = 0.0090
+ color3f[] primvars:displayColor = [(0.95, 0.85, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (-0.6500, 0.0500, 0.7490)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+
+ def Cylinder "PegB"
+ {
+ uniform token axis = "Z"
+ double height = 0.0400
+ double radius = 0.0090
+ color3f[] primvars:displayColor = [(0.95, 0.85, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (-0.5500, 0.0500, 0.7490)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+
+ def Cylinder "PegC"
+ {
+ uniform token axis = "Z"
+ double height = 0.0400
+ double radius = 0.0090
+ color3f[] primvars:displayColor = [(0.95, 0.85, 0.20)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:translate = (-0.6000, -0.0500, 0.7490)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+ }
+
+ # Each part is an Xform carrying a single xformOp:transform, with its shape as a child.
+ # The OpenUSD live binding drives the parent's transform, and the viewer sink authors
+ # that op as a whole matrix - composing translation with a default identity rotation and
+ # unit scale. Rotation and scale therefore have to live somewhere the binding does not
+ # overwrite, which is the child. The composition is unchanged: the shape is scaled and
+ # rotated about its own origin, then the parent translates it.
+ def Xform "Parts"
+ {
+ def Xform "RedCube"
+ {
+ matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0.5200, -0.0800, 0.7400, 1) )
+ uniform token[] xformOpOrder = ["xformOp:transform"]
+
+ def Cube "Shape"
+ {
+ double size = 0.0400
+ color3f[] primvars:displayColor = [(0.90, 0.15, 0.15)] (
+ interpolation = "constant"
+ )
+ double xformOp:rotateZ = 20
+ uniform token[] xformOpOrder = ["xformOp:rotateZ"]
+ }
+ }
+
+ def Xform "GreenCylinder"
+ {
+ matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0.6700, 0.0800, 0.7350, 1) )
+ uniform token[] xformOpOrder = ["xformOp:transform"]
+
+ def Cylinder "Shape"
+ {
+ uniform token axis = "Z"
+ double height = 0.0300
+ double radius = 0.0200
+ color3f[] primvars:displayColor = [(0.15, 0.85, 0.20)] (
+ interpolation = "constant"
+ )
+ }
+ }
+
+ def Xform "BlueSphere"
+ {
+ matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0.6800, -0.0800, 0.7440, 1) )
+ uniform token[] xformOpOrder = ["xformOp:transform"]
+
+ def Sphere "Shape"
+ {
+ double radius = 0.0240
+ color3f[] primvars:displayColor = [(0.15, 0.30, 0.95)] (
+ interpolation = "constant"
+ )
+ }
+ }
+
+ def Xform "YellowSlab"
+ {
+ matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0.5200, 0.0600, 0.7290, 1) )
+ uniform token[] xformOpOrder = ["xformOp:transform"]
+
+ def Cube "Shape"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:scale = (0.0640, 0.0320, 0.0180)
+ double xformOp:rotateZ = -15
+ uniform token[] xformOpOrder = ["xformOp:rotateZ", "xformOp:scale"]
+ }
+ }
+
+ def Xform "OrangeBrick"
+ {
+ matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0.6000, 0.0000, 0.7320, 1) )
+ uniform token[] xformOpOrder = ["xformOp:transform"]
+
+ def Cube "Shape"
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.95, 0.45, 0.10)] (
+ interpolation = "constant"
+ )
+ double3 xformOp:scale = (0.0500, 0.0280, 0.0240)
+ double xformOp:rotateZ = 40
+ uniform token[] xformOpOrder = ["xformOp:rotateZ", "xformOp:scale"]
+ }
+ }
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/Assets/palletizer-arm.usda b/samples/Robotics/BinPickingCell/Assets/palletizer-arm.usda
new file mode 100644
index 0000000000..81bd9ae604
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/Assets/palletizer-arm.usda
@@ -0,0 +1,193 @@
+#usda 1.0
+(
+ doc = """Four-axis palletizer arm for the OPC UA bin-picking Vision demo.
+
+ The commanded chain is /Palletizer/Base/J1/J2/J3/Leveling/J4/Flange:
+ J1 rotates about Z, J2 and J3 pitch about Y, and J4 rolls the tool about
+ its local X approach axis. Leveling is the mechanical parallelogram
+ compensation driven as 90 degrees - J2 - J3, keeping the gripper
+ vertically tool-down without a numerical offset-wrist solve.
+
+ Link lengths and shoulder height match BinPickingPalletizerGeometry.
+ Keep the prim and xform-op names stable: OPC UA OpenUSD live bindings
+ address them directly.
+ """
+ defaultPrim = "Palletizer"
+ metersPerUnit = 1
+ upAxis = "Z"
+)
+
+def Xform "Palletizer" (
+ kind = "component"
+)
+{
+ def Xform "Base"
+ {
+ def Cylinder "Turntable"
+ {
+ uniform token axis = "Z"
+ double height = 0.1600
+ double radius = 0.1500
+ color3f[] primvars:displayColor = [(0.16, 0.17, 0.18)]
+ rel material:binding =
+ double3 xformOp:translate = (0, 0, 0.0800)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+
+ def Cylinder "Column"
+ {
+ uniform token axis = "Z"
+ double height = 0.2000
+ double radius = 0.1120
+ color3f[] primvars:displayColor = [(0.78, 0.79, 0.78)]
+ rel material:binding =
+ double3 xformOp:translate = (0, 0, 0.1800)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+
+ def Xform "J1"
+ {
+ double xformOp:rotateZ = 0.000
+ double3 xformOp:translate = (0, 0, 0.2800)
+ uniform token[] xformOpOrder = ["xformOp:rotateZ", "xformOp:translate"]
+
+ def Sphere "ShoulderHousing"
+ {
+ double radius = 0.1000
+ color3f[] primvars:displayColor = [(0.78, 0.79, 0.78)]
+ rel material:binding =
+ }
+
+ def Xform "J2"
+ {
+ double xformOp:rotateY = 31.513
+ uniform token[] xformOpOrder = ["xformOp:rotateY"]
+
+ def Cylinder "UpperArm"
+ {
+ uniform token axis = "X"
+ double height = 0.4800
+ double radius = 0.0470
+ color3f[] primvars:displayColor = [(0.90, 0.90, 0.87)]
+ rel material:binding =
+ double3 xformOp:translate = (0.2400, 0, 0)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+
+ def Xform "J3"
+ {
+ double xformOp:rotateY = -65.890
+ double3 xformOp:translate = (0.4800, 0, 0)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateY"]
+
+ def Sphere "ElbowHousing"
+ {
+ double radius = 0.0840
+ color3f[] primvars:displayColor = [(0.16, 0.17, 0.18)]
+ rel material:binding =
+ }
+
+ def Cylinder "Forearm"
+ {
+ uniform token axis = "X"
+ double height = 0.4800
+ double radius = 0.0420
+ color3f[] primvars:displayColor = [(0.90, 0.90, 0.87)]
+ rel material:binding =
+ double3 xformOp:translate = (0.2400, 0, 0)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+
+ def Xform "Leveling"
+ {
+ double xformOp:rotateY = 124.377
+ double3 xformOp:translate = (0.4800, 0, 0)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateY"]
+
+ def Sphere "WristHousing"
+ {
+ double radius = 0.0640
+ color3f[] primvars:displayColor = [(0.10, 0.38, 0.68)]
+ rel material:binding =
+ }
+
+ def Xform "J4"
+ {
+ double xformOp:rotateX = 90.000
+ uniform token[] xformOpOrder = ["xformOp:rotateX"]
+
+ def Xform "Flange"
+ {
+ def Cylinder "IsoFlange"
+ {
+ uniform token axis = "X"
+ double height = 0.0180
+ double radius = 0.0315
+ color3f[] primvars:displayColor = [(0.78, 0.79, 0.78)]
+ rel material:binding =
+ double3 xformOp:translate = (0.0090, 0, 0)
+ uniform token[] xformOpOrder = ["xformOp:translate"]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ def Scope "Materials"
+ {
+ def Material "BodyWhite"
+ {
+ token outputs:surface.connect =
+ def Shader "Surface"
+ {
+ uniform token info:id = "UsdPreviewSurface"
+ color3f inputs:diffuseColor = (0.90, 0.90, 0.87)
+ float inputs:roughness = 0.52
+ float inputs:metallic = 0
+ token outputs:surface
+ }
+ }
+
+ def Material "DarkGrey"
+ {
+ token outputs:surface.connect =
+ def Shader "Surface"
+ {
+ uniform token info:id = "UsdPreviewSurface"
+ color3f inputs:diffuseColor = (0.16, 0.17, 0.18)
+ float inputs:roughness = 0.62
+ float inputs:metallic = 0
+ token outputs:surface
+ }
+ }
+
+ def Material "Silver"
+ {
+ token outputs:surface.connect =
+ def Shader "Surface"
+ {
+ uniform token info:id = "UsdPreviewSurface"
+ color3f inputs:diffuseColor = (0.78, 0.79, 0.78)
+ float inputs:roughness = 0.30
+ float inputs:metallic = 0.6
+ token outputs:surface
+ }
+ }
+
+ def Material "AccentBlue"
+ {
+ token outputs:surface.connect =
+ def Shader "Surface"
+ {
+ uniform token info:id = "UsdPreviewSurface"
+ color3f inputs:diffuseColor = (0.10, 0.38, 0.68)
+ float inputs:roughness = 0.45
+ float inputs:metallic = 0
+ token outputs:surface
+ }
+ }
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/Assets/palletizer-gripper.usda b/samples/Robotics/BinPickingCell/Assets/palletizer-gripper.usda
new file mode 100644
index 0000000000..b9514e5f72
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/Assets/palletizer-gripper.usda
@@ -0,0 +1,130 @@
+#usda 1.0
+/* ========================================================================
+ * 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/
+ * ======================================================================*/
+
+(
+ doc = """High-contrast palletizer gripper for the bin-picking demo.
+
+ Geometry and live jaw-slide prims come from the shared gripper asset. This
+ composition overrides only the body and carrier materials so the connector
+ between the blue palletizer wrist and yellow jaws remains visible against
+ the viewer's black background.
+ """
+ defaultPrim = "PalletizerGripper"
+ metersPerUnit = 1
+ upAxis = "Z"
+)
+
+def Xform "PalletizerGripper" (
+ references = @gripper.usda@
+)
+{
+ over "AdapterPlate"
+ {
+ color3f[] primvars:displayColor = [(0.80, 0.82, 0.86)]
+ rel material:binding =
+ }
+
+ over "Body"
+ {
+ color3f[] primvars:displayColor = [(0.92, 0.94, 0.97)]
+ rel material:binding =
+ }
+
+ def Cube "VisibleBody" (
+ prepend apiSchemas = ["MaterialBindingAPI"]
+ )
+ {
+ double size = 1
+ color3f[] primvars:displayColor = [(0.92, 0.94, 0.97)]
+ rel material:binding =
+ double3 xformOp:translate = (0.0750, 0, 0)
+ double3 xformOp:scale = (0.0650, 0.0400, 0.0400)
+ uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
+ }
+
+ over "GuideRodUpper"
+ {
+ color3f[] primvars:displayColor = [(0.80, 0.82, 0.86)]
+ rel material:binding =
+ }
+
+ over "GuideRodLower"
+ {
+ color3f[] primvars:displayColor = [(0.80, 0.82, 0.86)]
+ rel material:binding =
+ }
+
+ over "FingerLeftSlide"
+ {
+ over "Carrier"
+ {
+ color3f[] primvars:displayColor = [(0.80, 0.82, 0.86)]
+ rel material:binding =
+ }
+ }
+
+ over "FingerRightSlide"
+ {
+ over "Carrier"
+ {
+ color3f[] primvars:displayColor = [(0.80, 0.82, 0.86)]
+ rel material:binding =
+ }
+ }
+
+ def Scope "BinPickingMaterials"
+ {
+ def Material "BodyWhite"
+ {
+ token outputs:surface.connect =
+ def Shader "Surface"
+ {
+ uniform token info:id = "UsdPreviewSurface"
+ color3f inputs:diffuseColor = (0.92, 0.94, 0.97)
+ float inputs:roughness = 0.42
+ float inputs:metallic = 0.05
+ token outputs:surface
+ }
+ }
+
+ def Material "ConnectorSilver"
+ {
+ token outputs:surface.connect =
+ def Shader "Surface"
+ {
+ uniform token info:id = "UsdPreviewSurface"
+ color3f inputs:diffuseColor = (0.80, 0.82, 0.86)
+ float inputs:roughness = 0.28
+ float inputs:metallic = 0.65
+ token outputs:surface
+ }
+ }
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingAgentInferenceProvider.cs b/samples/Robotics/BinPickingCell/BinPickingAgentInferenceProvider.cs
new file mode 100644
index 0000000000..baac53b269
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingAgentInferenceProvider.cs
@@ -0,0 +1,792 @@
+/* ========================================================================
+ * 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 Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.Server;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Off-server perception path. Combined
+ /// and
+ /// that publishes results the
+ /// Server itself did not compute — the "agent-as-VLM" story of
+ /// clause 8.2, where inference runs outside the Server and the
+ /// results arrive over §9 feedback (SubmitDetections,
+ /// SubmitCorrection).
+ ///
+ ///
+ ///
+ /// Registered on the pipeline when the run selects
+ /// --inferenceLocation EdgeOffServer; the on-server ground
+ /// truth is used otherwise. Only one path is active at a time so
+ /// the pipeline's advertised inference-location facet
+ /// (VIS-Inference-OnServer vs
+ /// VIS-Inference-OffServer, derived from the pipeline
+ /// builder's onServer flag) says honestly which path is in
+ /// force.
+ ///
+ ///
+ /// refuses with
+ /// in this mode: the
+ /// point of the off-server path is that the Server does not have
+ /// a local model. Results arrive through the sink methods; the
+ /// message that comes back with the refusal spells that out for
+ /// the agent so it is not silent.
+ ///
+ ///
+ /// Submissions are validated, not trusted: a language model can
+ /// hallucinate a class label that is not a part in this cell, a
+ /// confidence outside 0..1, a bounding box outside the image, or
+ /// so many detections it cannot be a plausible bin. The provider
+ /// refuses the submission with
+ /// and a
+ /// that says WHY — the same message
+ /// the agent's tool sees — rather than silently trimming the
+ /// bad input into something that looks acceptable. §9 says the
+ /// Server refuses purposes it does not permit; malformed content
+ /// belongs to the same "refuse-with-a-reason" surface.
+ ///
+ ///
+ /// Every result carries the two provenance signals the address
+ /// space models:
+ ///
+ /// - is
+ /// agent-off-server-1 (or the exact model tag when a
+ /// real REST VLM answered), so for
+ /// the ground-truth path and this one are distinct enough for a
+ /// reader to tell them apart on that field alone.
+ /// - is
+ /// , a URN that names this sink and
+ /// the submission's purpose so a consumer can trace back what
+ /// produced the value.
+ ///
+ /// A correction publishes a fresh DetectionResultType whose
+ /// ExplanationUri encodes the corrected result's id — this
+ /// is the specification's learning path (a failed pick becomes a
+ /// labelled sample) and it is wired even if nothing consumes it
+ /// yet.
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1812",
+ Justification = "Instantiated by the DI container via AddSingleton.")]
+ internal sealed class BinPickingAgentInferenceProvider
+ : IVisionInferenceProvider, IVisionFeedbackSink
+ {
+ public BinPickingAgentInferenceProvider(
+ IBinPickingTargetProvider targetProvider,
+ ILogger logger)
+ {
+ m_targetProvider = targetProvider ?? throw new ArgumentNullException(nameof(targetProvider));
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// True when the provider has been bound to a pipeline. Consumed
+ /// by the proof hosted service to know when it is safe to run.
+ ///
+ public bool IsAttached => m_target != null;
+
+ ///
+ /// The bound pipeline's node id, or
+ /// before .
+ ///
+ public NodeId PipelineNodeId => m_target?.PipelineNodeId ?? NodeId.Null;
+
+ ///
+ /// The sensor node id the pipeline was configured against, or
+ /// before .
+ ///
+ public NodeId SensorNodeId => m_target?.SensorNodeId ?? NodeId.Null;
+
+ ///
+ /// The deployment node id the pipeline was configured against,
+ /// or before .
+ ///
+ public NodeId DeploymentNodeId => m_target?.DeploymentNodeId ?? NodeId.Null;
+
+ ///
+ /// Camera-in-world pose the pipeline was configured with. The
+ /// proof service composes a submitted camera-frame pose to
+ /// world through this so it does not have to walk the address
+ /// space.
+ ///
+ public VisionPose3DDataType CameraInWorldPose =>
+ m_target?.CameraInWorld ?? new VisionPose3DDataType();
+
+ ///
+ /// The camera frame the pipeline is calibrated against —
+ /// exposed so the proof can label the frame of a synthesised
+ /// pose the way the on-server provider labels its own.
+ ///
+ public string CameraFrameId => m_target?.CameraFrameId ?? string.Empty;
+
+ ///
+ /// Camera intrinsics width in pixels, exposed so the proof can
+ /// generate an in-bounds bounding box without duplicating the
+ /// configuration.
+ ///
+ public double ImageWidth => m_target?.ImageWidth ?? 0.0;
+
+ ///
+ /// Camera intrinsics height in pixels.
+ ///
+ public double ImageHeight => m_target?.ImageHeight ?? 0.0;
+
+ ///
+ /// Looks up a previously-published result by its identifier.
+ /// The correction path calls this to confirm the target
+ /// result actually exists before publishing the correction.
+ ///
+ public bool TryGetResult(string resultId, out DetectionResultState state)
+ {
+ return m_results.TryGetValue(resultId, out state!);
+ }
+
+ ///
+ /// The identifier of the most recent detection result
+ /// published through . Empty
+ /// when no submission has been accepted yet. Corrections do
+ /// not update this — the proof service relies on it to find
+ /// the id it just published so it can inspect the addressed
+ /// result and drive a correction against it.
+ ///
+ public string LastPublishedResultId => m_lastPublishedResultId;
+
+ ///
+ /// Called from the Vision configurator once the pipeline node
+ /// and its Results folder are available.
+ ///
+ /// is null.
+ ///
+ public void Attach(BinPickingInferenceTarget target)
+ {
+ if (target == null)
+ {
+ throw new ArgumentNullException(nameof(target));
+ }
+ if (Interlocked.CompareExchange(ref m_target, target, null) != null)
+ {
+ throw new InvalidOperationException(
+ "BinPickingAgentInferenceProvider has already been attached to a pipeline.");
+ }
+ m_logger.AgentSinkAttached(
+ target.PipelineNodeId.IsNull ? string.Empty : target.PipelineNodeId.ToString());
+ }
+
+ ///
+ public ValueTask RunInferenceAsync(
+ VisionInferenceRunRequest request,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // Off-server perception has no server-side computation to perform. Rather than
+ // silently return an empty result the agent might mistake for "nothing found",
+ // spell out that submissions arrive through the Feedback object. The message is
+ // the one an MCP tool surfaces to the calling model.
+ var result = new VisionInferenceRunResult(
+ new ServiceResult(
+ StatusCodes.BadNotSupported,
+ LocalizedText.From(
+ "This pipeline is configured for off-server perception (InferenceLocation=" +
+ "EdgeOffServer). Publish results by calling SubmitDetections on the " +
+ "pipeline's Feedback object.")),
+ string.Empty);
+ return ValueTask.FromResult(result);
+ }
+
+ ///
+ public ValueTask StartContinuousAsync(
+ NodeId pipeline, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // Continuous inference is driven by the agent's cadence in this mode; there is no
+ // Server-side clock to start. Refusing preserves the invariant "InferenceLocation
+ // says honestly which path is in force" — a Good response would suggest the Server
+ // was polling something it is not.
+ return ValueTask.FromResult(new ServiceResult(
+ StatusCodes.BadNotSupported,
+ LocalizedText.From(
+ "Continuous inference is not supported when InferenceLocation=EdgeOffServer. " +
+ "The off-server agent drives its own cadence and publishes results through " +
+ "SubmitDetections.")));
+ }
+
+ ///
+ public ValueTask StopAsync(
+ NodeId pipeline, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return ValueTask.FromResult(ServiceResult.Good);
+ }
+
+ ///
+ public async ValueTask SubmitDetectionsAsync(
+ VisionSubmitDetectionsRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (request == null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ BinPickingInferenceTarget target = RequireTarget();
+ if (!request.Pipeline.Equals(target.PipelineNodeId))
+ {
+ return Refuse(
+ "SubmitDetections",
+ StatusCodes.BadNodeIdUnknown,
+ "The pipeline node id does not match the attached off-server pipeline.");
+ }
+ // Part 9.5 pairs the array with the flag. SceneIsEmpty is how the agent
+ // reports an emptied bin - the terminating condition of the pick loop -
+ // so an empty array is accepted exactly when it is set. The dispatcher
+ // checks this too; the check is kept so the provider is correct when
+ // driven directly.
+ ServiceResult? refusal = ValidateDetections(
+ target, request.Detections, allowEmpty: request.SceneIsEmpty);
+ if (refusal != null)
+ {
+ return refusal;
+ }
+ if (!IsKnownPurpose(request.Purpose))
+ {
+ return Refuse(
+ "SubmitDetections",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Purpose '{request.Purpose}' is not a defined VisionFeedbackPurposeEnum value."));
+ }
+ string modelTag = ResolveModelTag(request.FrameReference);
+ string modelVersion = FormattableString.Invariant($"agent-off-server:{modelTag}");
+ string explanation = FormattableString.Invariant(
+ $"{ExplanationUri}?purpose={request.Purpose}&source={Uri.EscapeDataString(modelTag)}");
+ string resultId = "det-agent-" + Guid.NewGuid().ToString("N");
+ DateTimeUtc timestamp = DateTimeUtc.From(DateTime.UtcNow);
+ try
+ {
+ m_targetProvider.PublishDetections(
+ resultId,
+ timestamp,
+ request.Detections,
+ target.CameraInWorld,
+ target.CameraFrameId);
+ await PublishAsync(
+ target,
+ resultId,
+ timestamp,
+ request.Detections,
+ modelVersion,
+ explanation,
+ request.FrameReference,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (ServiceResultException ex)
+ {
+ // The frame maths raises BadInvalidArgument for a zero-norm quaternion rather
+ // than substituting identity. Surface it as a clean refusal so the agent sees
+ // the same shape of refusal it would for any other invalid input.
+ return Refuse("SubmitDetections", ex.StatusCode, ex.Message);
+ }
+ m_logger.AgentDetectionsPublished(
+ resultId, request.Detections.Count, request.Purpose, modelTag);
+ m_lastPublishedResultId = resultId;
+ return ServiceResult.Good;
+ }
+
+ ///
+ public ValueTask SubmitInspectionResultAsync(
+ VisionSubmitInspectionResultRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (request == null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ // The bin-picking cell is a detection cell, not an inspection cell. A model
+ // submitting an inspection is confused about what pipeline this is, so refuse
+ // rather than accept-and-silently-discard.
+ return ValueTask.FromResult(new ServiceResult(
+ StatusCodes.BadNotSupported,
+ LocalizedText.From(
+ "This pipeline exposes DetectionResultType results only. Use " +
+ "SubmitDetections or SubmitCorrection with corrected detections.")));
+ }
+
+ ///
+ public async ValueTask SubmitCorrectionAsync(
+ VisionSubmitCorrectionRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (request == null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ BinPickingInferenceTarget target = RequireTarget();
+ if (!request.Pipeline.Equals(target.PipelineNodeId))
+ {
+ return Refuse(
+ "SubmitCorrection",
+ StatusCodes.BadNodeIdUnknown,
+ "The pipeline node id does not match the attached off-server pipeline.");
+ }
+ if (string.IsNullOrEmpty(request.ResultId))
+ {
+ return Refuse(
+ "SubmitCorrection",
+ StatusCodes.BadInvalidArgument,
+ "ResultId is required and must name the result being corrected.");
+ }
+ if (!m_results.ContainsKey(request.ResultId))
+ {
+ return Refuse(
+ "SubmitCorrection",
+ StatusCodes.BadNodeIdUnknown,
+ FormattableString.Invariant(
+ $"ResultId '{request.ResultId}' does not name a result on this pipeline."));
+ }
+ if (request.CorrectedCharacteristics.Count > 0)
+ {
+ return Refuse(
+ "SubmitCorrection",
+ StatusCodes.BadNotSupported,
+ "This pipeline publishes DetectionResultType only; corrections must carry " +
+ "CorrectedDetections and not CorrectedCharacteristics.");
+ }
+ // Part 9.5 asks for at most one non-empty corrected array, and both empty
+ // is the false-positive retraction when RetractAll says so. The dispatcher
+ // checks this too; the check is kept so the provider is correct when
+ // driven directly.
+ ServiceResult? refusal = ValidateDetections(
+ target, request.CorrectedDetections, allowEmpty: request.RetractAll);
+ if (refusal != null)
+ {
+ return refusal;
+ }
+ if (!IsKnownPurpose(request.Purpose))
+ {
+ return Refuse(
+ "SubmitCorrection",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Purpose '{request.Purpose}' is not a defined VisionFeedbackPurposeEnum value."));
+ }
+ string reason = request.Reason.IsNull
+ ? string.Empty
+ : request.Reason.Text ?? string.Empty;
+ string correctionResultId = "det-agent-correction-" + Guid.NewGuid().ToString("N");
+ DateTimeUtc timestamp = DateTimeUtc.From(DateTime.UtcNow);
+ const string modelVersion = "agent-off-server:correction";
+ string explanation = FormattableString.Invariant(
+ $"{ExplanationUri}?purpose={request.Purpose}&corrects={Uri.EscapeDataString(request.ResultId)}");
+ try
+ {
+ await PublishAsync(
+ target,
+ correctionResultId,
+ timestamp,
+ request.CorrectedDetections,
+ modelVersion,
+ explanation,
+ frameReference: null,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (ServiceResultException ex)
+ {
+ return Refuse("SubmitCorrection", ex.StatusCode, ex.Message);
+ }
+ m_logger.AgentCorrectionPublished(
+ correctionResultId,
+ request.ResultId,
+ request.CorrectedDetections.Count,
+ reason);
+ return ServiceResult.Good;
+ }
+
+ ///
+ public ValueTask SubmitImageReferenceAsync(
+ VisionSubmitImageReferenceRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (request == null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ // A pure image submission without detections has no home on the pipeline's
+ // Results folder — the address space stores images alongside a result, never on
+ // their own. Accept the call so the address space stays consistent with §9.5,
+ // record the pointer, and let a consumer that cares walk it out of band.
+ m_logger.AgentImageReference(
+ request.Image?.Uri ?? string.Empty,
+ request.ResultId ?? string.Empty,
+ request.Purpose);
+ return ValueTask.FromResult(ServiceResult.Good);
+ }
+
+ private BinPickingInferenceTarget RequireTarget()
+ {
+ BinPickingInferenceTarget? target = m_target;
+ return target ??
+ throw new InvalidOperationException(
+ "BinPickingAgentInferenceProvider has not been attached to a pipeline.");
+ }
+
+ private ServiceResult? ValidateDetections(
+ BinPickingInferenceTarget target,
+ ArrayOf detections,
+ bool allowEmpty)
+ {
+ if (!allowEmpty && detections.Count == 0)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ "At least one detection is required.");
+ }
+ if (detections.Count > MaxDetectionsPerSubmission)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"{detections.Count} detections exceeds the plausible ceiling of {MaxDetectionsPerSubmission} for this bin (five parts \u00d7 three)."));
+ }
+ for (int ii = 0; ii < detections.Count; ii++)
+ {
+ VisionDetectionDataType detection = detections[ii];
+ if (string.IsNullOrEmpty(detection.ClassLabel))
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {ii} has no ClassLabel."));
+ }
+ if (BinPickingPartsCatalog.TryGet(detection.ClassLabel) == null)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {ii} class '{detection.ClassLabel}' is not a part in this cell. Known classes: RedCube, GreenCylinder, BlueSphere, YellowSlab, OrangeBrick."));
+ }
+ double confidence = detection.Confidence;
+ if (double.IsNaN(confidence) ||
+ confidence < 0.0 ||
+ confidence > 1.0)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {ii} confidence {confidence:0.###} is outside [0, 1]."));
+ }
+ if (detection.HasBoundingBox2D)
+ {
+ ServiceResult? boxRefusal = ValidateBoundingBox2D(target, ii, detection.BoundingBox2D);
+ if (boxRefusal != null)
+ {
+ return boxRefusal;
+ }
+ }
+ if (detection.HasPose)
+ {
+ ServiceResult? poseRefusal = ValidatePose(target, ii, detection.Pose);
+ if (poseRefusal != null)
+ {
+ return poseRefusal;
+ }
+ }
+ }
+ return null;
+ }
+
+ private ServiceResult? ValidateBoundingBox2D(
+ BinPickingInferenceTarget target,
+ int index,
+ VisionBoundingBox2DDataType box)
+ {
+ if (double.IsNaN(box.CenterX) ||
+ double.IsNaN(box.CenterY) ||
+ double.IsNaN(box.Width) ||
+ double.IsNaN(box.Height))
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {index} BoundingBox2D contains NaN."));
+ }
+ if (box.Width <= 0.0 || box.Height <= 0.0)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {index} BoundingBox2D has non-positive extents (w={box.Width:0.##}, h={box.Height:0.##})."));
+ }
+ double halfW = box.Width * 0.5;
+ double halfH = box.Height * 0.5;
+ double minU = box.CenterX - halfW;
+ double maxU = box.CenterX + halfW;
+ double minV = box.CenterY - halfH;
+ double maxV = box.CenterY + halfH;
+ if (maxU <= 0.0 ||
+ minU >= target.ImageWidth ||
+ maxV <= 0.0 ||
+ minV >= target.ImageHeight)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {index} BoundingBox2D (cx={box.CenterX:0.#}, cy={box.CenterY:0.#}, w={box.Width:0.#}, h={box.Height:0.#}) lies entirely outside the {target.ImageWidth:0}x{target.ImageHeight:0} image."));
+ }
+ return null;
+ }
+
+ private ServiceResult? ValidatePose(
+ BinPickingInferenceTarget target,
+ int index,
+ VisionPose3DDataType pose)
+ {
+ if (!string.Equals(pose.FrameId, target.CameraFrameId, StringComparison.Ordinal))
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ $"Detection {index} Pose.FrameId '{pose.FrameId}' does not match " +
+ $"the calibrated camera frame '{target.CameraFrameId}'.");
+ }
+ System.ReadOnlySpan orientation = pose.Orientation.Span;
+ if (orientation.Length < 4)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {index} Pose.Orientation must carry four components (x, y, z, w) per \u00a75.12."));
+ }
+ double normSq = (orientation[0] * orientation[0]) +
+ (orientation[1] * orientation[1]) +
+ (orientation[2] * orientation[2]) +
+ (orientation[3] * orientation[3]);
+ if (normSq <= 0.0)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {index} Pose.Orientation has zero norm and does not describe a rotation."));
+ }
+ System.ReadOnlySpan position = pose.Position.Span;
+ if (position.Length < 3)
+ {
+ return Refuse(
+ "Validate",
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {index} Pose.Position must carry three components."));
+ }
+ return null;
+ }
+
+ private static bool IsKnownPurpose(VisionFeedbackPurposeEnum purpose)
+ {
+ return purpose switch
+ {
+ VisionFeedbackPurposeEnum.Overlay => true,
+ VisionFeedbackPurposeEnum.Reconciliation => true,
+ VisionFeedbackPurposeEnum.GroundTruthLabel => true,
+ VisionFeedbackPurposeEnum.Trigger => true,
+ _ => false
+ };
+ }
+
+ private static string ResolveModelTag(VisionImageReferenceDataType? frameReference)
+ {
+ // A submission's frame reference carries an optional model tag in its URI when
+ // an MCP tool includes one; keep the wire simple and just report the URI or a
+ // placeholder. Nothing here consumes it, but it flows to ModelVersionUsed so a
+ // consumer can distinguish a hand-driven submission from a real VLM one.
+ if (frameReference == null || string.IsNullOrEmpty(frameReference.Uri))
+ {
+ return "unspecified";
+ }
+ return frameReference.Uri;
+ }
+
+ private async Task PublishAsync(
+ BinPickingInferenceTarget target,
+ string resultId,
+ DateTimeUtc timestamp,
+ ArrayOf detections,
+ string modelVersion,
+ string explanationUri,
+ VisionImageReferenceDataType? frameReference,
+ CancellationToken cancellationToken)
+ {
+ ISystemContext context = target.SystemContext;
+ var qualifiedName = new QualifiedName(resultId, target.InstanceNamespaceIndex);
+ DetectionResultState state = context.CreateInstanceOfDetectionResultType(
+ target.ResultsFolder, qualifiedName);
+ state.ReferenceTypeId = 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 = target.SensorNodeId;
+ state.CreateOrReplacePipeline(context, null).Value = target.PipelineNodeId;
+ state.CreateOrReplaceModelVersionUsed(context, null).Value = modelVersion;
+ state.CreateOrReplaceConfidence(context, null).Value = ComputeAggregateConfidence(detections);
+ state.CreateOrReplaceExplanationUri(context, null).Value = explanationUri;
+ BaseDataVariableState frame =
+ state.CreateOrReplaceFrame(context, null);
+ frame.Value = frameReference ??
+ new VisionImageReferenceDataType
+ {
+ Uri = FormattableString.Invariant(
+ $"opcua-inline://binpicking-cell/frames/{resultId}"),
+ Digest = ByteString.Empty,
+ DigestAlgorithm = string.Empty,
+ Format = VisionClipFormatEnum.Png,
+ PixelFormat = target.PixelFormat,
+ Width = (uint)Math.Round(target.ImageWidth),
+ Height = (uint)Math.Round(target.ImageHeight),
+ 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 static double ComputeAggregateConfidence(
+ ArrayOf detections)
+ {
+ if (detections.Count == 0)
+ {
+ return 0.0;
+ }
+ double sum = 0.0;
+ for (int ii = 0; ii < detections.Count; ii++)
+ {
+ sum += detections[ii].Confidence;
+ }
+ return sum / detections.Count;
+ }
+
+ private ServiceResult Refuse(string operation, StatusCode code, string message)
+ {
+ m_logger.AgentRefused(operation, code.Code, message);
+ return new ServiceResult(code, LocalizedText.From(message));
+ }
+
+ private const string ExplanationUri = "urn:opcfoundation:BinPickingCell:vision:agent-off-server";
+ private const int MaxDetectionsPerSubmission = 15;
+
+ private readonly ILogger m_logger;
+ private readonly IBinPickingTargetProvider m_targetProvider;
+
+ private readonly ConcurrentDictionary m_results
+ = new(StringComparer.Ordinal);
+
+ private BinPickingInferenceTarget? m_target;
+ private string m_lastPublishedResultId = string.Empty;
+ }
+
+ internal static partial class BinPickingAgentInferenceProviderLog
+ {
+ [LoggerMessage(EventId = BinPickingCellEventIds.Agent + 1,
+ Level = LogLevel.Information,
+ Message = "Bin-picking agent-driven off-server perception attached to pipeline {PipelineNodeId}.")]
+ public static partial void AgentSinkAttached(
+ this ILogger logger,
+ string pipelineNodeId);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Agent + 2,
+ Level = LogLevel.Information,
+ Message = "Agent SubmitDetections published result {ResultId} " +
+ "({DetectionCount} detections, purpose={Purpose}, source={ModelTag}).")]
+ public static partial void AgentDetectionsPublished(
+ this ILogger logger,
+ string resultId, int detectionCount, VisionFeedbackPurposeEnum purpose, string modelTag);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Agent + 3,
+ Level = LogLevel.Information,
+ Message = "Agent SubmitCorrection published result {CorrectionResultId} " +
+ "correcting {OriginalResultId} ({DetectionCount} detections, reason='{Reason}').")]
+ public static partial void AgentCorrectionPublished(
+ this ILogger logger,
+ string correctionResultId, string originalResultId,
+ int detectionCount, string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Agent + 4,
+ Level = LogLevel.Information,
+ Message = "Agent SubmitImageReference: uri={Uri} resultId={ResultId} purpose={Purpose}.")]
+ public static partial void AgentImageReference(
+ this ILogger logger,
+ string uri, string resultId, VisionFeedbackPurposeEnum purpose);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Agent + 5,
+ Level = LogLevel.Warning,
+ Message = "Agent {Operation} refused with code 0x{StatusCode:X8}: {Reason}.")]
+ public static partial void AgentRefused(
+ this ILogger logger,
+ string operation, uint statusCode, string reason);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingCaptureProof.cs b/samples/Robotics/BinPickingCell/BinPickingCaptureProof.cs
new file mode 100644
index 0000000000..b2c86c9a7e
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingCaptureProof.cs
@@ -0,0 +1,328 @@
+/* ========================================================================
+ * 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.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua.Vision.OpenUsd;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Renders one frame of the cell stage as soon as the server has
+ /// started, saves it as a PNG outside the source tree, and reports
+ /// how many distinct colours the frame contains and what the mean
+ /// RGB in each of the five part regions looks like.
+ ///
+ ///
+ ///
+ /// The purpose of the sample is to prove that the scene renders in
+ /// colour and that the mixed parts are visually distinguishable —
+ /// the demo it feeds is a language model looking at the frame and
+ /// picking "the red part". A blank or white frame would silently
+ /// destroy that. This service therefore also validates the guard
+ /// the OpenUSD capture provider enforces on drawn-geometry counts,
+ /// and treats a
+ /// result as a soft warning (typical of a CI host with no graphics
+ /// device) rather than a fatal error.
+ ///
+ ///
+ /// The service does not stop the host on any outcome. Set the
+ /// captureOnStartup=false configuration key to skip the
+ /// diagnostic entirely.
+ ///
+ ///
+ internal sealed class BinPickingCaptureProof : BackgroundService
+ {
+ public BinPickingCaptureProof(
+ ISceneCameraCaptureProvider capture,
+ BinPickingCellStage stage,
+ ILogger logger,
+ bool enabled,
+ string? artifactDirectory)
+ {
+ m_capture = capture ?? throw new ArgumentNullException(nameof(capture));
+ m_stage = stage ?? throw new ArgumentNullException(nameof(stage));
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ m_enabled = enabled;
+ m_artifactDirectory = string.IsNullOrEmpty(artifactDirectory)
+ ? Path.Combine(Path.GetTempPath(), "OPCFoundation", "BinPickingCell")
+ : artifactDirectory;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (!m_enabled)
+ {
+ m_logger.CaptureSkipped();
+ return;
+ }
+ m_logger.CaptureProofStarted(m_capture.Backend);
+ var request = new SceneCameraCaptureRequest
+ {
+ StageIdentifier = m_stage.CellStagePath,
+ PrimPath = BinPickingVisionCell.CameraPrimPath,
+ Width = ProofWidth,
+ Height = ProofHeight,
+ TimeCode = 0.0,
+ Format = SceneCameraImageFormat.Png,
+ TimestampUtc = DateTime.UtcNow
+ };
+ SceneCameraCaptureResult result = await m_capture
+ .CaptureAsync(request, stoppingToken)
+ .ConfigureAwait(false);
+ if (result.Status != SceneCameraCaptureStatus.Succeeded)
+ {
+ m_logger.CaptureProofNoImage(result.Status, result.Reason ?? string.Empty);
+ return;
+ }
+ byte[] png = result.Image.ToArray();
+ Directory.CreateDirectory(m_artifactDirectory);
+ string outPath = Path.Combine(m_artifactDirectory, "bin-picking-frame.png");
+ await File.WriteAllBytesAsync(outPath, png, stoppingToken).ConfigureAwait(false);
+
+ (byte[] Rgba, int W, int H) decoded;
+ try
+ {
+ decoded = PngDecoder.Decode(png);
+ }
+ catch (Exception ex)
+ {
+ m_logger.CaptureProofDecodeFailed(outPath, ex.Message);
+ return;
+ }
+
+ int distinct = CountDistinctColours(decoded.Rgba);
+ (int mR, int mG, int mB) = MeanRgb(decoded.Rgba, decoded.W, decoded.H);
+ List<(string Label, int R, int G, int B)> partSamples = SamplePartRegions(
+ decoded.Rgba, decoded.W, decoded.H);
+
+ m_logger.CaptureProofSaved(
+ outPath, decoded.W, decoded.H, png.Length, distinct, mR, mG, mB);
+ foreach ((string label, int r, int g, int b) in partSamples)
+ {
+ m_logger.CaptureProofPart(label, r, g, b);
+ }
+
+ AppendReport(m_artifactDirectory, outPath, decoded, distinct, mR, mG, mB, partSamples,
+ m_capture.Backend, result.Elapsed);
+ }
+
+ private static int CountDistinctColours(byte[] rgba)
+ {
+ var seen = new HashSet();
+ for (int ii = 0; ii + 3 < rgba.Length; ii += 4)
+ {
+ int packed = rgba[ii] << 16 | rgba[ii + 1] << 8 | rgba[ii + 2];
+ seen.Add(packed);
+ if (seen.Count > 65536)
+ {
+ return seen.Count;
+ }
+ }
+ return seen.Count;
+ }
+
+ private static (int R, int G, int B) MeanRgb(byte[] rgba, int width, int height)
+ {
+ long r = 0;
+ long g = 0;
+ long b = 0;
+ long count = 0;
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ int offset = 4 * ((y * width) + x);
+ r += rgba[offset];
+ g += rgba[offset + 1];
+ b += rgba[offset + 2];
+ count++;
+ }
+ }
+ if (count == 0)
+ {
+ return (0, 0, 0);
+ }
+ return ((int)(r / count), (int)(g / count), (int)(b / count));
+ }
+
+ private static List<(string Label, int R, int G, int B)> SamplePartRegions(
+ byte[] rgba, int width, int height)
+ {
+ var samples = new List<(string, int, int, int)>();
+ foreach ((string label, double fx, double fy) in s_partSampleFractions)
+ {
+ int cx = (int)Math.Clamp(Math.Round(fx * width), 0.0, width - 1);
+ int cy = (int)Math.Clamp(Math.Round(fy * height), 0.0, height - 1);
+ (int r, int g, int b) = AverageWindow(rgba, width, height, cx, cy, radius: 20);
+ samples.Add((label, r, g, b));
+ }
+ return samples;
+ }
+
+ private static (int R, int G, int B) AverageWindow(
+ byte[] rgba, int width, int height, int cx, int cy, int radius)
+ {
+ long r = 0;
+ long g = 0;
+ long b = 0;
+ long count = 0;
+ int x0 = Math.Max(0, cx - radius);
+ int x1 = Math.Min(width - 1, cx + radius);
+ int y0 = Math.Max(0, cy - radius);
+ int y1 = Math.Min(height - 1, cy + radius);
+ for (int y = y0; y <= y1; y++)
+ {
+ for (int x = x0; x <= x1; x++)
+ {
+ int offset = 4 * ((y * width) + x);
+ r += rgba[offset];
+ g += rgba[offset + 1];
+ b += rgba[offset + 2];
+ count++;
+ }
+ }
+ if (count == 0)
+ {
+ return (0, 0, 0);
+ }
+ return ((int)(r / count), (int)(g / count), (int)(b / count));
+ }
+
+ private static void AppendReport(
+ string directory,
+ string imagePath,
+ (byte[] Rgba, int W, int H) frame,
+ int distinctColours,
+ int meanR, int meanG, int meanB,
+ List<(string Label, int R, int G, int B)> partSamples,
+ SceneCameraCaptureBackend backend,
+ TimeSpan elapsed)
+ {
+ string reportPath = Path.Combine(directory, "bin-picking-frame.report.txt");
+ using StreamWriter writer = File.CreateText(reportPath);
+ CultureInfo culture = CultureInfo.InvariantCulture;
+ writer.WriteLine("BinPickingCell capture proof");
+ writer.WriteLine("============================");
+ writer.WriteLine(string.Format(culture, "Image : {0}", imagePath));
+ writer.WriteLine(string.Format(culture, "Dimensions : {0} x {1}", frame.W, frame.H));
+ writer.WriteLine(string.Format(culture, "Backend : {0}", backend));
+ writer.WriteLine(string.Format(culture, "Elapsed : {0:0.0} ms", elapsed.TotalMilliseconds));
+ writer.WriteLine(string.Format(culture, "Distinct RGB colours : {0}", distinctColours));
+ writer.WriteLine(string.Format(culture,
+ "Mean RGB (whole frame): ({0}, {1}, {2})", meanR, meanG, meanB));
+ writer.WriteLine();
+ writer.WriteLine("Part samples (mean RGB of a 41x41 patch around the centroid):");
+ foreach ((string label, int r, int g, int b) in partSamples)
+ {
+ writer.WriteLine(string.Format(culture,
+ " {0,-15} : ({1,3}, {2,3}, {3,3})", label, r, g, b));
+ }
+ }
+
+ ///
+ /// The proof renders at the same resolution the sensor declares and the cell
+ /// delivers, so what it checks is the picture an agent is actually handed.
+ ///
+ private const int ProofWidth = (int)BinPickingVisionCell.SensorWidth;
+ private const int ProofHeight = (int)BinPickingVisionCell.SensorHeight;
+
+ ///
+ /// Where each part projects to in the delivered frame, as a fraction of width and
+ /// height. These are the ground-truth detector's own BoundingBox2D centres divided
+ /// by the frame size, so the proof checks the colour at the pixel the detector
+ /// points an agent at rather than at an independently guessed spot.
+ ///
+ private static readonly (string Label, double Fx, double Fy)[] s_partSampleFractions =
+ [
+ ("RedCube", 0.450, 0.690),
+ ("GreenCylinder", 0.546, 0.375),
+ ("BlueSphere", 0.660, 0.559),
+ ("YellowSlab", 0.406, 0.401),
+ ("OrangeBrick", 0.572, 0.497)
+ ];
+
+ private readonly ISceneCameraCaptureProvider m_capture;
+ private readonly BinPickingCellStage m_stage;
+ private readonly ILogger m_logger;
+ private readonly bool m_enabled;
+ private readonly string m_artifactDirectory;
+ }
+
+ internal static partial class BinPickingCaptureProofLog
+ {
+ [LoggerMessage(EventId = BinPickingCellEventIds.Startup + 1,
+ Level = LogLevel.Information,
+ Message = "Capture-proof diagnostic starting; renderer backend={Backend}.")]
+ public static partial void CaptureProofStarted(
+ this ILogger logger, SceneCameraCaptureBackend backend);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Startup + 2,
+ Level = LogLevel.Information,
+ Message = "Capture-proof diagnostic disabled by configuration (captureOnStartup=false).")]
+ public static partial void CaptureSkipped(this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Startup + 3,
+ Level = LogLevel.Warning,
+ Message = "Capture-proof diagnostic did not produce a frame: {Status} - {Reason}.")]
+ public static partial void CaptureProofNoImage(
+ this ILogger logger,
+ SceneCameraCaptureStatus status,
+ string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Startup + 4,
+ Level = LogLevel.Warning,
+ Message = "Capture-proof frame saved to {Path} but PNG decoder failed: {Reason}.")]
+ public static partial void CaptureProofDecodeFailed(
+ this ILogger logger, string path, string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Startup + 5,
+ Level = LogLevel.Information,
+ Message = "Capture-proof frame {Width}x{Height} ({Bytes} bytes) saved to {Path}; " +
+ "distinct RGB colours={Distinct}; mean RGB=({MeanR},{MeanG},{MeanB}).")]
+ public static partial void CaptureProofSaved(
+ this ILogger logger,
+ string path,
+ int width, int height, int bytes,
+ int distinct, int meanR, int meanG, int meanB);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Startup + 6,
+ Level = LogLevel.Information,
+ Message = "Part '{Label}' mean RGB = ({R},{G},{B}).")]
+ public static partial void CaptureProofPart(
+ this ILogger logger,
+ string label, int r, int g, int b);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingCell.csproj b/samples/Robotics/BinPickingCell/BinPickingCell.csproj
new file mode 100644
index 0000000000..4256b5f972
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingCell.csproj
@@ -0,0 +1,63 @@
+
+
+ net10.0
+ Exe
+ false
+ BinPickingCell
+ BinPickingCell
+ Bin-picking work cell that hosts both the Robot Intent and Vision node managers with an eye-in-hand camera. Reproduces the OPC UA Robotics-Vision Addendum's worked example against the OPC UA .NET Standard stack.
+ Vision.BinPickingCell
+ enable
+
+ $(NoWarn);CA1014;CA1822;CA1812
+
+ false
+ true
+ win-x64
+ false
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cell.usda
+
+ palletizer-arm.usda
+
+ palletizer-gripper.usda
+ gripper.usda
+
+
diff --git a/samples/Robotics/BinPickingCell/BinPickingCellGeometry.cs b/samples/Robotics/BinPickingCell/BinPickingCellGeometry.cs
new file mode 100644
index 0000000000..5aaccccb0b
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingCellGeometry.cs
@@ -0,0 +1,120 @@
+/* ========================================================================
+ * 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 Opc.Ua;
+using Robotics.IntentEnabledRobot.Simulation;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// The cell's dimensions and furniture as solids the arm must not move through,
+ /// expressed in the arm's base frame.
+ ///
+ ///
+ ///
+ /// The numbers mirror Assets/Cell.usda. The arm's base frame is on a riser at
+ /// world z = 0.920 while the bench top is at z = 0.720, so the work surface is
+ /// 0.200 m below the base-frame origin.
+ ///
+ ///
+ /// Only the bench and the bin walls are declared. The fixture plate and its locating
+ /// pegs stand a few millimetres proud of the bench and are exactly where the tool has
+ /// to work, so treating them as obstacles would refuse the placements the cell exists
+ /// to perform without changing what the arm visibly does wrong.
+ ///
+ ///
+ internal static class BinPickingCellGeometry
+ {
+ ///
+ /// Height of the work surface in the world frame.
+ ///
+ public const double BenchTopMetres = 0.720;
+
+ ///
+ /// Height of the robot's base-frame origin in the world frame.
+ ///
+ public const double RobotBaseHeightMetres = 0.920;
+
+ ///
+ /// Centre of the fixture in the world frame.
+ ///
+ public const double FixtureCentreX = BinPickingPartsCatalog.FixtureCentreX;
+
+ ///
+ /// Top face of the fixture plate in the world frame.
+ ///
+ public const double FixturePlateTopMetres = BenchTopMetres + 0.009;
+
+ ///
+ /// Top face of the fixture locating pegs in the world frame.
+ ///
+ public const double FixturePegTopMetres = FixturePlateTopMetres + 0.040;
+ public const double FixturePegOffsetMetres = 0.050;
+
+ ///
+ /// Builds the collision model the arm's solver checks its configurations against.
+ ///
+ public static SimulatedCollisionModel CreateCollisionModel()
+ {
+ return new SimulatedCollisionModel(
+ ArrayOf.Create(s_obstacles.AsSpan()),
+ ArrayOf.Create(s_segmentRadii.AsSpan()));
+ }
+
+ ///
+ /// Point pairs are J1->J2 (same shoulder origin), shoulder->elbow,
+ /// elbow->wrist, and wrist->TCP.
+ ///
+ private static readonly double[] s_segmentRadii = [0.0, 0.047, 0.042, 0.018];
+
+ ///
+ /// Bench top: world z 0.650 to 0.720, which is 0.200 m below the robot base frame.
+ /// This is the one that stops a forearm crossing the table: the old height test only
+ /// sampled joint origins, which a link can straddle. Its clearance is zero because
+ /// it is a surface, not an obstacle standing on one - a link may come down to it,
+ /// and with the tool vertical this arm's wrist has to.
+ /// Bin walls: world z 0.713 to 0.753, 6 mm thick, around a 0.28 x 0.24 tray centred
+ /// on the Bin location. These are objects, so they keep the full link radius. The
+ /// tray's inside is deliberately left open so the tool can still descend into it.
+ ///
+ private static readonly SimulatedObstacleBox[] s_obstacles =
+ [
+ new("Bench", 0.0, 0.0, 1.4000, 0.9000, -0.2700, -0.2000, Clearance: 0.0),
+ new("BinWallN", BinPickingPartsCatalog.BinCentreX, 0.1170,
+ 0.2800, 0.0060, -0.2070, -0.1670),
+ new("BinWallS", BinPickingPartsCatalog.BinCentreX, -0.1170,
+ 0.2800, 0.0060, -0.2070, -0.1670),
+ new("BinWallE", BinPickingPartsCatalog.BinCentreX + 0.1370, 0.0000,
+ 0.0060, 0.2400, -0.2070, -0.1670),
+ new("BinWallW", BinPickingPartsCatalog.BinCentreX - 0.1370, 0.0000,
+ 0.0060, 0.2400, -0.2070, -0.1670)
+ ];
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingCellOptions.cs b/samples/Robotics/BinPickingCell/BinPickingCellOptions.cs
new file mode 100644
index 0000000000..e84a75e825
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingCellOptions.cs
@@ -0,0 +1,124 @@
+/* ========================================================================
+ * 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;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Where inference runs for the sample's pipeline. Selected once
+ /// at startup and pinned for the lifetime of the process — the
+ /// pipeline's advertised inference-location facet is derived from
+ /// this and cannot change afterwards, so it "says honestly" which
+ /// path is in force.
+ ///
+ ///
+ ///
+ /// The value maps onto the specification's InferenceLocation
+ /// concept: the on-server ground truth is
+ /// and the agent-as-VLM path is
+ /// . The two are mutually exclusive by
+ /// construction — the pipeline binds a single inference provider
+ /// and (optionally) a single feedback sink; wiring both at once
+ /// would let a submitted result and a computed result publish on
+ /// the same pipeline out of any known order, which is the
+ /// invariant the option prevents.
+ ///
+ ///
+ internal enum BinPickingInferenceLocation
+ {
+ ///
+ /// The Server computes results locally through the
+ /// deterministic ground-truth detector. The pipeline advertises
+ /// VIS-Inference-OnServer. This is the CI and offline
+ /// default; it needs neither a GPU nor a model.
+ ///
+ OnServer = 0,
+
+ ///
+ /// Inference runs off-Server: an agent connected over MCP
+ /// looks at the frame, decides what it sees, and calls
+ /// SubmitDetections. The pipeline advertises
+ /// VIS-Inference-OffServer and publishes results the
+ /// Server itself did not compute.
+ ///
+ EdgeOffServer = 1
+ }
+
+ ///
+ /// Startup options for the bin-picking sample. Populated in
+ /// Program.cs from the host configuration and registered as
+ /// a DI singleton so both the vision configurator and the proof
+ /// services pick up the same value.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1812",
+ Justification = "Instantiated by the DI container via AddSingleton.")]
+ internal sealed class BinPickingCellOptions
+ {
+ ///
+ /// Selected inference-location mode. See
+ /// . Defaults to
+ /// .
+ ///
+ public BinPickingInferenceLocation InferenceLocation { get; init; }
+ = BinPickingInferenceLocation.OnServer;
+
+ ///
+ /// Attempts to parse the CLI/config value into a
+ /// . Accepts the
+ /// exact enum names case-insensitively so both
+ /// OnServer and on-server resolve; returns
+ /// for anything else, including empty.
+ ///
+ public static bool TryParseLocation(
+ string? value, out BinPickingInferenceLocation location)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ location = BinPickingInferenceLocation.OnServer;
+ return false;
+ }
+ string normalised = value.Trim().Replace("-", string.Empty, StringComparison.Ordinal);
+ if (string.Equals(normalised, "OnServer", StringComparison.OrdinalIgnoreCase))
+ {
+ location = BinPickingInferenceLocation.OnServer;
+ return true;
+ }
+ if (string.Equals(normalised, "EdgeOffServer", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(normalised, "OffServer", StringComparison.OrdinalIgnoreCase))
+ {
+ location = BinPickingInferenceLocation.EdgeOffServer;
+ return true;
+ }
+ location = BinPickingInferenceLocation.OnServer;
+ return false;
+ }
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingCellStage.cs b/samples/Robotics/BinPickingCell/BinPickingCellStage.cs
new file mode 100644
index 0000000000..3f6958b4ee
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingCellStage.cs
@@ -0,0 +1,157 @@
+/* ========================================================================
+ * 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.IO;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Materialises the embedded USD assets to a stable per-user directory so
+ /// the offscreen renderer can open the stage by file path. The cell layer
+ /// (Cell.usda) references palletizer-arm.usda and gripper.usda
+ /// by relative path, so both sublayers must be extracted next to the root.
+ ///
+ ///
+ /// The output directory sits under
+ /// {LocalApplicationData}/OPCFoundation/UA-.NETStandard/BinPickingCell/<hash>/stage
+ /// where hash is derived from the sample assembly location. That is
+ /// intentional: two side-by-side builds do not overwrite each other's
+ /// assets, and the assets survive across restarts so the renderer plug-in
+ /// cache can be reused. The class overwrites existing files only when
+ /// their content differs, so subsequent runs are cheap.
+ ///
+ internal sealed class BinPickingCellStage
+ {
+ public const string CellStageAsset = "Cell.usda";
+ public const string ArmAsset = "palletizer-arm.usda";
+ public const string PalletizerGripperAsset = "palletizer-gripper.usda";
+ public const string GripperAsset = "gripper.usda";
+
+ ///
+ /// Gets the absolute path to the cell root layer on disk. Only valid
+ /// after has returned.
+ ///
+ public string CellStagePath { get; private set; } = string.Empty;
+
+ ///
+ /// Gets the directory the assets were extracted to.
+ ///
+ public string StageDirectory { get; private set; } = string.Empty;
+
+ ///
+ /// Extracts the embedded USD sublayers to disk and returns the cell
+ /// stage path.
+ ///
+ public string Extract()
+ {
+ Assembly assembly = typeof(BinPickingCellStage).Assembly;
+ string root = ResolveRootDirectory(assembly);
+ Directory.CreateDirectory(root);
+ StageDirectory = root;
+ WriteAssetIfChanged(assembly, CellStageAsset, Path.Combine(root, CellStageAsset));
+ WriteAssetIfChanged(assembly, ArmAsset, Path.Combine(root, ArmAsset));
+ WriteAssetIfChanged(
+ assembly,
+ PalletizerGripperAsset,
+ Path.Combine(root, PalletizerGripperAsset));
+ WriteAssetIfChanged(assembly, GripperAsset, Path.Combine(root, GripperAsset));
+ CellStagePath = Path.Combine(root, CellStageAsset);
+ return CellStagePath;
+ }
+
+ private static void WriteAssetIfChanged(Assembly assembly, string resourceName, string outputPath)
+ {
+ using Stream? stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ {
+ throw new InvalidOperationException(
+ $"Embedded resource '{resourceName}' is missing from the BinPickingCell assembly.");
+ }
+ using var memory = new MemoryStream();
+ stream.CopyTo(memory);
+ byte[] bytes = memory.ToArray();
+ if (File.Exists(outputPath))
+ {
+ byte[] existing = File.ReadAllBytes(outputPath);
+ if (BytesEqual(existing, bytes))
+ {
+ return;
+ }
+ }
+ File.WriteAllBytes(outputPath, bytes);
+ }
+
+ private static bool BytesEqual(byte[] left, byte[] right)
+ {
+ if (left.Length != right.Length)
+ {
+ return false;
+ }
+ for (int ii = 0; ii < left.Length; ii++)
+ {
+ if (left[ii] != right[ii])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static string ResolveRootDirectory(Assembly assembly)
+ {
+ string localAppData = Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData);
+ if (string.IsNullOrEmpty(localAppData))
+ {
+ localAppData = Path.GetTempPath();
+ }
+ string location = assembly.Location;
+ string discriminator = string.IsNullOrEmpty(location)
+ ? AppContext.BaseDirectory
+ : location;
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(discriminator));
+ var builder = new StringBuilder(16);
+ for (int ii = 0; ii < 8; ii++)
+ {
+ builder.Append(hash[ii].ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
+ }
+ return Path.Combine(
+ localAppData,
+ "OPCFoundation",
+ "UA-.NETStandard",
+ "BinPickingCell",
+ builder.ToString(),
+ "stage");
+ }
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingGroundTruthInferenceProvider.cs b/samples/Robotics/BinPickingCell/BinPickingGroundTruthInferenceProvider.cs
new file mode 100644
index 0000000000..19a939cf45
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingGroundTruthInferenceProvider.cs
@@ -0,0 +1,685 @@
+/* ========================================================================
+ * 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 Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.Server;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Deterministic on-server inference provider that derives detections
+ /// from the cell's ground truth: the parts' authored world positions
+ /// (see ) projected through the
+ /// same camera intrinsics the sensor publishes.
+ ///
+ ///
+ ///
+ /// The provider needs neither a model nor a GPU: it reads
+ /// on every tick, keeps
+ /// only parts that are still
+ /// (so a picked part disappears from the next result), and emits one
+ /// per remaining part with a
+ /// projected 2-D box, a 3-D box with grasp pose, and a full 6-DoF
+ /// grasp pose in the camera_eih frame the sensor is calibrated
+ /// against.
+ ///
+ ///
+ /// Convention:
+ ///
+ /// - Positions are metres, orientations are unit quaternions in
+ /// (x, y, z, w) ordering (§5.12).
+ /// - Reported Pose.FrameId is always
+ /// camera_eih; a consumer composes camera → flange → base
+ /// using the vision-side frame tree to obtain a base-frame grasp.
+ /// The demo tunes the vision-side flange transform so the
+ /// composed camera pose matches the USD-authored camera prim; that
+ /// is how BB2D and Pose stay internally consistent.
+ /// - 2-D projection uses the classic OpenCV pinhole model with
+ /// the sensor's published intrinsics (,
+ /// , , ); lens
+ /// distortion is ignored — the calibration residual (0.21 pixels)
+ /// is well below the class-separation margin.
+ ///
+ ///
+ ///
+ /// Results are materialised as
+ /// nodes under Pipeline.Results, per the docstring on
+ /// IVisionFeedbackSink. Old results are kept up to
+ /// to bound the address-space
+ /// footprint of the continuous mode.
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1812",
+ Justification = "Instantiated by the DI container via AddSingleton.")]
+ internal sealed class BinPickingGroundTruthInferenceProvider : IVisionInferenceProvider, IDisposable
+ {
+ public BinPickingGroundTruthInferenceProvider(
+ BinPickingWorldState worldState,
+ IBinPickingTargetProvider targetProvider,
+ ILogger logger)
+ {
+ m_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ m_targetProvider = targetProvider ?? throw new ArgumentNullException(nameof(targetProvider));
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// True when the provider has been bound to a pipeline. Consumed
+ /// by the proof hosted service to know when it is safe to run.
+ ///
+ public bool IsAttached => m_target != null;
+
+ ///
+ /// The bound pipeline's node id, or if
+ /// the provider has not been attached yet.
+ ///
+ public NodeId PipelineNodeId => m_target?.PipelineNodeId ?? NodeId.Null;
+
+ ///
+ /// The sensor node id the pipeline was configured against, or
+ /// before .
+ ///
+ public NodeId SensorNodeId => m_target?.SensorNodeId ?? NodeId.Null;
+
+ ///
+ /// The deployment node id the pipeline was configured against,
+ /// or before .
+ ///
+ public NodeId DeploymentNodeId => m_target?.DeploymentNodeId ?? NodeId.Null;
+
+ ///
+ /// Camera-in-world pose used for projection and pose-in-camera.
+ /// Snapshotted here so a client (or the proof hosted service)
+ /// can chain camera → world without walking the OPC UA frame
+ /// tree.
+ ///
+ public VisionPose3DDataType CameraInWorldPose =>
+ m_target?.CameraInWorld ?? new VisionPose3DDataType();
+
+ ///
+ /// Looks up a previously-published detection result by its
+ /// identifier. Returns false if the id is unknown or the
+ /// provider has been disposed.
+ ///
+ public bool TryGetResult(string resultId, out DetectionResultState state)
+ {
+ return m_results.TryGetValue(resultId, out state!);
+ }
+
+ ///
+ /// Called from the Vision configurator once the pipeline node
+ /// has been created and its Results folder is available. Stores
+ /// the references the provider needs to publish
+ /// DetectionResultType instances.
+ ///
+ /// is null.
+ ///
+ public void Attach(BinPickingInferenceTarget target)
+ {
+ if (target == null)
+ {
+ throw new ArgumentNullException(nameof(target));
+ }
+ if (Interlocked.CompareExchange(ref m_target, target, null) != null)
+ {
+ throw new InvalidOperationException(
+ "BinPickingGroundTruthInferenceProvider has already been attached to a pipeline.");
+ }
+ m_logger.ProviderAttached(
+ target.PipelineNodeId.IsNull ? string.Empty : target.PipelineNodeId.ToString());
+ }
+
+ ///
+ public async ValueTask RunInferenceAsync(
+ VisionInferenceRunRequest request,
+ CancellationToken cancellationToken)
+ {
+ BinPickingInferenceTarget target = RequireTarget();
+ cancellationToken.ThrowIfCancellationRequested();
+ IReadOnlyList parts = m_worldState.Snapshot();
+ string resultId = "det-" + Guid.NewGuid().ToString("N");
+ DateTimeUtc timestamp = request.Timestamp.IsNull
+ ? DateTimeUtc.From(DateTime.UtcNow)
+ : request.Timestamp;
+ var detections = new List(parts.Count);
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ BinPickingPartSnapshot snapshot = parts[ii];
+ if (snapshot.Location != BinPickingPartLocation.InBin)
+ {
+ continue;
+ }
+ if (!TryBuildDetection(target, snapshot, out VisionDetectionDataType detection))
+ {
+ continue;
+ }
+ detections.Add(detection);
+ }
+ ArrayOf payload = detections.ToArray().ToArrayOf();
+ m_targetProvider.PublishWorldState(resultId, timestamp, parts);
+ await PublishDetectionAsync(
+ target, resultId, timestamp, request, payload, cancellationToken).ConfigureAwait(false);
+ m_logger.ProducedDetectionResult(
+ resultId,
+ detections.Count,
+ parts.Count);
+ return new VisionInferenceRunResult(ServiceResult.Good, resultId);
+ }
+
+ ///
+ public ValueTask StartContinuousAsync(
+ NodeId pipeline, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ BinPickingInferenceTarget target = RequireTarget();
+ if (pipeline.IsNull || !pipeline.Equals(target.PipelineNodeId))
+ {
+ return ValueTask.FromResult(new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ LocalizedText.From(
+ "The pipeline node id does not match the attached bin-picking pipeline.")));
+ }
+ lock (m_continuousLock)
+ {
+ if (m_continuousCts != null)
+ {
+ return ValueTask.FromResult(ServiceResult.Good);
+ }
+ var cts = new CancellationTokenSource();
+ m_continuousCts = cts;
+ m_continuousTask = Task.Run(() => RunContinuousAsync(cts.Token), CancellationToken.None);
+ }
+ m_logger.ContinuousStarted();
+ return ValueTask.FromResult(ServiceResult.Good);
+ }
+
+ ///
+ public async ValueTask StopAsync(
+ NodeId pipeline, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ BinPickingInferenceTarget target = RequireTarget();
+ if (pipeline.IsNull || !pipeline.Equals(target.PipelineNodeId))
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ LocalizedText.From(
+ "The pipeline node id does not match the attached bin-picking pipeline."));
+ }
+ CancellationTokenSource? cts;
+ Task? task;
+ lock (m_continuousLock)
+ {
+ cts = m_continuousCts;
+ task = m_continuousTask;
+ m_continuousCts = null;
+ m_continuousTask = null;
+ }
+ if (cts != null)
+ {
+ await cts.CancelAsync().ConfigureAwait(false);
+ cts.Dispose();
+ }
+ if (task != null)
+ {
+ try
+ {
+ await task.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+ m_logger.ContinuousStopped();
+ return ServiceResult.Good;
+ }
+
+ ///
+ public void Dispose()
+ {
+ CancellationTokenSource? cts;
+ lock (m_continuousLock)
+ {
+ cts = m_continuousCts;
+ m_continuousCts = null;
+ m_continuousTask = null;
+ }
+ cts?.Cancel();
+ cts?.Dispose();
+ }
+
+ private BinPickingInferenceTarget RequireTarget()
+ {
+ BinPickingInferenceTarget? target = m_target;
+ return target ??
+ throw new InvalidOperationException(
+ "BinPickingGroundTruthInferenceProvider has not been attached to a pipeline.");
+ }
+
+ private async Task RunContinuousAsync(CancellationToken cancellationToken)
+ {
+ using var timer = new PeriodicTimer(ContinuousPeriod);
+ while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
+ {
+ try
+ {
+ var request = new VisionInferenceRunRequest(
+ RequireTarget().PipelineNodeId,
+ RequireTarget().SensorNodeId,
+ RequireTarget().DeploymentNodeId,
+ DateTimeUtc.From(DateTime.UtcNow));
+ _ = await RunInferenceAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ m_logger.ContinuousTickFailed(ex.Message);
+ }
+ }
+ }
+
+ private bool TryBuildDetection(
+ BinPickingInferenceTarget target,
+ BinPickingPartSnapshot snapshot,
+ out VisionDetectionDataType detection)
+ {
+ (double xc, double yc, double zc) = target.WorldToCamera(
+ snapshot.WorldX, snapshot.WorldY, snapshot.WorldZ);
+ if (zc <= 0.0)
+ {
+ detection = new VisionDetectionDataType();
+ return false;
+ }
+ if (!TryProjectBoundingBox2D(target, snapshot, out VisionBoundingBox2DDataType box2D))
+ {
+ detection = new VisionDetectionDataType();
+ return false;
+ }
+ VisionPose3DDataType poseInCamera = new()
+ {
+ FrameId = target.CameraFrameId,
+ Position = new[] { xc, yc, zc }.ToArrayOf(),
+ Orientation = s_identityOrientation.ToArrayOf(),
+ Covariance = ArrayOf.Empty
+ };
+ var boundingBox3D = new VisionBoundingBox3DDataType
+ {
+ Center = poseInCamera,
+ Size = new[]
+ {
+ snapshot.Part.Size[0],
+ snapshot.Part.Size[1],
+ snapshot.Part.Size[2]
+ }.ToArrayOf()
+ };
+ detection = new VisionDetectionDataType
+ {
+ DetectionId = FormattableString.Invariant(
+ $"det-{snapshot.Part.ClassLabel}-{snapshot.Part.ClassId}"),
+ ClassLabel = snapshot.Part.ClassLabel,
+ ClassId = snapshot.Part.ClassId,
+ Confidence = 0.99,
+ HasBoundingBox2D = true,
+ BoundingBox2D = box2D,
+ HasBoundingBox3D = true,
+ BoundingBox3D = boundingBox3D,
+ HasPose = true,
+ Pose = new VisionPose3DDataType
+ {
+ FrameId = target.CameraFrameId,
+ Position = new[] { xc, yc, zc }.ToArrayOf(),
+ Orientation = s_identityOrientation.ToArrayOf(),
+ Covariance = ArrayOf.Empty
+ },
+ TrackId = snapshot.Part.ClassLabel
+ };
+ return true;
+ }
+
+ private static bool TryProjectBoundingBox2D(
+ BinPickingInferenceTarget target,
+ BinPickingPartSnapshot snapshot,
+ out VisionBoundingBox2DDataType box2D)
+ {
+ double sizeX = snapshot.Part.Size[0];
+ double sizeY = snapshot.Part.Size[1];
+ double sizeZ = snapshot.Part.Size[2];
+ double rotZ = snapshot.RotationZDegrees * Math.PI / 180.0;
+ double cosZ = Math.Cos(rotZ);
+ double sinZ = Math.Sin(rotZ);
+ double minU = double.PositiveInfinity;
+ double maxU = double.NegativeInfinity;
+ double minV = double.PositiveInfinity;
+ double maxV = double.NegativeInfinity;
+ Span local = stackalloc double[3];
+ for (int ii = -1; ii <= 1; ii += 2)
+ {
+ for (int jj = -1; jj <= 1; jj += 2)
+ {
+ for (int kk = -1; kk <= 1; kk += 2)
+ {
+ local[0] = ii * (sizeX * 0.5);
+ local[1] = jj * (sizeY * 0.5);
+ local[2] = kk * (sizeZ * 0.5);
+ double dxWorld = (local[0] * cosZ) - (local[1] * sinZ);
+ double dyWorld = (local[0] * sinZ) + (local[1] * cosZ);
+ double dzWorld = local[2];
+ double x = snapshot.WorldX + dxWorld;
+ double y = snapshot.WorldY + dyWorld;
+ double z = snapshot.WorldZ + dzWorld;
+ (double xc, double yc, double zc) = target.WorldToCamera(x, y, z);
+ if (zc <= 0.0)
+ {
+ box2D = new VisionBoundingBox2DDataType();
+ return false;
+ }
+ double u = (target.Fx * xc / zc) + target.Cx;
+ double v = (target.Fy * yc / zc) + target.Cy;
+ if (u < minU)
+ {
+ minU = u;
+ }
+ if (u > maxU)
+ {
+ maxU = u;
+ }
+ if (v < minV)
+ {
+ minV = v;
+ }
+ if (v > maxV)
+ {
+ maxV = v;
+ }
+ }
+ }
+ }
+ double clampedMinU = Math.Clamp(minU, 0.0, target.ImageWidth);
+ double clampedMaxU = Math.Clamp(maxU, 0.0, target.ImageWidth);
+ double clampedMinV = Math.Clamp(minV, 0.0, target.ImageHeight);
+ double clampedMaxV = Math.Clamp(maxV, 0.0, target.ImageHeight);
+ double width = clampedMaxU - clampedMinU;
+ double height = clampedMaxV - clampedMinV;
+ if (width <= 0.0 || height <= 0.0)
+ {
+ box2D = new VisionBoundingBox2DDataType();
+ return false;
+ }
+ box2D = new VisionBoundingBox2DDataType
+ {
+ CenterX = (clampedMinU + clampedMaxU) * 0.5,
+ CenterY = (clampedMinV + clampedMaxV) * 0.5,
+ Width = width,
+ Height = height,
+ Rotation = 0.0
+ };
+ return true;
+ }
+
+ private async Task PublishDetectionAsync(
+ BinPickingInferenceTarget 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 = 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.99;
+ state.CreateOrReplaceExplanationUri(context, null).Value = ExplanationUri;
+ BaseDataVariableState frame =
+ state.CreateOrReplaceFrame(context, null);
+ frame.Value = new VisionImageReferenceDataType
+ {
+ Uri = FormattableString.Invariant(
+ $"opcua-inline://binpicking-cell/frames/{resultId}"),
+ Digest = ByteString.Empty,
+ DigestAlgorithm = string.Empty,
+ Format = VisionClipFormatEnum.Png,
+ PixelFormat = target.PixelFormat,
+ Width = (uint)Math.Round(target.ImageWidth),
+ Height = (uint)Math.Round(target.ImageHeight),
+ 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 = "binpicking-groundtruth-1";
+ private const string ExplanationUri = "urn:opcfoundation:BinPickingCell:vision:groundtruth";
+ private static readonly TimeSpan ContinuousPeriod = TimeSpan.FromMilliseconds(500);
+ private static readonly double[] s_identityOrientation = [0.0, 0.0, 0.0, 1.0];
+
+ private readonly BinPickingWorldState m_worldState;
+ private readonly IBinPickingTargetProvider m_targetProvider;
+ private readonly ILogger m_logger;
+ private readonly ConcurrentDictionary m_results = new(StringComparer.Ordinal);
+ private readonly Lock m_continuousLock = new();
+ private BinPickingInferenceTarget? m_target;
+ private CancellationTokenSource? m_continuousCts;
+ private Task? m_continuousTask;
+ }
+
+ ///
+ /// Everything the provider needs to publish results into the address
+ /// space and to project part positions into camera pixels. Populated
+ /// by the Vision configurator once the pipeline node and its Results
+ /// folder are available.
+ ///
+ internal sealed class BinPickingInferenceTarget
+ {
+ public BinPickingInferenceTarget(
+ AsyncCustomNodeManager nodeManager,
+ ISystemContext systemContext,
+ ushort instanceNamespaceIndex,
+ NodeId pipelineNodeId,
+ NodeId sensorNodeId,
+ NodeId deploymentNodeId,
+ FolderState resultsFolder,
+ string cameraFrameId,
+ string pixelFormat,
+ double fx, double fy, double cx, double cy,
+ double imageWidth, double imageHeight,
+ VisionPose3DDataType cameraInWorld)
+ {
+ NodeManager = nodeManager ?? throw new ArgumentNullException(nameof(nodeManager));
+ SystemContext = systemContext ?? throw new ArgumentNullException(nameof(systemContext));
+ InstanceNamespaceIndex = instanceNamespaceIndex;
+ PipelineNodeId = pipelineNodeId.IsNull
+ ? throw new ArgumentException("Pipeline NodeId must not be null.", nameof(pipelineNodeId))
+ : pipelineNodeId;
+ SensorNodeId = sensorNodeId.IsNull
+ ? throw new ArgumentException("Sensor NodeId must not be null.", nameof(sensorNodeId))
+ : sensorNodeId;
+ DeploymentNodeId = deploymentNodeId.IsNull
+ ? throw new ArgumentException(
+ "Deployment NodeId must not be null.", nameof(deploymentNodeId))
+ : deploymentNodeId;
+ ResultsFolder = resultsFolder ?? throw new ArgumentNullException(nameof(resultsFolder));
+ CameraFrameId = cameraFrameId ?? throw new ArgumentNullException(nameof(cameraFrameId));
+ PixelFormat = pixelFormat ?? throw new ArgumentNullException(nameof(pixelFormat));
+ Fx = fx;
+ Fy = fy;
+ Cx = cx;
+ Cy = cy;
+ ImageWidth = imageWidth;
+ ImageHeight = imageHeight;
+ CameraInWorld = cameraInWorld ?? throw new ArgumentNullException(nameof(cameraInWorld));
+ (m_cameraInvOrientation, m_cameraPositionInWorld) = InvertCameraInWorld(cameraInWorld);
+ }
+
+ public AsyncCustomNodeManager NodeManager { get; }
+
+ public ISystemContext SystemContext { get; }
+
+ public ushort InstanceNamespaceIndex { get; }
+
+ public NodeId PipelineNodeId { get; }
+
+ public NodeId SensorNodeId { get; }
+
+ public NodeId DeploymentNodeId { get; }
+
+ public FolderState ResultsFolder { get; }
+
+ public string CameraFrameId { get; }
+
+ public string PixelFormat { get; }
+
+ public double Fx { get; }
+
+ public double Fy { get; }
+
+ public double Cx { get; }
+
+ public double Cy { get; }
+
+ public double ImageWidth { get; }
+
+ public double ImageHeight { get; }
+
+ public VisionPose3DDataType CameraInWorld { get; }
+
+ ///
+ /// Transforms a world position into the camera frame used for
+ /// projection and pose reporting. Uses the pre-inverted camera
+ /// orientation so the hot path allocates nothing.
+ ///
+ public (double X, double Y, double Z) WorldToCamera(double x, double y, double z)
+ {
+ double dx = x - m_cameraPositionInWorld.X;
+ double dy = y - m_cameraPositionInWorld.Y;
+ double dz = z - m_cameraPositionInWorld.Z;
+ (double qx, double qy, double qz, double qw) = m_cameraInvOrientation;
+ double tx = (qy * dz) - (qz * dy);
+ double ty = (qz * dx) - (qx * dz);
+ double tz = (qx * dy) - (qy * dx);
+ double rotatedX = dx + (2.0 * ((qw * tx) + (qy * tz) - (qz * ty)));
+ double rotatedY = dy + (2.0 * ((qw * ty) + (qz * tx) - (qx * tz)));
+ double rotatedZ = dz + (2.0 * ((qw * tz) + (qx * ty) - (qy * tx)));
+ return (rotatedX, rotatedY, rotatedZ);
+ }
+
+ private static ((double X, double Y, double Z, double W) InverseOrientation,
+ (double X, double Y, double Z) CameraPositionInWorld) InvertCameraInWorld(
+ VisionPose3DDataType cameraInWorld)
+ {
+ ReadOnlySpan p = cameraInWorld.Position.Span;
+ ReadOnlySpan q = cameraInWorld.Orientation.Span;
+ if (p.Length < 3 || q.Length < 4)
+ {
+ throw new ArgumentException(
+ "Camera-in-world pose must carry a 3-vector position and 4-vector quaternion.",
+ nameof(cameraInWorld));
+ }
+ return ((-q[0], -q[1], -q[2], q[3]), (p[0], p[1], p[2]));
+ }
+
+ private readonly (double X, double Y, double Z, double W) m_cameraInvOrientation;
+ private readonly (double X, double Y, double Z) m_cameraPositionInWorld;
+ }
+
+ internal static partial class BinPickingGroundTruthInferenceProviderLog
+ {
+ [LoggerMessage(EventId = BinPickingCellEventIds.Inference + 1,
+ Level = LogLevel.Information,
+ Message = "Bin-picking ground-truth inference provider attached to pipeline {PipelineNodeId}.")]
+ public static partial void ProviderAttached(
+ this ILogger logger,
+ string pipelineNodeId);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Inference + 2,
+ Level = LogLevel.Information,
+ Message = "Bin-picking ground-truth inference produced result {ResultId} " +
+ "with {Detections} of {TrackedParts} tracked parts visible.")]
+ public static partial void ProducedDetectionResult(
+ this ILogger logger,
+ string resultId, int detections, int trackedParts);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Inference + 3,
+ Level = LogLevel.Information,
+ Message = "Bin-picking ground-truth inference started continuous mode.")]
+ public static partial void ContinuousStarted(
+ this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Inference + 4,
+ Level = LogLevel.Information,
+ Message = "Bin-picking ground-truth inference stopped continuous mode.")]
+ public static partial void ContinuousStopped(
+ this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Inference + 5,
+ Level = LogLevel.Warning,
+ Message = "Bin-picking ground-truth inference continuous tick failed: {Reason}.")]
+ public static partial void ContinuousTickFailed(
+ this ILogger logger,
+ string reason);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingInferenceProof.cs b/samples/Robotics/BinPickingCell/BinPickingInferenceProof.cs
new file mode 100644
index 0000000000..1c0c9b06b0
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingInferenceProof.cs
@@ -0,0 +1,411 @@
+/* ========================================================================
+ * 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.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.Server;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Hosted service that exercises the on-server ground-truth
+ /// perception path end-to-end, once the Vision pipeline is
+ /// available. Runs one inference, dumps every detection to the
+ /// console (class label, confidence, 2-D box in pixels and 6-DoF
+ /// grasp pose in camera_eih), composes the RedCube pose to
+ /// the world frame and cross-checks it against the authored
+ /// USD position, then simulates a pick of RedCube and re-runs the
+ /// inference to prove the detector tracks the world (the picked
+ /// class disappears from the next result).
+ ///
+ ///
+ ///
+ /// This diagnostic is the sample-side answer to "does the loop
+ /// work"? It never fails the host on a mismatch — it just logs
+ /// what it saw. The pass/fail decision is left to the reader of
+ /// the console output.
+ ///
+ ///
+ internal sealed class BinPickingInferenceProof : BackgroundService
+ {
+ public BinPickingInferenceProof(
+ BinPickingGroundTruthInferenceProvider provider,
+ BinPickingWorldState worldState,
+ ILogger logger)
+ {
+ m_provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ m_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ m_logger.ProofWaiting();
+ for (int ii = 0; ii < AttachAttempts; ii++)
+ {
+ if (m_provider.IsAttached)
+ {
+ break;
+ }
+ await Task.Delay(AttachPollInterval, stoppingToken).ConfigureAwait(false);
+ }
+ if (!m_provider.IsAttached)
+ {
+ m_logger.ProofProviderNotAttached();
+ return;
+ }
+
+ m_logger.ProofBanner("on-server ground-truth inference");
+
+ VisionInferenceRunRequest request = BuildRequest();
+ VisionInferenceRunResult first = await m_provider
+ .RunInferenceAsync(request, stoppingToken)
+ .ConfigureAwait(false);
+ if (!ServiceResult.IsGood(first.ServiceResult))
+ {
+ m_logger.ProofRunFailed(first.ServiceResult.ToString());
+ return;
+ }
+
+ if (!m_provider.TryGetResult(first.ResultId, out DetectionResultState resultState) ||
+ resultState == null ||
+ resultState.Detections == null)
+ {
+ m_logger.ProofResultUnavailable(first.ResultId);
+ return;
+ }
+
+ ArrayOf detections = resultState.Detections.Value;
+ m_logger.ProofResultHeader(first.ResultId, detections.Count);
+ var detectionSnapshot = new VisionDetectionDataType[detections.Count];
+ for (int ii = 0; ii < detections.Count; ii++)
+ {
+ detectionSnapshot[ii] = detections[ii];
+ }
+ foreach (VisionDetectionDataType detection in detectionSnapshot)
+ {
+ LogDetection(detection);
+ }
+
+ VisionDetectionDataType? redCube = TryFindDetection(detectionSnapshot, RedCubeClass);
+ if (redCube != null && redCube.HasPose)
+ {
+ LogComposedPose(redCube);
+ }
+ else
+ {
+ m_logger.ProofNoRedCube();
+ }
+
+ m_logger.ProofPicking(RedCubeClass);
+ const double gripperCarryX = 0.30;
+ const double gripperCarryY = 0.05;
+ const double gripperCarryZ = 0.95;
+ const double fixtureX = 0.10;
+ const double fixtureY = 0.20;
+ const double fixtureZ = 0.82;
+ m_worldState.MarkHeld(RedCubeClass, gripperCarryX, gripperCarryY, gripperCarryZ);
+ m_worldState.MarkPlaced(RedCubeClass, fixtureX, fixtureY, fixtureZ);
+
+ VisionInferenceRunResult second = await m_provider
+ .RunInferenceAsync(BuildRequest(), stoppingToken)
+ .ConfigureAwait(false);
+ if (!ServiceResult.IsGood(second.ServiceResult))
+ {
+ m_logger.ProofRunFailed(second.ServiceResult.ToString());
+ return;
+ }
+ if (!m_provider.TryGetResult(second.ResultId, out DetectionResultState secondState) ||
+ secondState == null ||
+ secondState.Detections == null)
+ {
+ m_logger.ProofResultUnavailable(second.ResultId);
+ return;
+ }
+
+ ArrayOf afterPick = secondState.Detections.Value;
+ m_logger.ProofPostPickHeader(second.ResultId, afterPick.Count);
+ var afterSnapshot = new VisionDetectionDataType[afterPick.Count];
+ bool redCubeStillPresent = false;
+ for (int ii = 0; ii < afterPick.Count; ii++)
+ {
+ VisionDetectionDataType det = afterPick[ii];
+ afterSnapshot[ii] = det;
+ LogDetection(det);
+ if (string.Equals(det.ClassLabel, RedCubeClass, StringComparison.Ordinal))
+ {
+ redCubeStillPresent = true;
+ }
+ }
+
+ if (redCubeStillPresent)
+ {
+ m_logger.ProofPickFailed(RedCubeClass);
+ }
+ else
+ {
+ m_logger.ProofPickSucceeded(RedCubeClass);
+ }
+
+ // Put the bin back. This proof runs at startup, before any client connects, and
+ // the world it mutates is the one the paired client's demo then works against.
+ // Leaving RedCube picked meant the demo's default target was already gone, so it
+ // reported success for a part it never touched.
+ m_worldState.Reset();
+
+ m_logger.ProofCompleted();
+ }
+
+ private VisionInferenceRunRequest BuildRequest()
+ {
+ return new VisionInferenceRunRequest(
+ m_provider.PipelineNodeId,
+ m_provider.SensorNodeId,
+ m_provider.DeploymentNodeId,
+ DateTimeUtc.From(DateTime.UtcNow));
+ }
+
+ private static VisionDetectionDataType? TryFindDetection(
+ VisionDetectionDataType[] detections, string classLabel)
+ {
+ foreach (VisionDetectionDataType detection in detections)
+ {
+ if (string.Equals(detection.ClassLabel, classLabel, StringComparison.Ordinal))
+ {
+ return detection;
+ }
+ }
+ return null;
+ }
+
+ private void LogDetection(VisionDetectionDataType detection)
+ {
+ CultureInfo culture = CultureInfo.InvariantCulture;
+ string boxSummary = string.Empty;
+ if (detection.HasBoundingBox2D)
+ {
+ VisionBoundingBox2DDataType box = detection.BoundingBox2D;
+ boxSummary = string.Format(
+ culture,
+ "cx={0:0.0} cy={1:0.0} w={2:0.0} h={3:0.0}",
+ box.CenterX, box.CenterY, box.Width, box.Height);
+ }
+ string poseSummary = string.Empty;
+ if (detection.HasPose)
+ {
+ VisionPose3DDataType pose = detection.Pose;
+ (double px, double py, double pz) = ReadVec3(pose.Position);
+ (double qx, double qy, double qz, double qw) = ReadQuat(pose.Orientation);
+ poseSummary = string.Format(
+ culture,
+ "frame='{0}' pos=({1:0.000},{2:0.000},{3:0.000}) " +
+ "quat=({4:0.000},{5:0.000},{6:0.000},{7:0.000})",
+ pose.FrameId, px, py, pz, qx, qy, qz, qw);
+ }
+ m_logger.ProofDetection(
+ detection.ClassLabel ?? "",
+ detection.Confidence,
+ boxSummary,
+ poseSummary);
+ }
+
+ private void LogComposedPose(VisionDetectionDataType detection)
+ {
+ VisionPose3DDataType poseCam = detection.Pose;
+ (double px, double py, double pz) = ReadVec3(poseCam.Position);
+ VisionPose3DDataType cameraInWorld = m_provider.CameraInWorldPose;
+ (double cx, double cy, double cz) = ReadVec3(cameraInWorld.Position);
+ (double qx, double qy, double qz, double qw) = ReadQuat(cameraInWorld.Orientation);
+ (double rx, double ry, double rz) = QuaternionRotate(qx, qy, qz, qw, px, py, pz);
+ double worldX = cx + rx;
+ double worldY = cy + ry;
+ double worldZ = cz + rz;
+ var authored = BinPickingPartsCatalog.TryGet(RedCubeClass);
+ if (authored != null)
+ {
+ double ax = authored.InitialWorldPosition[0];
+ double ay = authored.InitialWorldPosition[1];
+ double az = authored.InitialWorldPosition[2];
+ double dx = worldX - ax;
+ double dy = worldY - ay;
+ double dz = worldZ - az;
+ double error = Math.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
+ m_logger.ProofComposedPose(
+ RedCubeClass,
+ worldX, worldY, worldZ,
+ ax, ay, az,
+ error);
+ }
+ }
+
+ private static (double X, double Y, double Z) ReadVec3(ArrayOf vec)
+ {
+ System.ReadOnlySpan span = vec.Span;
+ if (span.Length < 3)
+ {
+ return (0.0, 0.0, 0.0);
+ }
+ return (span[0], span[1], span[2]);
+ }
+
+ private static (double X, double Y, double Z, double W) ReadQuat(ArrayOf vec)
+ {
+ System.ReadOnlySpan span = vec.Span;
+ if (span.Length < 4)
+ {
+ return (0.0, 0.0, 0.0, 1.0);
+ }
+ return (span[0], span[1], span[2], span[3]);
+ }
+
+ private static (double X, double Y, double Z) QuaternionRotate(
+ double qx, double qy, double qz, double qw,
+ double vx, double vy, double vz)
+ {
+ double tx = (qy * vz) - (qz * vy);
+ double ty = (qz * vx) - (qx * vz);
+ double tz = (qx * vy) - (qy * vx);
+ double rx = vx + (2.0 * ((qw * tx) + (qy * tz) - (qz * ty)));
+ double ry = vy + (2.0 * ((qw * ty) + (qz * tx) - (qx * tz)));
+ double rz = vz + (2.0 * ((qw * tz) + (qx * ty) - (qy * tx)));
+ return (rx, ry, rz);
+ }
+
+ private const string RedCubeClass = "RedCube";
+ private const int AttachAttempts = 300;
+ private static readonly TimeSpan AttachPollInterval = TimeSpan.FromMilliseconds(100);
+
+ private readonly BinPickingGroundTruthInferenceProvider m_provider;
+ private readonly BinPickingWorldState m_worldState;
+ private readonly ILogger m_logger;
+ }
+
+ internal static partial class BinPickingInferenceProofLog
+ {
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 1,
+ Level = LogLevel.Information,
+ Message = "Bin-picking on-server inference proof waiting for pipeline to attach.")]
+ public static partial void ProofWaiting(this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 2,
+ Level = LogLevel.Warning,
+ Message = "Bin-picking on-server inference proof gave up: provider never attached.")]
+ public static partial void ProofProviderNotAttached(
+ this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 3,
+ Level = LogLevel.Information,
+ Message = "=== Bin-picking demo: {Banner} ===")]
+ public static partial void ProofBanner(
+ this ILogger logger, string banner);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 4,
+ Level = LogLevel.Warning,
+ Message = "RunInferenceAsync returned non-good service result: {ServiceResult}")]
+ public static partial void ProofRunFailed(
+ this ILogger logger, string serviceResult);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 5,
+ Level = LogLevel.Warning,
+ Message = "DetectionResult '{ResultId}' was not stored on the provider — cannot read back.")]
+ public static partial void ProofResultUnavailable(
+ this ILogger logger, string resultId);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 6,
+ Level = LogLevel.Information,
+ Message = "Detection result {ResultId}: {DetectionCount} parts in view (initial scan).")]
+ public static partial void ProofResultHeader(
+ this ILogger logger,
+ string resultId, int detectionCount);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 7,
+ Level = LogLevel.Information,
+ Message = " {ClassLabel} (conf={Confidence:0.00}) box2D=[{BoundingBox2D}] pose=[{Pose}]")]
+ public static partial void ProofDetection(
+ this ILogger logger,
+ string classLabel, double confidence, string boundingBox2D, string pose);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 8,
+ Level = LogLevel.Information,
+ Message = "Composed {ClassLabel} pose camera_eih -> world = " +
+ "({ComposedX:0.000},{ComposedY:0.000},{ComposedZ:0.000}); " +
+ "authored=({AuthoredX:0.000},{AuthoredY:0.000},{AuthoredZ:0.000}); " +
+ "residual={ResidualMetres:0.0000} m")]
+ public static partial void ProofComposedPose(
+ this ILogger logger,
+ string classLabel,
+ double composedX, double composedY, double composedZ,
+ double authoredX, double authoredY, double authoredZ,
+ double residualMetres);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 9,
+ Level = LogLevel.Warning,
+ Message = "The RedCube detection was not present or had no pose; skipping compose step.")]
+ public static partial void ProofNoRedCube(
+ this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 10,
+ Level = LogLevel.Information,
+ Message = "Simulating pick+place of {ClassLabel} to see whether the detector tracks the world.")]
+ public static partial void ProofPicking(
+ this ILogger logger, string classLabel);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 11,
+ Level = LogLevel.Information,
+ Message = "Detection result {ResultId}: {DetectionCount} parts in view (after pick).")]
+ public static partial void ProofPostPickHeader(
+ this ILogger logger,
+ string resultId, int detectionCount);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 12,
+ Level = LogLevel.Error,
+ Message = "{ClassLabel} still visible after the pick — detector is NOT tracking world state.")]
+ public static partial void ProofPickFailed(
+ this ILogger logger, string classLabel);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 13,
+ Level = LogLevel.Information,
+ Message = "{ClassLabel} correctly disappeared from the detection result after the pick — " +
+ "the ground-truth path tracks the world.")]
+ public static partial void ProofPickSucceeded(
+ this ILogger logger, string classLabel);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Proof + 14,
+ Level = LogLevel.Information,
+ Message = "=== Bin-picking demo: on-server inference proof completed ===")]
+ public static partial void ProofCompleted(
+ this ILogger logger);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingMediaProvider.cs b/samples/Robotics/BinPickingCell/BinPickingMediaProvider.cs
new file mode 100644
index 0000000000..4a37289521
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingMediaProvider.cs
@@ -0,0 +1,280 @@
+/* ========================================================================
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.OpenUsd;
+using Opc.Ua.Vision.Server;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Bridges the Vision media surface ()
+ /// to the OpenUSD offscreen renderer.
+ ///
+ ///
+ ///
+ /// The provider serves the eye-in-hand camera as clip frames rendered
+ /// from the sample cell stage; it does not serve a live RTSP stream and
+ /// therefore reports from
+ /// . That is the correct sample behaviour:
+ /// the sample models the mandatory RTSP stream endpoint in the address
+ /// space per spec §6.2, but a real RTSP server is not part of a
+ /// self-contained sample.
+ ///
+ ///
+ /// When the OpenUSD capture provider has no graphics backend
+ /// (), this
+ /// provider surfaces it as
+ /// so the Vision server can report the condition to callers rather than
+ /// fail to start. The provider itself starts and reports the condition
+ /// through .
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1812",
+ Justification = "Instantiated by the DI container via AddSingleton.")]
+ internal sealed class BinPickingMediaProvider : IVisionMediaProvider
+ {
+ public BinPickingMediaProvider(
+ ISceneCameraCaptureProvider capture,
+ BinPickingSensorSpec spec,
+ ILogger logger)
+ {
+ m_capture = capture ?? throw new ArgumentNullException(nameof(capture));
+ m_spec = spec ?? throw new ArgumentNullException(nameof(spec));
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Gets the capture backend the provider is bound to.
+ ///
+ public SceneCameraCaptureBackend Backend => m_capture.Backend;
+
+ ///
+ public ValueTask GetStreamAsync(
+ VisionStreamRequest request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var session = new VisionStreamSessionDataType
+ {
+ SessionToken = ByteString.Empty,
+ Uri = string.Empty,
+ Protocol = request.PreferredProtocol,
+ ExpiresAt = DateTimeUtc.MinValue
+ };
+ return ValueTask.FromResult(new VisionStreamLease(
+ new ServiceResult(StatusCodes.BadNotSupported,
+ LocalizedText.From(
+ "The bin-picking sample does not host a live RTSP stream. " +
+ "Use GetClip to fetch one-shot rendered frames.")),
+ session,
+ request.Endpoint));
+ }
+
+ ///
+ public ValueTask ReleaseStreamAsync(
+ ByteString sessionToken, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return ValueTask.FromResult(ServiceResult.Good);
+ }
+
+ ///
+ public ValueTask ConfigureStreamAsync(
+ VisionStreamConfigurationRequest request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return ValueTask.FromResult(new ServiceResult(StatusCodes.BadNotSupported,
+ LocalizedText.From(
+ "The bin-picking sample renders single frames per GetClip; the stream endpoint " +
+ "is a modelling placeholder and cannot be reconfigured.")));
+ }
+
+ ///
+ public ValueTask SelectEndpointAsync(
+ NodeId streamEndpoint, NodeId clipEndpoint, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return ValueTask.FromResult(ServiceResult.Good);
+ }
+
+ ///
+ public async ValueTask GetClipAsync(
+ VisionClipRequest request, CancellationToken cancellationToken)
+ {
+ DateTime timestamp = request.Timestamp.IsNull
+ ? DateTime.UtcNow
+ : request.Timestamp.ToDateTime();
+ var captureRequest = new SceneCameraCaptureRequest
+ {
+ StageIdentifier = m_spec.StageIdentifier,
+ PrimPath = m_spec.CameraPrimPath,
+ Width = m_spec.CaptureWidth,
+ Height = m_spec.CaptureHeight,
+ TimeCode = 0.0,
+ Format = SceneCameraImageFormat.Png,
+ TimestampUtc = timestamp
+ };
+ SceneCameraCaptureResult result = await m_capture
+ .CaptureAsync(captureRequest, cancellationToken)
+ .ConfigureAwait(false);
+ ServiceResult serviceResult = MapStatus(result);
+ if (result.Status != SceneCameraCaptureStatus.Succeeded)
+ {
+ m_logger.CaptureFailed(result.Status, result.Reason ?? string.Empty);
+ var failureImage = new VisionImageReferenceDataType
+ {
+ Uri = string.Empty,
+ Digest = ByteString.Empty,
+ DigestAlgorithm = string.Empty,
+ Format = VisionClipFormatEnum.Jpeg,
+ PixelFormat = m_spec.PixelFormat,
+ Width = (uint)m_spec.CaptureWidth,
+ Height = (uint)m_spec.CaptureHeight,
+ SizeBytes = 0u,
+ Timestamp = DateTimeUtc.From(timestamp)
+ };
+ return new VisionClipResult(
+ serviceResult, failureImage, request.Endpoint, ByteString.Empty);
+ }
+ ByteString png = result.Image;
+ byte[] digest = ComputeDigest(png);
+ var image = new VisionImageReferenceDataType
+ {
+ // A reference, not a container. Embedding the encoded frame here as a
+ // base64 data URI sent the image twice - once in this String and once in
+ // the inline ByteString below - and a 1.3 MB PNG becomes a 1.7 MB string
+ // against a 64 KB MaxStringLength, so the Server could not encode its own
+ // camera output and every read failed with BadEncodingLimitsExceeded.
+ Uri = FormattableString.Invariant(
+ $"opcua-inline://binpicking-cell/frames/{timestamp:yyyyMMddHHmmssfff}"),
+ Digest = ByteString.From(digest),
+ DigestAlgorithm = "SHA-256",
+ Format = VisionClipFormatEnum.Png,
+ PixelFormat = m_spec.PixelFormat,
+ Width = (uint)result.Width,
+ Height = (uint)result.Height,
+ SizeBytes = (uint)png.Length,
+ Timestamp = DateTimeUtc.From(timestamp)
+ };
+ ByteString inline = request.RequestInline ? png : ByteString.Empty;
+ m_logger.CaptureSucceeded(result.Width, result.Height, png.Length);
+ return new VisionClipResult(ServiceResult.Good, image, request.Endpoint, inline);
+ }
+
+ private static ServiceResult MapStatus(SceneCameraCaptureResult result)
+ {
+ return result.Status switch
+ {
+ SceneCameraCaptureStatus.Succeeded => ServiceResult.Good,
+ SceneCameraCaptureStatus.NoRenderingBackend => new ServiceResult(
+ StatusCodes.BadResourceUnavailable,
+ LocalizedText.From(result.Reason ?? "No graphics backend is available on this host.")),
+ SceneCameraCaptureStatus.InvalidRequest => new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ LocalizedText.From(result.Reason ?? "The capture request was rejected as invalid.")),
+ SceneCameraCaptureStatus.StageOpenFailed => new ServiceResult(
+ StatusCodes.BadResourceUnavailable,
+ LocalizedText.From(result.Reason ?? "The USD stage could not be opened.")),
+ SceneCameraCaptureStatus.CameraResolveFailed => new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ LocalizedText.From(result.Reason ?? "The camera prim could not be resolved on the stage.")),
+ SceneCameraCaptureStatus.RenderFailed => new ServiceResult(
+ StatusCodes.BadInternalError,
+ LocalizedText.From(result.Reason ?? "The scene renderer failed.")),
+ SceneCameraCaptureStatus.BlankFrame => new ServiceResult(
+ StatusCodes.BadNoDataAvailable,
+ LocalizedText.From(result.Reason ?? "The renderer produced a blank frame.")),
+ SceneCameraCaptureStatus.EncodingFailed => new ServiceResult(
+ StatusCodes.BadEncodingError,
+ LocalizedText.From(result.Reason ?? "The rendered frame could not be encoded.")),
+ _ => new ServiceResult(StatusCodes.BadInternalError,
+ LocalizedText.From(result.Reason ?? "The scene camera capture provider reported an unknown status."))
+ };
+ }
+
+ private static byte[] ComputeDigest(ByteString png)
+ {
+ return System.Security.Cryptography.SHA256.HashData(png.Span);
+ }
+
+ private readonly ISceneCameraCaptureProvider m_capture;
+ private readonly BinPickingSensorSpec m_spec;
+ private readonly ILogger m_logger;
+ }
+
+ ///
+ /// Static description of the eye-in-hand sensor used by the sample.
+ ///
+ ///
+ /// Path or URI to the USD stage the sensor renders from.
+ ///
+ ///
+ /// Absolute prim path of the UsdGeomCamera that acts as the
+ /// camera view.
+ ///
+ ///
+ /// GenICam PFNC pixel-format string reported on frames the provider
+ /// returns.
+ ///
+ ///
+ /// Rendered frame width in pixels.
+ ///
+ ///
+ /// Rendered frame height in pixels.
+ ///
+ internal sealed record BinPickingSensorSpec(
+ string StageIdentifier,
+ string CameraPrimPath,
+ string PixelFormat,
+ int CaptureWidth,
+ int CaptureHeight);
+
+ internal static partial class BinPickingMediaProviderLog
+ {
+ [LoggerMessage(EventId = BinPickingCellEventIds.MediaProvider + 1,
+ Level = LogLevel.Information,
+ Message = "Rendered eye-in-hand frame {Width}x{Height} ({Bytes} bytes).")]
+ public static partial void CaptureSucceeded(
+ this ILogger logger, int width, int height, int bytes);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.MediaProvider + 2,
+ Level = LogLevel.Warning,
+ Message = "Eye-in-hand capture failed: {Status} - {Reason}.")]
+ public static partial void CaptureFailed(
+ this ILogger logger,
+ SceneCameraCaptureStatus status,
+ string reason);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingOffServerProof.cs b/samples/Robotics/BinPickingCell/BinPickingOffServerProof.cs
new file mode 100644
index 0000000000..d8c07f4aaf
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingOffServerProof.cs
@@ -0,0 +1,697 @@
+/* ========================================================================
+ * 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.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.Server;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Hosted service that stands in for the connected MCP agent and
+ /// exercises the off-server perception path end-to-end.
+ ///
+ ///
+ ///
+ /// The demo's headline is that a language model over MCP does the
+ /// seeing — but the sample must be able to prove the server-side
+ /// half without an actual model in the loop. This service is that
+ /// proof: it plays the role of an agent that has looked at the
+ /// frame and decided what it sees, and calls
+ /// the way
+ /// the MCP tool would. It also drives every validation refusal so
+ /// the messages a real agent would see are visible in the log.
+ ///
+ ///
+ /// This service is only wired when the run selects
+ /// InferenceLocation=EdgeOffServer, so its evidence
+ /// complements — never conflicts with — the on-server proof.
+ ///
+ ///
+ internal sealed class BinPickingOffServerProof : BackgroundService
+ {
+ public BinPickingOffServerProof(
+ BinPickingAgentInferenceProvider provider,
+ ILogger logger)
+ {
+ m_provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ m_logger.ProofWaiting();
+ for (int ii = 0; ii < AttachAttempts; ii++)
+ {
+ if (m_provider.IsAttached)
+ {
+ break;
+ }
+ await Task.Delay(AttachPollInterval, stoppingToken).ConfigureAwait(false);
+ }
+ if (!m_provider.IsAttached)
+ {
+ m_logger.ProofProviderNotAttached();
+ return;
+ }
+
+ m_logger.ProofBanner("off-server agent-as-VLM perception");
+
+ await ProveRunInferenceRefusedAsync(stoppingToken).ConfigureAwait(false);
+ string? initialResultId = await ProveHappyPathAsync(stoppingToken).ConfigureAwait(false);
+ await ProveValidationRefusalsAsync(stoppingToken).ConfigureAwait(false);
+ if (initialResultId != null)
+ {
+ await ProveCorrectionAsync(initialResultId, stoppingToken).ConfigureAwait(false);
+ await ProveCorrectionAgainstUnknownResultAsync(stoppingToken).ConfigureAwait(false);
+ }
+
+ m_logger.ProofCompleted();
+ }
+
+ private async Task ProveRunInferenceRefusedAsync(CancellationToken cancellationToken)
+ {
+ var request = new VisionInferenceRunRequest(
+ m_provider.PipelineNodeId,
+ m_provider.SensorNodeId,
+ m_provider.DeploymentNodeId,
+ DateTimeUtc.From(DateTime.UtcNow));
+ VisionInferenceRunResult runResult = await m_provider
+ .RunInferenceAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+ m_logger.ProofRunInferenceRefused(
+ runResult.ServiceResult.StatusCode.Code,
+ runResult.ServiceResult.LocalizedText.Text ?? string.Empty);
+ }
+
+ private async Task ProveHappyPathAsync(CancellationToken cancellationToken)
+ {
+ m_logger.ProofBanner("valid submission");
+ (VisionDetectionDataType redCube, VisionPose3DDataType poseInCamera) = BuildRedCubeDetection();
+ (VisionDetectionDataType greenCylinder, _) = BuildGreenCylinderDetection();
+ var detections = new[] { redCube, greenCylinder }.ToArrayOf();
+ var frameReference = new VisionImageReferenceDataType
+ {
+ Uri = "opcua-agent://binpicking-cell/vlm/frames/proof-happy",
+ Digest = ByteString.Empty,
+ DigestAlgorithm = string.Empty,
+ Format = VisionClipFormatEnum.Png,
+ PixelFormat = "RGB8",
+ Width = (uint)Math.Round(m_provider.ImageWidth),
+ Height = (uint)Math.Round(m_provider.ImageHeight),
+ SizeBytes = 0u,
+ Timestamp = DateTimeUtc.From(DateTime.UtcNow)
+ };
+ ServiceResult submission = await m_provider
+ .SubmitDetectionsAsync(
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ VisionFeedbackPurposeEnum.Reconciliation,
+ detections,
+ frameReference,
+ ByteString.Empty),
+ cancellationToken)
+ .ConfigureAwait(false);
+ if (!ServiceResult.IsGood(submission))
+ {
+ m_logger.ProofSubmissionFailed(submission.StatusCode.Code, submission.ToString());
+ return null;
+ }
+ string resultId = m_provider.LastPublishedResultId;
+ if (string.IsNullOrEmpty(resultId) ||
+ !m_provider.TryGetResult(resultId, out DetectionResultState state))
+ {
+ m_logger.ProofResultUnavailable(resultId);
+ return null;
+ }
+ LogResultShape(state);
+ LogComposedPose(RedCubeClass, poseInCamera);
+ return resultId;
+ }
+
+ private async Task ProveValidationRefusalsAsync(CancellationToken cancellationToken)
+ {
+ m_logger.ProofBanner("validation refusals (the messages an agent would see)");
+ await ExpectRefusalAsync(
+ "hallucinated class label",
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ VisionFeedbackPurposeEnum.Reconciliation,
+ new[] { BuildDetectionWithClass("PurpleWidget") }.ToArrayOf(),
+ new VisionImageReferenceDataType(),
+ ByteString.Empty),
+ cancellationToken).ConfigureAwait(false);
+ await ExpectRefusalAsync(
+ "confidence outside [0, 1]",
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ VisionFeedbackPurposeEnum.Reconciliation,
+ new[] { BuildDetectionWithConfidence(1.42) }.ToArrayOf(),
+ new VisionImageReferenceDataType(),
+ ByteString.Empty),
+ cancellationToken).ConfigureAwait(false);
+ await ExpectRefusalAsync(
+ "bounding box entirely outside image",
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ VisionFeedbackPurposeEnum.Reconciliation,
+ new[] { BuildDetectionWithBoxOutsideImage() }.ToArrayOf(),
+ new VisionImageReferenceDataType(),
+ ByteString.Empty),
+ cancellationToken).ConfigureAwait(false);
+ await ExpectRefusalAsync(
+ "zero-norm quaternion",
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ VisionFeedbackPurposeEnum.Reconciliation,
+ new[] { BuildDetectionWithZeroNormQuat() }.ToArrayOf(),
+ new VisionImageReferenceDataType(),
+ ByteString.Empty),
+ cancellationToken).ConfigureAwait(false);
+ await ExpectAcceptedAsync(
+ "empty detections report an empty bin",
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ VisionFeedbackPurposeEnum.Reconciliation,
+ ArrayOf.Empty,
+ new VisionImageReferenceDataType(),
+ ByteString.Empty),
+ cancellationToken).ConfigureAwait(false);
+ await ExpectRefusalAsync(
+ "detection count exceeds the cell's plausible ceiling",
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ VisionFeedbackPurposeEnum.Reconciliation,
+ BuildManyDetections(HallucinationCount),
+ new VisionImageReferenceDataType(),
+ ByteString.Empty),
+ cancellationToken).ConfigureAwait(false);
+ await ExpectRefusalAsync(
+ "purpose not a defined enum value",
+ new VisionSubmitDetectionsRequest(
+ m_provider.PipelineNodeId,
+ (VisionFeedbackPurposeEnum)77,
+ new[] { BuildBlueSphereDetection() }.ToArrayOf(),
+ new VisionImageReferenceDataType(),
+ ByteString.Empty),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ProveCorrectionAsync(string originalResultId, CancellationToken cancellationToken)
+ {
+ m_logger.ProofBanner("submitting a correction (§9 learning path)");
+ VisionDetectionDataType corrected = BuildBlueSphereDetection();
+ ServiceResult correctionResult = await m_provider
+ .SubmitCorrectionAsync(
+ new VisionSubmitCorrectionRequest(
+ m_provider.PipelineNodeId,
+ originalResultId,
+ VisionFeedbackPurposeEnum.GroundTruthLabel,
+ new[] { corrected }.ToArrayOf(),
+ ArrayOf.Empty,
+ LocalizedText.From("Original miscalled the class; corrected to BlueSphere."),
+ ByteString.Empty),
+ cancellationToken)
+ .ConfigureAwait(false);
+ m_logger.ProofCorrectionResult(
+ originalResultId,
+ correctionResult.StatusCode.Code,
+ correctionResult.LocalizedText.Text ?? string.Empty);
+ }
+
+ private async Task ProveCorrectionAgainstUnknownResultAsync(CancellationToken cancellationToken)
+ {
+ m_logger.ProofBanner("correction against an unknown result-id");
+ ServiceResult refusal = await m_provider
+ .SubmitCorrectionAsync(
+ new VisionSubmitCorrectionRequest(
+ m_provider.PipelineNodeId,
+ "det-agent-never-existed",
+ VisionFeedbackPurposeEnum.GroundTruthLabel,
+ new[] { BuildBlueSphereDetection() }.ToArrayOf(),
+ ArrayOf.Empty,
+ LocalizedText.From("Correction referencing a fabricated id."),
+ ByteString.Empty),
+ cancellationToken)
+ .ConfigureAwait(false);
+ m_logger.ProofCorrectionResult(
+ "det-agent-never-existed",
+ refusal.StatusCode.Code,
+ refusal.LocalizedText.Text ?? string.Empty);
+ }
+
+ private async Task ExpectRefusalAsync(
+ string scenario, VisionSubmitDetectionsRequest request, CancellationToken cancellationToken)
+ {
+ ServiceResult result = await m_provider
+ .SubmitDetectionsAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+ m_logger.ProofRefusalScenario(
+ scenario, result.StatusCode.Code, result.LocalizedText.Text ?? string.Empty);
+ }
+
+ private async Task ExpectAcceptedAsync(
+ string scenario, VisionSubmitDetectionsRequest request, CancellationToken cancellationToken)
+ {
+ ServiceResult result = await m_provider
+ .SubmitDetectionsAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+ m_logger.ProofAcceptedScenario(
+ scenario, result.StatusCode.Code, result.LocalizedText.Text ?? string.Empty);
+ }
+
+ private (VisionDetectionDataType Detection, VisionPose3DDataType PoseInCamera) BuildRedCubeDetection()
+ {
+ BinPickingPart? part = BinPickingPartsCatalog.TryGet(RedCubeClass);
+ if (part == null)
+ {
+ throw new InvalidOperationException("RedCube missing from catalog.");
+ }
+ (double xc, double yc, double zc) = WorldToCamera(
+ part.InitialWorldPosition[0],
+ part.InitialWorldPosition[1],
+ part.InitialWorldPosition[2]);
+ var pose = new VisionPose3DDataType
+ {
+ FrameId = m_provider.CameraFrameId,
+ Position = new[] { xc, yc, zc }.ToArrayOf(),
+ Orientation = s_identityOrientation.ToArrayOf(),
+ Covariance = ArrayOf.Empty
+ };
+ var detection = new VisionDetectionDataType
+ {
+ DetectionId = "vlm-red-cube-0",
+ ClassLabel = RedCubeClass,
+ ClassId = part.ClassId,
+ Confidence = 0.94,
+ HasBoundingBox2D = true,
+ BoundingBox2D = new VisionBoundingBox2DDataType
+ {
+ CenterX = m_provider.ImageWidth * 0.5,
+ CenterY = m_provider.ImageHeight * 0.5,
+ Width = m_provider.ImageWidth * 0.10,
+ Height = m_provider.ImageHeight * 0.10,
+ Rotation = 0.0
+ },
+ HasPose = true,
+ Pose = pose,
+ TrackId = RedCubeClass
+ };
+ return (detection, pose);
+ }
+
+ private (VisionDetectionDataType Detection, VisionPose3DDataType PoseInCamera) BuildGreenCylinderDetection()
+ {
+ BinPickingPart? part = BinPickingPartsCatalog.TryGet("GreenCylinder");
+ if (part == null)
+ {
+ throw new InvalidOperationException("GreenCylinder missing from catalog.");
+ }
+ (double xc, double yc, double zc) = WorldToCamera(
+ part.InitialWorldPosition[0],
+ part.InitialWorldPosition[1],
+ part.InitialWorldPosition[2]);
+ var pose = new VisionPose3DDataType
+ {
+ FrameId = m_provider.CameraFrameId,
+ Position = new[] { xc, yc, zc }.ToArrayOf(),
+ Orientation = s_identityOrientation.ToArrayOf(),
+ Covariance = ArrayOf.Empty
+ };
+ var detection = new VisionDetectionDataType
+ {
+ DetectionId = "vlm-green-cyl-1",
+ ClassLabel = part.ClassLabel,
+ ClassId = part.ClassId,
+ Confidence = 0.83,
+ HasBoundingBox2D = true,
+ BoundingBox2D = new VisionBoundingBox2DDataType
+ {
+ CenterX = m_provider.ImageWidth * 0.55,
+ CenterY = m_provider.ImageHeight * 0.50,
+ Width = m_provider.ImageWidth * 0.09,
+ Height = m_provider.ImageHeight * 0.09,
+ Rotation = 0.0
+ },
+ HasPose = true,
+ Pose = pose,
+ TrackId = part.ClassLabel
+ };
+ return (detection, pose);
+ }
+
+ private VisionDetectionDataType BuildBlueSphereDetection()
+ {
+ BinPickingPart part = BinPickingPartsCatalog.TryGet("BlueSphere")
+ ?? throw new InvalidOperationException("BlueSphere missing from catalog.");
+ return new VisionDetectionDataType
+ {
+ DetectionId = "vlm-blue-sphere",
+ ClassLabel = part.ClassLabel,
+ ClassId = part.ClassId,
+ Confidence = 0.88,
+ HasBoundingBox2D = true,
+ BoundingBox2D = new VisionBoundingBox2DDataType
+ {
+ CenterX = m_provider.ImageWidth * 0.60,
+ CenterY = m_provider.ImageHeight * 0.50,
+ Width = m_provider.ImageWidth * 0.10,
+ Height = m_provider.ImageHeight * 0.10,
+ Rotation = 0.0
+ },
+ HasPose = false,
+ TrackId = part.ClassLabel
+ };
+ }
+
+ private VisionDetectionDataType BuildDetectionWithClass(string classLabel)
+ {
+ return new VisionDetectionDataType
+ {
+ DetectionId = "vlm-invalid-class",
+ ClassLabel = classLabel,
+ ClassId = 99u,
+ Confidence = 0.61,
+ HasBoundingBox2D = true,
+ BoundingBox2D = InBoundsBox(),
+ HasPose = false,
+ TrackId = classLabel
+ };
+ }
+
+ private VisionDetectionDataType BuildDetectionWithConfidence(double confidence)
+ {
+ return new VisionDetectionDataType
+ {
+ DetectionId = "vlm-invalid-confidence",
+ ClassLabel = RedCubeClass,
+ ClassId = 1u,
+ Confidence = confidence,
+ HasBoundingBox2D = true,
+ BoundingBox2D = InBoundsBox(),
+ HasPose = false,
+ TrackId = RedCubeClass
+ };
+ }
+
+ private VisionDetectionDataType BuildDetectionWithBoxOutsideImage()
+ {
+ return new VisionDetectionDataType
+ {
+ DetectionId = "vlm-invalid-box",
+ ClassLabel = RedCubeClass,
+ ClassId = 1u,
+ Confidence = 0.5,
+ HasBoundingBox2D = true,
+ BoundingBox2D = new VisionBoundingBox2DDataType
+ {
+ CenterX = m_provider.ImageWidth + 500.0,
+ CenterY = m_provider.ImageHeight + 500.0,
+ Width = 40.0,
+ Height = 40.0,
+ Rotation = 0.0
+ },
+ HasPose = false,
+ TrackId = RedCubeClass
+ };
+ }
+
+ private VisionDetectionDataType BuildDetectionWithZeroNormQuat()
+ {
+ return new VisionDetectionDataType
+ {
+ DetectionId = "vlm-invalid-pose",
+ ClassLabel = RedCubeClass,
+ ClassId = 1u,
+ Confidence = 0.5,
+ HasBoundingBox2D = false,
+ HasPose = true,
+ Pose = new VisionPose3DDataType
+ {
+ FrameId = m_provider.CameraFrameId,
+ Position = s_zeroNormPosition.ToArrayOf(),
+ Orientation = s_zeroNormQuat.ToArrayOf(),
+ Covariance = ArrayOf.Empty
+ },
+ TrackId = RedCubeClass
+ };
+ }
+
+ private VisionBoundingBox2DDataType InBoundsBox()
+ {
+ return new VisionBoundingBox2DDataType
+ {
+ CenterX = m_provider.ImageWidth * 0.5,
+ CenterY = m_provider.ImageHeight * 0.5,
+ Width = 40.0,
+ Height = 40.0,
+ Rotation = 0.0
+ };
+ }
+
+ private ArrayOf BuildManyDetections(int count)
+ {
+ var detections = new VisionDetectionDataType[count];
+ BinPickingPart part = BinPickingPartsCatalog.TryGet(RedCubeClass)!;
+ for (int ii = 0; ii < count; ii++)
+ {
+ detections[ii] = new VisionDetectionDataType
+ {
+ DetectionId = FormattableString.Invariant($"vlm-many-{ii}"),
+ ClassLabel = part.ClassLabel,
+ ClassId = part.ClassId,
+ Confidence = 0.5,
+ HasBoundingBox2D = true,
+ BoundingBox2D = InBoundsBox(),
+ HasPose = false,
+ TrackId = part.ClassLabel
+ };
+ }
+ return detections.ToArrayOf();
+ }
+
+ private void LogResultShape(DetectionResultState state)
+ {
+ string? resultId = state.ResultId?.Value;
+ string? modelVersion = state.ModelVersionUsed?.Value;
+ string? explanationUri = state.ExplanationUri?.Value;
+ NodeId sensorId = state.Sensor?.Value ?? NodeId.Null;
+ NodeId pipelineId = state.Pipeline?.Value ?? NodeId.Null;
+ int detectionCount = state.Detections?.Value.Count ?? 0;
+ string? frameId = state.FrameId?.Value;
+ m_logger.ProofPublishedResult(
+ resultId ?? string.Empty,
+ detectionCount,
+ sensorId,
+ pipelineId,
+ modelVersion ?? string.Empty,
+ explanationUri ?? string.Empty,
+ frameId ?? string.Empty);
+ }
+
+ private void LogComposedPose(string classLabel, VisionPose3DDataType poseInCamera)
+ {
+ System.ReadOnlySpan pos = poseInCamera.Position.Span;
+ if (pos.Length < 3)
+ {
+ return;
+ }
+ (double x, double y, double z) = QuaternionComposeToWorld(pos[0], pos[1], pos[2]);
+ BinPickingPart? authored = BinPickingPartsCatalog.TryGet(classLabel);
+ if (authored == null)
+ {
+ return;
+ }
+ double ax = authored.InitialWorldPosition[0];
+ double ay = authored.InitialWorldPosition[1];
+ double az = authored.InitialWorldPosition[2];
+ double dx = x - ax;
+ double dy = y - ay;
+ double dz = z - az;
+ double residual = Math.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
+ m_logger.ProofComposedPose(classLabel, x, y, z, ax, ay, az, residual);
+ }
+
+ private (double X, double Y, double Z) WorldToCamera(double x, double y, double z)
+ {
+ VisionPose3DDataType cameraInWorld = m_provider.CameraInWorldPose;
+ System.ReadOnlySpan pos = cameraInWorld.Position.Span;
+ System.ReadOnlySpan ori = cameraInWorld.Orientation.Span;
+ if (pos.Length < 3 || ori.Length < 4)
+ {
+ return (0.0, 0.0, 0.0);
+ }
+ double dx = x - pos[0];
+ double dy = y - pos[1];
+ double dz = z - pos[2];
+ double qx = -ori[0];
+ double qy = -ori[1];
+ double qz = -ori[2];
+ double qw = ori[3];
+ double tx = (qy * dz) - (qz * dy);
+ double ty = (qz * dx) - (qx * dz);
+ double tz = (qx * dy) - (qy * dx);
+ double rx = dx + (2.0 * ((qw * tx) + (qy * tz) - (qz * ty)));
+ double ry = dy + (2.0 * ((qw * ty) + (qz * tx) - (qx * tz)));
+ double rz = dz + (2.0 * ((qw * tz) + (qx * ty) - (qy * tx)));
+ return (rx, ry, rz);
+ }
+
+ private (double X, double Y, double Z) QuaternionComposeToWorld(double cx, double cy, double cz)
+ {
+ VisionPose3DDataType cameraInWorld = m_provider.CameraInWorldPose;
+ System.ReadOnlySpan pos = cameraInWorld.Position.Span;
+ System.ReadOnlySpan ori = cameraInWorld.Orientation.Span;
+ if (pos.Length < 3 || ori.Length < 4)
+ {
+ return (0.0, 0.0, 0.0);
+ }
+ double qx = ori[0];
+ double qy = ori[1];
+ double qz = ori[2];
+ double qw = ori[3];
+ double tx = (qy * cz) - (qz * cy);
+ double ty = (qz * cx) - (qx * cz);
+ double tz = (qx * cy) - (qy * cx);
+ double rx = cx + (2.0 * ((qw * tx) + (qy * tz) - (qz * ty)));
+ double ry = cy + (2.0 * ((qw * ty) + (qz * tx) - (qx * tz)));
+ double rz = cz + (2.0 * ((qw * tz) + (qx * ty) - (qy * tx)));
+ return (pos[0] + rx, pos[1] + ry, pos[2] + rz);
+ }
+
+ private const int AttachAttempts = 300;
+ private const string RedCubeClass = "RedCube";
+ private const int HallucinationCount = 20;
+ private static readonly TimeSpan AttachPollInterval = TimeSpan.FromMilliseconds(100);
+ private static readonly double[] s_identityOrientation = [0.0, 0.0, 0.0, 1.0];
+ private static readonly double[] s_zeroNormPosition = [0.0, 0.0, 0.5];
+ private static readonly double[] s_zeroNormQuat = [0.0, 0.0, 0.0, 0.0];
+
+ private readonly BinPickingAgentInferenceProvider m_provider;
+ private readonly ILogger m_logger;
+ }
+
+ internal static partial class BinPickingOffServerProofLog
+ {
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 1,
+ Level = LogLevel.Information,
+ Message = "Bin-picking off-server perception proof waiting for pipeline to attach.")]
+ public static partial void ProofWaiting(this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 2,
+ Level = LogLevel.Warning,
+ Message = "Bin-picking off-server perception proof gave up: agent provider never attached.")]
+ public static partial void ProofProviderNotAttached(
+ this ILogger logger);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 3,
+ Level = LogLevel.Information,
+ Message = "=== Bin-picking demo: {Banner} ===")]
+ public static partial void ProofBanner(
+ this ILogger logger, string banner);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 4,
+ Level = LogLevel.Information,
+ Message = "RunInference refused (as designed for InferenceLocation=EdgeOffServer): " +
+ "code=0x{StatusCode:X8} reason='{Reason}'")]
+ public static partial void ProofRunInferenceRefused(
+ this ILogger logger,
+ uint statusCode, string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 5,
+ Level = LogLevel.Information,
+ Message = "Published off-server result {ResultId} ({DetectionCount} detections): " +
+ "Sensor={SensorId} Pipeline={PipelineId} ModelVersionUsed='{ModelVersion}' " +
+ "ExplanationUri='{ExplanationUri}' FrameId='{FrameId}'.")]
+ public static partial void ProofPublishedResult(
+ this ILogger logger,
+ string resultId, int detectionCount, NodeId sensorId, NodeId pipelineId,
+ string modelVersion, string explanationUri, string frameId);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 6,
+ Level = LogLevel.Information,
+ Message = "Composed submitted {ClassLabel} pose camera_eih -> world = " +
+ "({ComposedX:0.000},{ComposedY:0.000},{ComposedZ:0.000}); " +
+ "authored=({AuthoredX:0.000},{AuthoredY:0.000},{AuthoredZ:0.000}); " +
+ "residual={ResidualMetres:0.0000} m")]
+ public static partial void ProofComposedPose(
+ this ILogger logger,
+ string classLabel,
+ double composedX, double composedY, double composedZ,
+ double authoredX, double authoredY, double authoredZ,
+ double residualMetres);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 7,
+ Level = LogLevel.Warning,
+ Message = "Submission was refused when the proof expected it to succeed: " +
+ "code=0x{StatusCode:X8} reason='{Reason}'")]
+ public static partial void ProofSubmissionFailed(
+ this ILogger logger,
+ uint statusCode, string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 8,
+ Level = LogLevel.Warning,
+ Message = "Result '{ResultId}' was not stored on the provider — cannot inspect.")]
+ public static partial void ProofResultUnavailable(
+ this ILogger logger, string resultId);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 9,
+ Level = LogLevel.Information,
+ Message = "Refusal scenario '{Scenario}' → code=0x{StatusCode:X8} reason='{Reason}'")]
+ public static partial void ProofRefusalScenario(
+ this ILogger logger,
+ string scenario, uint statusCode, string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 12,
+ Level = LogLevel.Information,
+ Message = "Accepted scenario '{Scenario}' → code=0x{StatusCode:X8} reason='{Reason}'")]
+ public static partial void ProofAcceptedScenario(
+ this ILogger logger,
+ string scenario, uint statusCode, string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 10,
+ Level = LogLevel.Information,
+ Message = "Correction against {OriginalResultId} → code=0x{StatusCode:X8} reason='{Reason}'")]
+ public static partial void ProofCorrectionResult(
+ this ILogger logger,
+ string originalResultId, uint statusCode, string reason);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.OffServerProof + 11,
+ Level = LogLevel.Information,
+ Message = "=== Bin-picking demo: off-server perception proof completed ===")]
+ public static partial void ProofCompleted(
+ this ILogger logger);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingPalletizerGeometry.cs b/samples/Robotics/BinPickingCell/BinPickingPalletizerGeometry.cs
new file mode 100644
index 0000000000..9d28914601
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingPalletizerGeometry.cs
@@ -0,0 +1,54 @@
+/* ========================================================================
+ * 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/
+ * ======================================================================*/
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Dimensions of the branch-stable palletizer used by the bin-picking cell.
+ ///
+ internal static class BinPickingPalletizerGeometry
+ {
+ public const string RobotBaseFrameId = "robot_base";
+ public const int AxisCount = 4;
+ public const double ShoulderHeightMetres = 0.280;
+ public const double UpperArmLengthMetres = 0.480;
+ public const double ForearmLengthMetres = 0.480;
+ public const double FlangeToTcpMetres = 0.185;
+
+ public const double MaximumReachMetres =
+ UpperArmLengthMetres + ForearmLengthMetres;
+
+ public const double BaseYawLimitRadians = 3.1415926535897931;
+ public const double ShoulderMinimumRadians = -1.3962634015954636;
+ public const double ShoulderMaximumRadians = 2.2689280275926285;
+ public const double ElbowMinimumRadians = -2.6179938779914944;
+ public const double ElbowMaximumRadians = 2.6179938779914944;
+ public const double ToolRollLimitRadians = 3.1415926535897931;
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingPalletizerKinematics.cs b/samples/Robotics/BinPickingCell/BinPickingPalletizerKinematics.cs
new file mode 100644
index 0000000000..e372d36922
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingPalletizerKinematics.cs
@@ -0,0 +1,607 @@
+/* ========================================================================
+ * 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.Diagnostics.CodeAnalysis;
+using Opc.Ua;
+using Opc.Ua.RobotIntent;
+using Robotics.IntentEnabledRobot.Kinematics;
+using Robotics.IntentEnabledRobot.Simulation;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Analytic kinematics for the bin-picking palletizer.
+ ///
+ internal sealed class BinPickingPalletizerKinematics : ISimulatedArmKinematics
+ {
+ public int AxisCount => BinPickingPalletizerGeometry.AxisCount;
+
+ public double MaximumReach => BinPickingPalletizerGeometry.MaximumReachMetres;
+
+ public ArrayOf InitialJointAngles => ArrayOf.Create(s_initialJointAngles.AsSpan());
+
+ public double MinimumLinkHeight { get; set; } = double.NegativeInfinity;
+
+ public SimulatedCollisionModel? Collisions { get; set; }
+
+ public void SetHeldObjectEnvelope(double sizeX, double sizeY, double sizeZ)
+ {
+ if (sizeX < 0.0 || sizeY < 0.0 || sizeZ < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(sizeX),
+ "Held-object dimensions must be non-negative.");
+ }
+ m_heldObjectSizeX = sizeX;
+ m_heldObjectSizeY = sizeY;
+ m_heldObjectSizeZ = sizeZ;
+ }
+
+ public void ClearHeldObjectEnvelope()
+ {
+ m_heldObjectSizeX = 0.0;
+ m_heldObjectSizeY = 0.0;
+ m_heldObjectSizeZ = 0.0;
+ }
+
+ public SimulatedArmForwardPose Forward(ReadOnlySpan jointAngles)
+ {
+ RequireJointCount(jointAngles);
+
+ double baseYaw = jointAngles[0];
+ double shoulder = jointAngles[1];
+ double elbow = jointAngles[2];
+ double toolRoll = jointAngles[3];
+ double forearmPitch = shoulder + elbow;
+
+ double cosBase = Math.Cos(baseYaw);
+ double sinBase = Math.Sin(baseYaw);
+
+ double elbowRadius =
+ BinPickingPalletizerGeometry.UpperArmLengthMetres * Math.Cos(shoulder);
+ double elbowZ =
+ BinPickingPalletizerGeometry.ShoulderHeightMetres -
+ (BinPickingPalletizerGeometry.UpperArmLengthMetres * Math.Sin(shoulder));
+
+ double wristRadius = elbowRadius +
+ (BinPickingPalletizerGeometry.ForearmLengthMetres * Math.Cos(forearmPitch));
+ double wristZ = elbowZ -
+ (BinPickingPalletizerGeometry.ForearmLengthMetres * Math.Sin(forearmPitch));
+
+ double[] shoulderPosition =
+ [0.0, 0.0, BinPickingPalletizerGeometry.ShoulderHeightMetres];
+ double[] elbowPosition =
+ [elbowRadius * cosBase, elbowRadius * sinBase, elbowZ];
+ double[] wristPosition =
+ [wristRadius * cosBase, wristRadius * sinBase, wristZ];
+
+ double[] baseOrientation = Orientation(baseYaw, 0.0, 0.0);
+ double[] shoulderOrientation = Orientation(baseYaw, shoulder, 0.0);
+ double[] elbowOrientation = Orientation(baseYaw, forearmPitch, 0.0);
+ double[] toolOrientation = Orientation(baseYaw, HalfTurn, toolRoll);
+ ArrayOf toolAxis = PoseMath.RotateVector(toolOrientation, s_axisX);
+ ReadOnlySpan axis = toolAxis.Span;
+ double[] toolPosition =
+ [
+ wristPosition[0] + (axis[0] * BinPickingPalletizerGeometry.FlangeToTcpMetres),
+ wristPosition[1] + (axis[1] * BinPickingPalletizerGeometry.FlangeToTcpMetres),
+ wristPosition[2] + (axis[2] * BinPickingPalletizerGeometry.FlangeToTcpMetres)
+ ];
+
+ ArrayOf frames = ArrayOf.Create(
+ [
+ Pose(shoulderPosition, baseOrientation),
+ Pose(shoulderPosition, shoulderOrientation),
+ Pose(elbowPosition, elbowOrientation),
+ Pose(wristPosition, toolOrientation)
+ ]);
+ return new SimulatedArmForwardPose(
+ Pose(toolPosition, toolOrientation),
+ frames);
+ }
+
+ public SimulatedArmIkResult Inverse(
+ Pose3DDataType target,
+ ReadOnlySpan referenceJointAngles)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ RequireJointCount(referenceJointAngles);
+ if (target.Position.Count < 3 || target.Orientation.Count < 4)
+ {
+ return new SimulatedArmIkResult(
+ SimulatedArmKinematicFailure.Kinematics,
+ "The target must carry a 3D position and quaternion orientation.",
+ []);
+ }
+
+ ArrayOf toolAxis = PoseMath.RotateVector(target.Orientation.Span, s_axisX);
+ ReadOnlySpan axis = toolAxis.Span;
+ if (Math.Abs(axis[0]) > OrientationTolerance ||
+ Math.Abs(axis[1]) > OrientationTolerance ||
+ Math.Abs(axis[2] + 1.0) > OrientationTolerance)
+ {
+ return new SimulatedArmIkResult(
+ SimulatedArmKinematicFailure.Kinematics,
+ "The palletizer supports tool-down targets only.",
+ []);
+ }
+
+ ReadOnlySpan targetPosition = target.Position.Span;
+ double wristX = targetPosition[0] -
+ (axis[0] * BinPickingPalletizerGeometry.FlangeToTcpMetres);
+ double wristY = targetPosition[1] -
+ (axis[1] * BinPickingPalletizerGeometry.FlangeToTcpMetres);
+ double wristZ = targetPosition[2] -
+ (axis[2] * BinPickingPalletizerGeometry.FlangeToTcpMetres);
+ double radius = Math.Sqrt((wristX * wristX) + (wristY * wristY));
+ double verticalDrop =
+ BinPickingPalletizerGeometry.ShoulderHeightMetres - wristZ;
+ const double upper = BinPickingPalletizerGeometry.UpperArmLengthMetres;
+ const double forearm = BinPickingPalletizerGeometry.ForearmLengthMetres;
+ double cosineElbow =
+ (((radius * radius) + (verticalDrop * verticalDrop)) -
+ (upper * upper) -
+ (forearm * forearm)) /
+ (2.0 * upper * forearm);
+ if (cosineElbow < -1.0 - PositionTolerance ||
+ cosineElbow > 1.0 + PositionTolerance)
+ {
+ return new SimulatedArmIkResult(
+ SimulatedArmKinematicFailure.Unreachable,
+ "The target lies outside the palletizer workspace.",
+ []);
+ }
+ cosineElbow = Math.Clamp(cosineElbow, -1.0, 1.0);
+
+ double baseYaw = NormalizeAngle(Math.Atan2(wristY, wristX));
+ if (!TryExtractToolRoll(
+ target.Orientation.Span,
+ baseYaw,
+ out double requestedToolRoll))
+ {
+ return new SimulatedArmIkResult(
+ SimulatedArmKinematicFailure.Kinematics,
+ "The target yaw cannot be represented by the palletizer wrist.",
+ []);
+ }
+ requestedToolRoll = NormalizeAngle(requestedToolRoll);
+
+ var solutions = new List(2);
+ double elbowMagnitude = Math.Acos(cosineElbow);
+ AddBranch(
+ elbowMagnitude,
+ baseYaw,
+ requestedToolRoll,
+ radius,
+ verticalDrop,
+ referenceJointAngles,
+ solutions);
+ if (elbowMagnitude > PositionTolerance)
+ {
+ AddBranch(
+ -elbowMagnitude,
+ baseYaw,
+ requestedToolRoll,
+ radius,
+ verticalDrop,
+ referenceJointAngles,
+ solutions);
+ }
+ solutions.Sort(static (left, right) =>
+ {
+ // Prefer elbow-up when travel is effectively tied; it keeps the elbow and
+ // wrist above the work instead of folding toward the table.
+ double delta = left.TravelCost - right.TravelCost;
+ if (Math.Abs(delta) > 1e-9)
+ {
+ return delta < 0.0 ? -1 : 1;
+ }
+ return left.JointAngles[2].CompareTo(right.JointAngles[2]);
+ });
+
+ return solutions.Count == 0
+ ? new SimulatedArmIkResult(
+ SimulatedArmKinematicFailure.JointLimit,
+ "All palletizer branches exceed a joint limit.",
+ [])
+ : new SimulatedArmIkResult(
+ SimulatedArmKinematicFailure.None,
+ string.Empty,
+ ArrayOf.Create(solutions.ToArray().AsSpan()));
+ }
+
+ public bool TrySelectNearest(
+ Pose3DDataType target,
+ ReadOnlySpan currentJointAngles,
+ [NotNullWhen(true)] out SimulatedArmIkSolution? solution,
+ out SimulatedArmKinematicFailure failure)
+ {
+ return TrySelectNearestCore(
+ target,
+ currentJointAngles,
+ requireClearPath: true,
+ out solution,
+ out failure);
+ }
+
+ public bool TrySelectNearestConfiguration(
+ Pose3DDataType target,
+ ReadOnlySpan currentJointAngles,
+ [NotNullWhen(true)] out SimulatedArmIkSolution? solution,
+ out SimulatedArmKinematicFailure failure)
+ {
+ return TrySelectNearestCore(
+ target,
+ currentJointAngles,
+ requireClearPath: false,
+ out solution,
+ out failure);
+ }
+
+ public bool IsWithinLimits(ReadOnlySpan jointAngles)
+ {
+ RequireJointCount(jointAngles);
+ return Math.Abs(jointAngles[0]) <= BinPickingPalletizerGeometry.BaseYawLimitRadians &&
+ jointAngles[1] >= BinPickingPalletizerGeometry.ShoulderMinimumRadians &&
+ jointAngles[1] <= BinPickingPalletizerGeometry.ShoulderMaximumRadians &&
+ jointAngles[2] >= BinPickingPalletizerGeometry.ElbowMinimumRadians &&
+ jointAngles[2] <= BinPickingPalletizerGeometry.ElbowMaximumRadians &&
+ Math.Abs(jointAngles[3]) <= BinPickingPalletizerGeometry.ToolRollLimitRadians;
+ }
+
+ public ArrayOf InterpolateJoints(
+ ReadOnlySpan start,
+ ReadOnlySpan end,
+ double fraction)
+ {
+ RequireJointCount(start);
+ RequireJointCount(end);
+ double t = Math.Clamp(fraction, 0.0, 1.0);
+ return ArrayOf.Create(
+ [
+ Lerp(start[0], end[0], t),
+ Lerp(start[1], end[1], t),
+ Lerp(start[2], end[2], t),
+ Lerp(start[3], end[3], t)
+ ]);
+ }
+
+ public Pose3DDataType InterpolateCartesian(
+ Pose3DDataType start,
+ Pose3DDataType end,
+ double fraction)
+ {
+ ArgumentNullException.ThrowIfNull(start);
+ ArgumentNullException.ThrowIfNull(end);
+ double t = Math.Clamp(fraction, 0.0, 1.0);
+ ReadOnlySpan a = start.Position.Span;
+ ReadOnlySpan b = end.Position.Span;
+ return new Pose3DDataType
+ {
+ FrameId = string.IsNullOrEmpty(end.FrameId) ? start.FrameId : end.FrameId,
+ Position = ArrayOf.Create(
+ [
+ Lerp(a[0], b[0], t),
+ Lerp(a[1], b[1], t),
+ Lerp(a[2], b[2], t)
+ ]),
+ Orientation = Nlerp(start.Orientation.Span, end.Orientation.Span, t)
+ };
+ }
+
+ public bool ClearsPath(ReadOnlySpan start, ReadOnlySpan target)
+ {
+ RequireJointCount(start);
+ RequireJointCount(target);
+ Span configuration = stackalloc double[BinPickingPalletizerGeometry.AxisCount];
+ for (int step = 0; step <= PathSampleCount; step++)
+ {
+ double fraction = (double)step / PathSampleCount;
+ for (int ii = 0; ii < configuration.Length; ii++)
+ {
+ configuration[ii] = Lerp(start[ii], target[ii], fraction);
+ }
+ if (!ClearsWorkSurface(configuration))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public bool ClearsWorkSurface(ReadOnlySpan jointAngles)
+ {
+ SimulatedArmForwardPose pose = Forward(jointAngles);
+ ReadOnlySpan frames = pose.JointFramePoses.Span;
+ if (!double.IsNegativeInfinity(MinimumLinkHeight))
+ {
+ for (int ii = 0; ii < frames.Length; ii++)
+ {
+ if (frames[ii].Position.Span[2] < MinimumLinkHeight)
+ {
+ return false;
+ }
+ }
+ if (pose.ToolPose.Position.Span[2] < MinimumLinkHeight)
+ {
+ return false;
+ }
+ }
+ if (Collisions == null)
+ {
+ return true;
+ }
+ Span points = stackalloc double[(frames.Length + 1) * 3];
+ for (int ii = 0; ii < frames.Length; ii++)
+ {
+ ReadOnlySpan position = frames[ii].Position.Span;
+ points[(ii * 3) + 0] = position[0];
+ points[(ii * 3) + 1] = position[1];
+ points[(ii * 3) + 2] = position[2];
+ }
+ ReadOnlySpan tool = pose.ToolPose.Position.Span;
+ points[(frames.Length * 3) + 0] = tool[0];
+ points[(frames.Length * 3) + 1] = tool[1];
+ points[(frames.Length * 3) + 2] = tool[2];
+ if (!Collisions.IsClear(points, out _))
+ {
+ return false;
+ }
+ if (m_heldObjectSizeX <= 0.0 ||
+ m_heldObjectSizeY <= 0.0 ||
+ m_heldObjectSizeZ <= 0.0)
+ {
+ return true;
+ }
+ return Collisions.IsBoxClear(
+ tool[0],
+ tool[1],
+ tool[2] -
+ global::Robotics.IntentEnabledRobot.Simulation.SimulatedArmExecutor
+ .HeldPartTcpOffset,
+ m_heldObjectSizeX,
+ m_heldObjectSizeY,
+ m_heldObjectSizeZ,
+ out _);
+ }
+
+ public IntentFailureEnum MapFailure(SimulatedArmKinematicFailure failure)
+ {
+ return SimulatedArmKinematics.ToIntentFailure(failure);
+ }
+
+ ///
+ /// Creates a tool-down orientation for one base yaw and jaw roll.
+ ///
+ public static ArrayOf ToolDownOrientation(double baseYaw, double toolRoll)
+ {
+ return Orientation(baseYaw, HalfTurn, toolRoll).ToArrayOf();
+ }
+
+ private static void AddBranch(
+ double elbow,
+ double baseYaw,
+ double toolRoll,
+ double radius,
+ double verticalDrop,
+ ReadOnlySpan reference,
+ List solutions)
+ {
+ double shoulder = Math.Atan2(verticalDrop, radius) -
+ Math.Atan2(
+ BinPickingPalletizerGeometry.ForearmLengthMetres * Math.Sin(elbow),
+ BinPickingPalletizerGeometry.UpperArmLengthMetres +
+ (BinPickingPalletizerGeometry.ForearmLengthMetres * Math.Cos(elbow)));
+ double[] candidate = [baseYaw, shoulder, elbow, toolRoll];
+ var kinematics = new BinPickingPalletizerKinematics();
+ if (!kinematics.IsWithinLimits(candidate))
+ {
+ return;
+ }
+ double cost = 0.0;
+ for (int ii = 0; ii < candidate.Length; ii++)
+ {
+ double delta = candidate[ii] - reference[ii];
+ cost += delta * delta;
+ }
+ solutions.Add(new SimulatedArmIkSolution(candidate.ToArrayOf(), cost));
+ }
+
+ private bool TrySelectNearestCore(
+ Pose3DDataType target,
+ ReadOnlySpan currentJointAngles,
+ bool requireClearPath,
+ [NotNullWhen(true)] out SimulatedArmIkSolution? solution,
+ out SimulatedArmKinematicFailure failure)
+ {
+ SimulatedArmIkResult result = Inverse(target, currentJointAngles);
+ failure = result.Failure;
+ solution = null;
+ ReadOnlySpan candidates = result.Solutions.Span;
+ for (int ii = 0; ii < candidates.Length; ii++)
+ {
+ ReadOnlySpan angles = candidates[ii].JointAngles.Span;
+ if (ClearsWorkSurface(angles) &&
+ (!requireClearPath || ClearsPath(currentJointAngles, angles)))
+ {
+ solution = candidates[ii];
+ return true;
+ }
+ }
+ if (!result.Solutions.IsEmpty)
+ {
+ failure = SimulatedArmKinematicFailure.WorkSurface;
+ }
+ return false;
+ }
+
+ private static bool TryExtractToolRoll(
+ ReadOnlySpan orientation,
+ double baseYaw,
+ out double toolRoll)
+ {
+ double[] baseDown = Orientation(baseYaw, HalfTurn, 0.0);
+ double[] relative = Multiply(Inverse(baseDown), orientation);
+ relative = Normalize(relative);
+ if (Math.Abs(relative[1]) > OrientationTolerance ||
+ Math.Abs(relative[2]) > OrientationTolerance)
+ {
+ toolRoll = 0.0;
+ return false;
+ }
+ toolRoll = 2.0 * Math.Atan2(relative[0], relative[3]);
+ return true;
+ }
+
+ private static Pose3DDataType Pose(double[] position, double[] orientation)
+ {
+ return new Pose3DDataType
+ {
+ FrameId = BinPickingPalletizerGeometry.RobotBaseFrameId,
+ Position = position.ToArrayOf(),
+ Orientation = orientation.ToArrayOf()
+ };
+ }
+
+ private static double[] Orientation(double yaw, double pitch, double roll)
+ {
+ return Normalize(
+ Multiply(
+ Multiply(
+ [0.0, 0.0, Math.Sin(yaw * 0.5), Math.Cos(yaw * 0.5)],
+ [0.0, Math.Sin(pitch * 0.5), 0.0, Math.Cos(pitch * 0.5)]),
+ [Math.Sin(roll * 0.5), 0.0, 0.0, Math.Cos(roll * 0.5)]));
+ }
+
+ private static double[] Multiply(ReadOnlySpan left, ReadOnlySpan right)
+ {
+ double lx = left[0];
+ double ly = left[1];
+ double lz = left[2];
+ double lw = left[3];
+ double rx = right[0];
+ double ry = right[1];
+ double rz = right[2];
+ double rw = right[3];
+ return
+ [
+ (lw * rx) + (lx * rw) + (ly * rz) - (lz * ry),
+ (lw * ry) - (lx * rz) + (ly * rw) + (lz * rx),
+ (lw * rz) + (lx * ry) - (ly * rx) + (lz * rw),
+ (lw * rw) - (lx * rx) - (ly * ry) - (lz * rz)
+ ];
+ }
+
+ private static double[] Inverse(ReadOnlySpan value)
+ {
+ return [-value[0], -value[1], -value[2], value[3]];
+ }
+
+ private static double[] Normalize(ReadOnlySpan value)
+ {
+ double norm = Math.Sqrt(
+ (value[0] * value[0]) +
+ (value[1] * value[1]) +
+ (value[2] * value[2]) +
+ (value[3] * value[3]));
+ return
+ [
+ value[0] / norm,
+ value[1] / norm,
+ value[2] / norm,
+ value[3] / norm
+ ];
+ }
+
+ private static ArrayOf Nlerp(
+ ReadOnlySpan start,
+ ReadOnlySpan end,
+ double fraction)
+ {
+ double dot =
+ (start[0] * end[0]) +
+ (start[1] * end[1]) +
+ (start[2] * end[2]) +
+ (start[3] * end[3]);
+ double sign = dot < 0.0 ? -1.0 : 1.0;
+ double[] value =
+ [
+ Lerp(start[0], end[0] * sign, fraction),
+ Lerp(start[1], end[1] * sign, fraction),
+ Lerp(start[2], end[2] * sign, fraction),
+ Lerp(start[3], end[3] * sign, fraction)
+ ];
+ return Normalize(value).ToArrayOf();
+ }
+
+ private static double NormalizeAngle(double value)
+ {
+ while (value > Math.PI)
+ {
+ value -= TwoPi;
+ }
+ while (value < -Math.PI)
+ {
+ value += TwoPi;
+ }
+ return value;
+ }
+
+ private static double Lerp(double start, double end, double fraction)
+ {
+ return start + ((end - start) * fraction);
+ }
+
+ private static void RequireJointCount(ReadOnlySpan jointAngles)
+ {
+ if (jointAngles.Length != BinPickingPalletizerGeometry.AxisCount)
+ {
+ throw new ArgumentException(
+ $"Expected {BinPickingPalletizerGeometry.AxisCount} joint angles.",
+ nameof(jointAngles));
+ }
+ }
+
+ private static readonly double[] s_axisX = [1.0, 0.0, 0.0];
+
+ private static readonly double[] s_initialJointAngles =
+ [0.0, 0.4811790135369469, -1.6798993676150382, HalfTurn];
+
+ private const double HalfTurn = Math.PI / 2.0;
+ private const double TwoPi = Math.PI * 2.0;
+ private const double PositionTolerance = 1e-7;
+ private const double OrientationTolerance = 1e-5;
+ private const int PathSampleCount = 32;
+ private double m_heldObjectSizeX;
+ private double m_heldObjectSizeY;
+ private double m_heldObjectSizeZ;
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingParts.cs b/samples/Robotics/BinPickingCell/BinPickingParts.cs
new file mode 100644
index 0000000000..0df8696d3b
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingParts.cs
@@ -0,0 +1,423 @@
+/* ========================================================================
+ * 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.Threading;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Where a part currently lives in the cell.
+ ///
+ internal enum BinPickingPartLocation
+ {
+ ///
+ /// The part is in the bin at its authored position.
+ ///
+ InBin = 0,
+
+ ///
+ /// The part is currently held by the gripper.
+ ///
+ Held = 1,
+
+ ///
+ /// The part has been placed on the fixture (or somewhere else
+ /// outside the bin).
+ ///
+ Placed = 2
+ }
+
+ ///
+ /// Immutable description of a part in the cell. Values mirror the
+ /// authored transforms in Assets/Cell.usda: same class labels,
+ /// same initial world positions, same colours and same axis-aligned
+ /// bounding-box sizes. The detector reads this catalog rather than
+ /// re-parsing the USD file so the ground truth is deterministic and
+ /// available even when the OpenUSD render backend is not.
+ ///
+ ///
+ /// Human-readable class name (matches Cell.usda prim name).
+ ///
+ ///
+ /// Small integer id, monotonic and stable across restarts. A client
+ /// can key on the id when the label is not convenient.
+ ///
+ ///
+ /// Coarse shape hint: cube, cylinder, sphere,
+ /// slab or brick. Not part of the OPC UA payload but
+ /// used to compute the 3-D size vector below.
+ ///
+ ///
+ /// Approximate authored displayColor (RGB in [0,1]). Kept for the
+ /// demo's traceability — a client that reads the rendered frame can
+ /// cross-reference this against the mean colour of the reported
+ /// bounding box.
+ ///
+ ///
+ /// Position (metres) in the world frame, matching the
+ /// authored USD translate.
+ ///
+ ///
+ /// Rotation about the world Z axis, degrees, matching the authored
+ /// xformOp:rotateZ.
+ ///
+ ///
+ /// Axis-aligned size (width, depth, height) in metres before Z
+ /// rotation — the extents the detector reports as
+ /// BoundingBox3D.Size.
+ ///
+ internal sealed record BinPickingPart(
+ string ClassLabel,
+ uint ClassId,
+ string Shape,
+ double[] Colour,
+ double[] InitialWorldPosition,
+ double RotationZDegrees,
+ double[] Size);
+
+ ///
+ /// Mutable per-part runtime state. Copied out under a lock into a
+ /// snapshot so the detector can iterate lock-free.
+ ///
+ internal sealed class BinPickingPartRuntime
+ {
+ public BinPickingPartRuntime(BinPickingPart part)
+ {
+ Part = part ?? throw new ArgumentNullException(nameof(part));
+ WorldX = part.InitialWorldPosition[0];
+ WorldY = part.InitialWorldPosition[1];
+ WorldZ = part.InitialWorldPosition[2];
+ RotationZDegrees = part.RotationZDegrees;
+ Location = BinPickingPartLocation.InBin;
+ }
+
+ public BinPickingPart Part { get; }
+
+ public double WorldX { get; set; }
+
+ public double WorldY { get; set; }
+
+ public double WorldZ { get; set; }
+
+ public double RotationZDegrees { get; set; }
+
+ public BinPickingPartLocation Location { get; set; }
+ }
+
+ ///
+ /// Immutable snapshot of one part's live state, safe to hand out to
+ /// the detector without any lock.
+ ///
+ internal sealed record BinPickingPartSnapshot(
+ BinPickingPart Part,
+ double WorldX,
+ double WorldY,
+ double WorldZ,
+ double RotationZDegrees,
+ BinPickingPartLocation Location);
+
+ ///
+ /// Aggregate live state for the five parts in the cell. Registered
+ /// as a DI singleton and mutated from either the arm-executor's
+ /// pick/place events or from the demo hosted service; the detector
+ /// reads a lock-free snapshot on every inference tick.
+ ///
+ ///
+ ///
+ /// The class deliberately does not depend on the OPC UA server
+ /// address space or on the OpenUSD stage: this is where the demo
+ /// declares "what parts exist and where they are right now". The
+ /// values are seeded from
+ /// which mirrors the authored transforms in Assets/Cell.usda.
+ ///
+ ///
+ /// Mutation uses because these
+ /// operations are short and synchronous; the detector never blocks
+ /// on the lock — it copies the state into an
+ /// and
+ /// walks that copy.
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1812",
+ Justification = "Instantiated by the DI container via AddSingleton.")]
+ internal sealed class BinPickingWorldState
+ {
+ public BinPickingWorldState()
+ {
+ IReadOnlyList catalog = BinPickingPartsCatalog.Parts;
+ m_parts = new BinPickingPartRuntime[catalog.Count];
+ for (int ii = 0; ii < catalog.Count; ii++)
+ {
+ m_parts[ii] = new BinPickingPartRuntime(catalog[ii]);
+ }
+ }
+
+ ///
+ /// Returns a lock-free snapshot of every part's current state.
+ ///
+ public IReadOnlyList Snapshot()
+ {
+ lock (m_lock)
+ {
+ var copy = new BinPickingPartSnapshot[m_parts.Length];
+ for (int ii = 0; ii < m_parts.Length; ii++)
+ {
+ BinPickingPartRuntime runtime = m_parts[ii];
+ copy[ii] = new BinPickingPartSnapshot(
+ runtime.Part,
+ runtime.WorldX,
+ runtime.WorldY,
+ runtime.WorldZ,
+ runtime.RotationZDegrees,
+ runtime.Location);
+ }
+ return copy;
+ }
+ }
+
+ ///
+ /// Marks the part with as
+ /// and moves its
+ /// world position to ,
+ /// , .
+ ///
+ ///
+ /// true when the class was recognised.
+ ///
+ /// is null.
+ public bool MarkHeld(string classLabel, double worldX, double worldY, double worldZ)
+ {
+ if (classLabel == null)
+ {
+ throw new ArgumentNullException(nameof(classLabel));
+ }
+ lock (m_lock)
+ {
+ for (int ii = 0; ii < m_parts.Length; ii++)
+ {
+ BinPickingPartRuntime runtime = m_parts[ii];
+ if (string.Equals(runtime.Part.ClassLabel, classLabel, StringComparison.Ordinal))
+ {
+ runtime.Location = BinPickingPartLocation.Held;
+ runtime.WorldX = worldX;
+ runtime.WorldY = worldY;
+ runtime.WorldZ = worldZ;
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ ///
+ /// Marks the part with as released at
+ /// , ,
+ /// , and records whether that spot is inside the bin.
+ ///
+ ///
+ /// The location label follows the coordinates rather than the operation: a part
+ /// put back inside the bin's footprint is
+ /// again. It used to become whatever
+ /// the coordinates said, and since the detector only reports parts that are InBin,
+ /// a part the robot had returned to the bin stayed invisible to the camera - the
+ /// world model claiming one thing while its own coordinates said another.
+ ///
+ ///
+ /// true when the class was recognised.
+ ///
+ /// is null.
+ public bool MarkPlaced(string classLabel, double worldX, double worldY, double worldZ)
+ {
+ if (classLabel == null)
+ {
+ throw new ArgumentNullException(nameof(classLabel));
+ }
+ lock (m_lock)
+ {
+ for (int ii = 0; ii < m_parts.Length; ii++)
+ {
+ BinPickingPartRuntime runtime = m_parts[ii];
+ if (string.Equals(runtime.Part.ClassLabel, classLabel, StringComparison.Ordinal))
+ {
+ runtime.Location = BinPickingPartsCatalog.IsInsideBin(worldX, worldY)
+ ? BinPickingPartLocation.InBin
+ : BinPickingPartLocation.Placed;
+ runtime.WorldX = worldX;
+ runtime.WorldY = worldY;
+ runtime.WorldZ = worldZ;
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ ///
+ /// Resets every part to its authored position and marks it as
+ /// . Used by the proof
+ /// service so a second run of the demo starts from a known
+ /// state.
+ ///
+ public void Reset()
+ {
+ lock (m_lock)
+ {
+ for (int ii = 0; ii < m_parts.Length; ii++)
+ {
+ BinPickingPartRuntime runtime = m_parts[ii];
+ runtime.WorldX = runtime.Part.InitialWorldPosition[0];
+ runtime.WorldY = runtime.Part.InitialWorldPosition[1];
+ runtime.WorldZ = runtime.Part.InitialWorldPosition[2];
+ runtime.RotationZDegrees = runtime.Part.RotationZDegrees;
+ runtime.Location = BinPickingPartLocation.InBin;
+ }
+ }
+ }
+
+ private readonly BinPickingPartRuntime[] m_parts;
+ private readonly Lock m_lock = new();
+ }
+
+ ///
+ /// Static catalog seeded from Assets/Cell.usda. The numbers
+ /// mirror the authored transforms exactly; changing one without the
+ /// other silently splits the ground truth from the rendered image.
+ ///
+ internal static class BinPickingPartsCatalog
+ {
+ ///
+ /// Returns the five parts of the reference bin, in the order
+ /// they are declared in the USD stage.
+ ///
+ public static IReadOnlyList Parts => s_parts;
+
+ ///
+ /// Gets whether a world position is inside the bin's footprint.
+ ///
+ ///
+ /// The bin is where parts are picked from and returned to, so "is it in the bin"
+ /// is what decides whether the camera should still be reporting a part. Keeping
+ /// the footprint here, next to the authored part positions, keeps one answer to
+ /// where the bin is: the cell's Bin Location is built from these same numbers.
+ ///
+ public static bool IsInsideBin(double worldX, double worldY)
+ {
+ return Math.Abs(worldX - BinCentreX) <= BinHalfExtent &&
+ Math.Abs(worldY - BinCentreY) <= BinHalfExtent;
+ }
+
+ ///
+ /// Looks up a part by its class label. Returns null when
+ /// the label is unknown — used by the demo hosted service to
+ /// cross-check a composed pose against the authored world
+ /// position.
+ ///
+ public static BinPickingPart? TryGet(string classLabel)
+ {
+ if (classLabel == null)
+ {
+ return null;
+ }
+ for (int ii = 0; ii < s_parts.Length; ii++)
+ {
+ if (string.Equals(s_parts[ii].ClassLabel, classLabel, StringComparison.Ordinal))
+ {
+ return s_parts[ii];
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Centre of the bin in the world frame, matching Assets/Cell.usda.
+ ///
+ public const double BinCentreX = 0.60;
+
+ ///
+ /// Centre of the bin in the world frame, matching Assets/Cell.usda.
+ ///
+ public const double BinCentreY = 0.0;
+
+ ///
+ /// Centre of the fixture in the world frame, matching Assets/Cell.usda.
+ ///
+ public const double FixtureCentreX = -0.600;
+
+ ///
+ /// Half the bin's inner span; a part within this of the centre is in the bin.
+ ///
+ public const double BinHalfExtent = 0.12;
+
+ private static readonly BinPickingPart[] s_parts = [
+ new BinPickingPart(
+ ClassLabel: "RedCube",
+ ClassId: 1u,
+ Shape: "cube",
+ Colour: [0.90, 0.15, 0.15],
+ InitialWorldPosition: [0.5200, -0.0800, 0.7400],
+ RotationZDegrees: 20.0,
+ Size: [0.0400, 0.0400, 0.0400]),
+ new BinPickingPart(
+ ClassLabel: "GreenCylinder",
+ ClassId: 2u,
+ Shape: "cylinder",
+ Colour: [0.15, 0.85, 0.20],
+ InitialWorldPosition: [0.6700, 0.0800, 0.7350],
+ RotationZDegrees: 0.0,
+ Size: [0.0400, 0.0400, 0.0300]),
+ new BinPickingPart(
+ ClassLabel: "BlueSphere",
+ ClassId: 3u,
+ Shape: "sphere",
+ Colour: [0.15, 0.30, 0.95],
+ InitialWorldPosition: [0.6800, -0.0800, 0.7440],
+ RotationZDegrees: 0.0,
+ Size: [0.0480, 0.0480, 0.0480]),
+ new BinPickingPart(
+ ClassLabel: "YellowSlab",
+ ClassId: 4u,
+ Shape: "slab",
+ Colour: [0.95, 0.85, 0.15],
+ InitialWorldPosition: [0.5200, 0.0600, 0.7290],
+ RotationZDegrees: -15.0,
+ Size: [0.0640, 0.0320, 0.0180]),
+ new BinPickingPart(
+ ClassLabel: "OrangeBrick",
+ ClassId: 5u,
+ Shape: "brick",
+ Colour: [0.95, 0.45, 0.10],
+ InitialWorldPosition: [0.6000, 0.0000, 0.7320],
+ RotationZDegrees: 40.0,
+ Size: [0.0500, 0.0280, 0.0240])
+ ];
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingRobotCell.cs b/samples/Robotics/BinPickingCell/BinPickingRobotCell.cs
new file mode 100644
index 0000000000..3a858f539f
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingRobotCell.cs
@@ -0,0 +1,1086 @@
+/* ========================================================================
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Robotics.Server;
+using Opc.Ua.Robotics.Server.Builders;
+using Opc.Ua.RobotIntent;
+using Opc.Ua.Server;
+using Robotics.IntentEnabledRobot.Kinematics;
+using Robotics.IntentEnabledRobot.Simulation;
+using ThreeDCartesianCoordinates = Opc.Ua.ThreeDCartesianCoordinates;
+using ThreeDFrame = Opc.Ua.ThreeDFrame;
+using ThreeDOrientation = Opc.Ua.ThreeDOrientation;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Robot Intent side of the bin-picking cell. Reuses the simulated arm
+ /// executor from the IntentEnabledRobot sample and exposes the
+ /// controller with the frame identifiers from the OPC UA Robotics-Vision
+ /// Addendum (§4 worked example) so the Vision node manager can name its
+ /// coordinate frames the same way.
+ ///
+ ///
+ ///
+ /// The frame names world, robot_base, flange and
+ /// gripper_tcp match the addendum's frame tree and the
+ /// FrameId values used by the vision-side calibrations. Publishing
+ /// the same names from both node managers lets a client cross-reference
+ /// the intent side ("go to gripper_tcp") with the vision side
+ /// ("HandEye calibrates camera_eih to flange")
+ /// without any translation step.
+ ///
+ ///
+ /// The controller carries only the two locations the demo actually uses
+ /// (Bin and Fixture); the extra "Inspect" and "Handoff"
+ /// stops from IntentEnabledRobot are omitted here because the
+ /// bin-picking demo picks from the bin and places on the fixture and
+ /// nothing else.
+ ///
+ ///
+ internal sealed partial class BinPickingRobotCell : IDisposable
+ {
+ public BinPickingRobotCell(
+ ILogger logger,
+ SimulatedArmExecutor executor,
+ BinPickingPalletizerKinematics kinematics,
+ BinPickingWorldState worldState,
+ IBinPickingTargetProvider targetProvider)
+ {
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ m_executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ m_kinematics = kinematics ?? throw new ArgumentNullException(nameof(kinematics));
+ m_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ m_targetProvider = targetProvider ?? throw new ArgumentNullException(nameof(targetProvider));
+ m_toolDownOrientation = executor.CurrentSnapshot.ToolPose.Orientation;
+ m_executor.SnapshotChanged += OnSnapshotChanged;
+ m_executor.ResolveLocationPosition = TryResolveLocationPosition;
+ m_executor.ResolveLocationPose = TryResolveLocationPose;
+ m_executor.ResolvePickPose = TryResolvePickPose;
+ m_executor.PickAttemptFinished = OnPickAttemptFinished;
+ m_executor.PreferCartesianDescent = PreferCartesianDescent;
+ m_executor.Diagnostic = message => m_logger.ArmTravel(message);
+ UpdateMovingObstacles();
+ }
+
+ internal ServerSystemContext SystemContext => m_systemContext ??
+ throw new InvalidOperationException(
+ "BinPickingRobotCell has not been attached to a Robot Intent build context.");
+
+ internal AsyncCustomNodeManager Manager => m_manager ??
+ throw new InvalidOperationException(
+ "BinPickingRobotCell has not been attached to a Robot Intent build context.");
+
+ internal IIntentControllerBuilder Controller => m_controller ??
+ throw new InvalidOperationException(
+ "The bin-picking intent controller has not been materialised.");
+
+ internal IEnumerable Axes => m_axes;
+
+ internal ushort InstanceNamespaceIndex => m_instanceNamespaceIndex;
+
+ internal IReadOnlyDictionary LocationNodes => m_locationNodes;
+
+ ///
+ /// Configures the Robot Intent controller and OpenUSD nodes for the cell.
+ ///
+ /// is null.
+ public async ValueTask ConfigureAsync(
+ IRobotIntentBuildContext context, CancellationToken cancellationToken)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+ m_manager = context.Manager;
+ m_systemContext = context.Manager.SystemContext;
+ m_instanceNamespaceIndex = context.InstanceNamespaceIndex;
+ await MaterialiseOpenUsdFacilityAsync(cancellationToken).ConfigureAwait(false);
+ m_controller = await context.AddIntentControllerAsync(
+ "BinPickingController",
+ ConfigureController,
+ cancellationToken).ConfigureAwait(false);
+ await MaterialisePartStateAsync(cancellationToken).ConfigureAwait(false);
+ await MaterialiseRepresentationsAsync(cancellationToken).ConfigureAwait(false);
+ PublishSnapshot(m_executor.CurrentSnapshot);
+ ArrayOf facets = m_controller.ComputeFacets();
+ m_logger.RobotCellReady(m_axes.Count, m_locations.Count, facets);
+ }
+
+ ///
+ public void Dispose()
+ {
+ m_executor.SnapshotChanged -= OnSnapshotChanged;
+ }
+
+ private void ConfigureController(IIntentControllerBuilder controller)
+ {
+ controller
+ .WithOperationalMode(OperationalModeEnum.AutomaticExternal)
+ .WithReady(true)
+ .WithMaxQueueDepth(16)
+ .Accepts(cancelSupported: true)
+ .Accepts(cancelSupported: true, pauseSupported: true)
+ .Accepts(cancelSupported: false)
+ .Accepts(cancelSupported: true)
+ .Accepts(cancelSupported: false)
+ .Accepts(cancelSupported: true)
+ .Accepts(cancelSupported: true, pauseSupported: true);
+
+ controller.State.Capabilities!.MissionsSupported!.Value = true;
+ controller.State.Capabilities.BlendingSupported!.Value = false;
+ controller.State.Capabilities.ForceControlSupported!.Value = false;
+ controller.State.Capabilities.MaxTrajectoryPoints!.Value = 64u;
+
+ IIntentFrameBuilder world = controller.AddFrame(
+ "World",
+ WorldFrameId,
+ FrameRoleEnum.World,
+ Pose(WorldFrameId, 0.0, 0.0, 0.0));
+ IIntentFrameBuilder @base = controller.AddFrame(
+ "RobotBase",
+ RobotBaseFrameId,
+ FrameRoleEnum.Base,
+ Pose(WorldFrameId, 0.0, 0.0, RobotBaseHeightMetres),
+ frame => frame.WithParent(world));
+ IIntentFrameBuilder flange = controller.AddFrame(
+ "Flange",
+ FlangeFrameId,
+ FrameRoleEnum.MechanicalInterface,
+ m_kinematics.Forward(m_kinematics.InitialJointAngles.Span).JointFramePoses[3],
+ frame => frame.WithParent(@base));
+ IIntentFrameBuilder tool = controller.AddFrame(
+ "GripperTcp",
+ ToolFrameId,
+ FrameRoleEnum.Tool,
+ Pose(
+ FlangeFrameId,
+ BinPickingPalletizerGeometry.FlangeToTcpMetres,
+ 0.0,
+ 0.0),
+ frame => frame.WithParent(flange));
+ controller.AddTool("ParallelGripper", tool, fitted: true);
+
+ IIntentOutputSignalBuilder gripperLeft = controller.AddOutput(
+ "GripperLeftSlide",
+ Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
+ ToVariant(GripperSlide(m_executor.CurrentSnapshot.GripperOpening, 1.0)));
+ IIntentOutputSignalBuilder gripperRight = controller.AddOutput(
+ "GripperRightSlide",
+ Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
+ ToVariant(GripperSlide(m_executor.CurrentSnapshot.GripperOpening, -1.0)));
+ m_gripperLeftSlideValue = gripperLeft.State.Value;
+ m_gripperRightSlideValue = gripperRight.State.Value;
+ IIntentOutputSignalBuilder leveling = controller.AddOutput(
+ "PalletizerLeveling",
+ Opc.Ua.DataTypeIds.Double,
+ new Variant(LevelingDegrees(m_executor.CurrentSnapshot.JointAngles.Span)));
+ m_levelingValue = leveling.State.Value;
+
+ for (uint index = 0; index < s_axes.Length; index++)
+ {
+ IIntentAxisBuilder axis = controller.AddAxis(s_axes[index], index, AxisKindEnum.Revolute);
+ (double minimum, double maximum) = AxisLimits(index);
+ ConfigureAxis(axis.State, minimum, maximum);
+ m_axes.Add(axis.State);
+ }
+
+ // Publish where the arm actually is. Leaving Position unset reports 0 for every
+ // axis, which this arm is never in: at all-zeros the elbow and forearm hang
+ // straight down through the bench. A client - and the OpenUSD live binding that
+ // renders from these very nodes - would faithfully show a pose the robot never
+ // held, so seed them from the simulator's own starting configuration.
+ PublishSnapshot(m_executor.CurrentSnapshot);
+
+ foreach ((string name, double x, double y, double z, double rz) in s_locations)
+ {
+ bool isBin = string.Equals(name, BinLocationName, StringComparison.Ordinal);
+
+ // A home slot starts occupied because its part starts there, and the bin
+ // starts occupied because all of them do. Occupied is kept true to the
+ // world from here on by UpdateLocationOccupancy: a slot that stayed
+ // "occupied" after the robot emptied it would be a node reporting
+ // something the cell can see is no longer the case.
+ uint capacity = isBin ? PayloadSlotCount : 1u;
+ bool occupied = isBin || name.StartsWith(HomeLocationPrefix, StringComparison.Ordinal);
+ IIntentLocationBuilder location = controller.AddLocation(
+ name,
+ Pose(WorldFrameId, x, y, z, rz),
+ builder => builder.WithOccupancy(occupied, capacity));
+ m_locations.Add(location.State);
+ m_locationNodes[name] = location.State.NodeId;
+ m_locationStates[name] = location.State;
+ }
+
+ controller.WithDescription(description => description
+ .WithKinematicChain(CreateKinematicChain())
+ .WithLimits(
+ m_kinematics.MaximumReach,
+ payloadLimit: 2.0,
+ maxCartesianSpeed: 0.25,
+ maxCartesianAcceleration: 0.7));
+ }
+
+ private void ConfigureAxis(global::Opc.Ua.RobotIntent.AxisState axis, double min, double max)
+ {
+ axis.CreateOrReplaceMinPosition(SystemContext, null).Value = min;
+ axis.CreateOrReplaceMaxPosition(SystemContext, null).Value = max;
+ axis.CreateOrReplaceMaxSpeed(SystemContext, null).Value = MaxAxisSpeedDegreesPerSecond;
+ }
+
+ private ArrayOf CreateKinematicChain()
+ {
+ return ArrayOf.Create(
+ [
+ Joint(
+ s_axes[0],
+ Pose(
+ RobotBaseFrameId,
+ 0.0,
+ 0.0,
+ BinPickingPalletizerGeometry.ShoulderHeightMetres),
+ s_axisZ),
+ Joint(s_axes[1], Pose(s_axes[0], 0.0, 0.0, 0.0), s_axisY),
+ Joint(
+ s_axes[2],
+ Pose(
+ s_axes[1],
+ BinPickingPalletizerGeometry.UpperArmLengthMetres,
+ 0.0,
+ 0.0),
+ s_axisY),
+ Joint(
+ s_axes[3],
+ Pose(
+ s_axes[2],
+ BinPickingPalletizerGeometry.ForearmLengthMetres,
+ 0.0,
+ 0.0),
+ s_axisX)
+ ]);
+ }
+
+ private static KinematicJointDataType Joint(
+ string axisId,
+ Pose3DDataType origin,
+ double[] axisVector)
+ {
+ return new KinematicJointDataType
+ {
+ AxisId = axisId,
+ Kind = AxisKindEnum.Revolute,
+ OriginTransform = origin,
+ AxisVector = axisVector.ToArrayOf()
+ };
+ }
+
+ private static (double Minimum, double Maximum) AxisLimits(uint index)
+ {
+ const double radiansToDegrees = 180.0 / Math.PI;
+ return index switch
+ {
+ 0 => (
+ -BinPickingPalletizerGeometry.BaseYawLimitRadians * radiansToDegrees,
+ BinPickingPalletizerGeometry.BaseYawLimitRadians * radiansToDegrees),
+ 1 => (
+ BinPickingPalletizerGeometry.ShoulderMinimumRadians * radiansToDegrees,
+ BinPickingPalletizerGeometry.ShoulderMaximumRadians * radiansToDegrees),
+ 2 => (
+ BinPickingPalletizerGeometry.ElbowMinimumRadians * radiansToDegrees,
+ BinPickingPalletizerGeometry.ElbowMaximumRadians * radiansToDegrees),
+ _ => (
+ -BinPickingPalletizerGeometry.ToolRollLimitRadians * radiansToDegrees,
+ BinPickingPalletizerGeometry.ToolRollLimitRadians * radiansToDegrees)
+ };
+ }
+
+ private void OnSnapshotChanged(object? sender, SimulatedArmSnapshot snapshot)
+ {
+ PublishSnapshot(snapshot);
+ }
+
+ ///
+ /// Resolves one of this cell's Locations to an approach position in the arm's base
+ /// frame, so a Pick or a Place travels to the bin or the fixture instead of
+ /// actuating the gripper where it stands.
+ ///
+ ///
+ /// The Locations are authored in the world frame and the kinematics work in the
+ /// robot base frame, so the base origin is subtracted. The approach height lifts
+ /// the target off the bench: a target on the surface asks the solver for a pose
+ /// with the tool exactly at table height, which is both harder to reach and not
+ /// what an approach looks like.
+ ///
+ private bool TryResolveLocationPosition(NodeId location, out ArrayOf position)
+ {
+ foreach ((string name, double x, double y, double z, double _) in s_locations)
+ {
+ if (m_locationNodes.TryGetValue(name, out NodeId nodeId) && nodeId == location)
+ {
+ // Carrying something means this is the move before a Place, because a
+ // Pick travels with the gripper empty and closes on arrival while a
+ // Place travels holding the part and opens. So the tool can descend to
+ // just above the height that leaves the part on its support. The small
+ // clearance keeps the wrist and jaws out of an accumulated stack; the
+ // support model settles the released part the remaining 17 mm instead
+ // of leaving it floating or driving the tool into what is already there.
+ double toolWorldZ = m_carriedClass.Length > 0
+ ? RestingCentreHeight(m_carriedClass, x, y) +
+ SimulatedArmExecutor.HeldPartTcpOffset +
+ PlaceReleaseClearanceMetres
+ : z + ApproachHeightMetres;
+ position = new[] { x, y, toolWorldZ - RobotBaseHeightMetres }.ToArrayOf();
+ return true;
+ }
+ }
+ position = ArrayOf.Empty;
+ return false;
+ }
+
+ ///
+ /// Resolves a Location to a deliberate tool-down pose rather than inheriting the
+ /// yaw left behind by the previous grasp.
+ ///
+ private bool TryResolveLocationPose(NodeId location, out Pose3DDataType pose)
+ {
+ if (TryResolveLocationPosition(location, out ArrayOf position))
+ {
+ foreach ((string name, double _, double _, double _, double _) in s_locations)
+ {
+ if (m_locationNodes.TryGetValue(name, out NodeId nodeId) && nodeId == location)
+ {
+ pose = new Pose3DDataType
+ {
+ FrameId = RobotBaseFrameId,
+ Position = position,
+ // The Location's Rz describes how a part or fixture is authored,
+ // not a mandatory wrist yaw. Applying Fixture's 25 degrees here
+ // made the first place succeed and left no retract path for the
+ // next pick. Hold one solved tool-down orientation everywhere;
+ // the executor's deterministic yaw search still turns it when
+ // the standard pose itself has no clear solution.
+ Orientation = m_toolDownOrientation
+ };
+ return true;
+ }
+ }
+ }
+ pose = new Pose3DDataType();
+ return false;
+ }
+
+ private bool TryResolvePickPose(
+ NodeId location,
+ string objectClass,
+ out Pose3DDataType pose)
+ {
+ if (!m_targetProvider.TryResolve(objectClass, out BinPickingTarget target))
+ {
+ pose = new Pose3DDataType();
+ return false;
+ }
+ foreach ((string name, double x, double y, double _, double _) in s_locations)
+ {
+ if (!m_locationNodes.TryGetValue(name, out NodeId nodeId) || nodeId != location)
+ {
+ continue;
+ }
+ double radius = string.Equals(name, BinLocationName, StringComparison.Ordinal)
+ ? BinSlotRadiusMetres
+ : GraspReachRadiusMetres;
+ if (Math.Abs(target.WorldX - x) > radius ||
+ Math.Abs(target.WorldY - y) > radius)
+ {
+ break;
+ }
+ double baseYaw = Math.Atan2(target.WorldY, target.WorldX);
+ pose = new Pose3DDataType
+ {
+ FrameId = RobotBaseFrameId,
+ Position = new[]
+ {
+ target.WorldX,
+ target.WorldY,
+ target.WorldZ +
+ SimulatedArmExecutor.HeldPartTcpOffset -
+ RobotBaseHeightMetres
+ }.ToArrayOf(),
+ Orientation = BinPickingPalletizerKinematics.ToolDownOrientation(
+ baseYaw,
+ PalletizerWorkToolRollRadians)
+ };
+ m_pickTargetClass = objectClass;
+ UpdateMovingObstacles();
+ return true;
+ }
+ pose = new Pose3DDataType();
+ return false;
+ }
+
+ private void OnPickAttemptFinished(string objectClass)
+ {
+ if (string.Equals(m_pickTargetClass, objectClass, StringComparison.Ordinal))
+ {
+ m_pickTargetClass = string.Empty;
+ UpdateMovingObstacles();
+ }
+ }
+
+ ///
+ /// Gets whether a Location is inside the open bin, where the final approach should
+ /// be vertical rather than a joint interpolation that can sweep through a wall.
+ ///
+ private bool PreferCartesianDescent(NodeId location)
+ {
+ foreach ((string name, NodeId nodeId) in m_locationNodes)
+ {
+ if (nodeId == location)
+ {
+ // Empty-gripper picks are made Cartesian by the executor regardless of
+ // this value. Loaded placements into a home slot are Cartesian too, and
+ // the executor records every joint sample so the next command can replay
+ // the exact approach in reverse. The fixture keeps the short local joint
+ // approach because its final Cartesian branch is less reliable.
+ return string.Equals(name, BinLocationName, StringComparison.Ordinal) ||
+ name.StartsWith(HomeLocationPrefix, StringComparison.Ordinal);
+ }
+ }
+ return false;
+ }
+
+ ///
+ /// Gets whether the named part is lying under the tool, which is the only place a
+ /// grasp can pick it up from.
+ ///
+ ///
+ /// A Pick names an object class and a source Location. Without this check the cell
+ /// hands over whatever class the intent names no matter where that part actually
+ /// is, so picking from an empty bin still produces a part in the gripper - and a
+ /// loop that keeps picking and placing walks every part onto one spot and stacks
+ /// them into the air, each Place resting the part on the pile the last one left.
+ /// The test is against the tool's own position rather than the Location the intent
+ /// named: the tool has already travelled there by the time it closes, and a field
+ /// remembering the last resolved Location goes stale as soon as intents queue
+ /// back to back - which made every grasp fail and the robot stop moving at all.
+ ///
+ private bool CanGrasp(string classLabel, double toolX, double toolY)
+ {
+ IReadOnlyList parts = m_worldState.Snapshot();
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ BinPickingPartSnapshot part = parts[ii];
+ if (!string.Equals(part.Part.ClassLabel, classLabel, StringComparison.Ordinal))
+ {
+ continue;
+ }
+ bool within = Math.Abs(part.WorldX - toolX) <= GraspReachRadiusMetres &&
+ Math.Abs(part.WorldY - toolY) <= GraspReachRadiusMetres;
+ if (!within)
+ {
+ m_logger.GraspFoundNothing(classLabel, FormatPosition(toolX, toolY));
+ }
+ return within;
+ }
+ return false;
+ }
+
+ ///
+ /// Formats a position for a log message.
+ ///
+ private static string FormatPosition(double x, double y)
+ {
+ return string.Create(
+ System.Globalization.CultureInfo.InvariantCulture, $"({x:F3}, {y:F3})");
+ }
+
+ ///
+ /// Gets the height a part's centre comes to rest at over a spot on the bench, given
+ /// everything else that is standing there.
+ ///
+ ///
+ /// This is what makes a second part placed on the same spot end up on top of the
+ /// first rather than inside it, and it is why a placed part stops floating: the
+ /// part used to be left wherever the tool centre point was, which for a Place at the
+ /// approach height was 165 mm above the bench.
+ ///
+ private double RestingCentreHeight(string classLabel, double x, double y)
+ {
+ BinPickingPart? part = BinPickingPartsCatalog.TryGet(classLabel);
+ if (part == null)
+ {
+ return BenchTopMetres;
+ }
+ return m_support.RestingCentreHeight(
+ x, y, part.Size[0], part.Size[1], part.Size[2], SupportingParts(classLabel));
+ }
+
+ ///
+ /// Gets the other parts as solids that can hold something up, leaving out the one
+ /// being placed and anything currently in the gripper.
+ ///
+ private ArrayOf SupportingParts(string exclude)
+ {
+ IReadOnlyList parts = m_worldState.Snapshot();
+ var solids = new List(parts.Count);
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ BinPickingPartSnapshot snapshot = parts[ii];
+ if (string.Equals(snapshot.Part.ClassLabel, exclude, StringComparison.Ordinal) ||
+ snapshot.Location == BinPickingPartLocation.Held)
+ {
+ continue;
+ }
+ solids.Add(new SimulatedSupportSolid(
+ snapshot.Part.ClassLabel,
+ snapshot.WorldX,
+ snapshot.WorldY,
+ snapshot.Part.Size[0],
+ snapshot.Part.Size[1],
+ snapshot.WorldZ + (snapshot.Part.Size[2] * 0.5)));
+ }
+ return ArrayOf.Create(solids.ToArray().AsSpan());
+ }
+
+ ///
+ /// Moves the carried part in the cell's world model so the world changes when the
+ /// robot changes it, rather than only when a proof service says so.
+ ///
+ ///
+ /// The arm reports the carried position in its own base frame; the world model and
+ /// the ground-truth detector work in the world frame, so the base height is added
+ /// back. Running on every snapshot is what makes the part travel with the tool
+ /// instead of teleporting when the grasp opens.
+ ///
+ private void TrackHeldPart(SimulatedArmSnapshot snapshot)
+ {
+ ReadOnlySpan carried = snapshot.HeldPartPosition.Span;
+ if (carried.Length < 3)
+ {
+ return;
+ }
+ double worldX = carried[0];
+ double worldY = carried[1];
+ double worldZ = carried[2] + RobotBaseHeightMetres;
+
+ if (snapshot.HasObject && snapshot.HeldObjectClass.Length > 0)
+ {
+ if (m_carriedClass.Length == 0 &&
+ !CanGrasp(snapshot.HeldObjectClass, worldX, worldY))
+ {
+ // The gripper closed where the part is not. Attaching it anyway is
+ // what let a Pick teleport a part out of a stack on the far side of
+ // the bench into the tool: the cell would then carry it off and set it
+ // down somewhere it was never taken from. Closing on nothing is the
+ // honest outcome, and it leaves the part where it lies.
+ return;
+ }
+ _ = m_worldState.MarkHeld(snapshot.HeldObjectClass, worldX, worldY, worldZ);
+ m_carriedClass = snapshot.HeldObjectClass;
+ m_pickTargetClass = string.Empty;
+ SetHeldObjectEnvelope(snapshot.HeldObjectClass);
+ PublishPartPosition(snapshot.HeldObjectClass, worldX, worldY, worldZ);
+ UpdateLocationOccupancy();
+ UpdateMovingObstacles();
+ return;
+ }
+ if (!snapshot.HasObject && m_carriedClass.Length > 0)
+ {
+ // The gripper opened. The part settles onto whatever is under it rather
+ // than staying at the tool centre point: released at the approach height it
+ // would hang in the air, and released over another part it would stand
+ // inside it. The tool has normally already descended to the resting height
+ // by this point, so this is a few millimetres of settle, not a fall.
+ double restingZ = m_support.ClampAboveSupport(
+ worldX,
+ worldY,
+ PartSize(m_carriedClass, 0),
+ PartSize(m_carriedClass, 1),
+ PartSize(m_carriedClass, 2),
+ worldZ,
+ SupportingParts(m_carriedClass));
+ _ = m_worldState.MarkPlaced(m_carriedClass, worldX, worldY, restingZ);
+ PublishPartPosition(m_carriedClass, worldX, worldY, restingZ);
+ m_carriedClass = string.Empty;
+ m_pickTargetClass = string.Empty;
+ m_kinematics.ClearHeldObjectEnvelope();
+ UpdateLocationOccupancy();
+ UpdateMovingObstacles();
+ }
+ }
+
+ ///
+ /// Republishes moving workpieces as solids the arm has to keep out of.
+ ///
+ ///
+ /// The bench and bin walls are fixed. The fixture, pegs, non-target workpieces and
+ /// accumulated stack are republished as moving solids. Whatever the gripper carries
+ /// is omitted from that list and represented by the palletizer's held-object swept
+ /// envelope instead.
+ ///
+ private void UpdateMovingObstacles()
+ {
+ SimulatedCollisionModel? collisions = m_kinematics?.Collisions;
+ if (collisions == null)
+ {
+ return;
+ }
+ IReadOnlyList parts = m_worldState.Snapshot();
+ var solids = new List(parts.Count + 4)
+ {
+ new(
+ "FixturePlate",
+ BinPickingCellGeometry.FixtureCentreX,
+ 0.0,
+ 0.140,
+ 0.140,
+ FixturePlateTopMetres - RobotBaseHeightMetres - 0.018,
+ FixturePlateTopMetres - RobotBaseHeightMetres),
+ new(
+ "FixturePegA",
+ BinPickingCellGeometry.FixtureCentreX -
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ 0.018,
+ 0.018,
+ FixturePlateTopMetres - RobotBaseHeightMetres,
+ FixturePegTopMetres - RobotBaseHeightMetres),
+ new(
+ "FixturePegB",
+ BinPickingCellGeometry.FixtureCentreX +
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ 0.018,
+ 0.018,
+ FixturePlateTopMetres - RobotBaseHeightMetres,
+ FixturePegTopMetres - RobotBaseHeightMetres),
+ new(
+ "FixturePegC",
+ BinPickingCellGeometry.FixtureCentreX,
+ -BinPickingCellGeometry.FixturePegOffsetMetres,
+ 0.018,
+ 0.018,
+ FixturePlateTopMetres - RobotBaseHeightMetres,
+ FixturePegTopMetres - RobotBaseHeightMetres)
+ };
+
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ BinPickingPartSnapshot part = parts[ii];
+ if (part.Location == BinPickingPartLocation.Held ||
+ string.Equals(part.Part.ClassLabel, m_carriedClass, StringComparison.Ordinal) ||
+ string.Equals(part.Part.ClassLabel, m_pickTargetClass, StringComparison.Ordinal))
+ {
+ continue;
+ }
+ double halfHeight = part.Part.Size[2] * 0.5;
+ solids.Add(new SimulatedObstacleBox(
+ part.Part.ClassLabel,
+ part.WorldX,
+ part.WorldY,
+ part.Part.Size[0],
+ part.Part.Size[1],
+ part.WorldZ - halfHeight - RobotBaseHeightMetres,
+ part.WorldZ + halfHeight - RobotBaseHeightMetres));
+ }
+ collisions.MovingObstacles = ArrayOf.Create(solids.ToArray().AsSpan());
+ }
+
+ private void SetHeldObjectEnvelope(string classLabel)
+ {
+ BinPickingPart? part = BinPickingPartsCatalog.TryGet(classLabel);
+ if (part == null)
+ {
+ m_kinematics.ClearHeldObjectEnvelope();
+ return;
+ }
+ m_kinematics.SetHeldObjectEnvelope(
+ part.Size[0],
+ part.Size[1],
+ part.Size[2]);
+ }
+
+ ///
+ /// Republishes each Location's Occupied flag from where the parts actually are.
+ ///
+ ///
+ /// Occupancy is declarative in the Robot Intent model - nothing enforces it - which
+ /// makes it easy to author once and leave wrong. A client asking "is the bin empty
+ /// yet" would then be told "no" forever. A Location counts as occupied when a part
+ /// that is not in the gripper is standing within its slot radius.
+ ///
+ private void UpdateLocationOccupancy()
+ {
+ if (m_systemContext == null || m_locationStates.Count == 0)
+ {
+ return;
+ }
+ IReadOnlyList parts = m_worldState.Snapshot();
+ foreach ((string name, double x, double y, double _, double _) in s_locations)
+ {
+ if (!m_locationStates.TryGetValue(name, out global::Opc.Ua.RobotIntent.LocationState? state) ||
+ state.Occupied == null)
+ {
+ continue;
+ }
+ double radius = string.Equals(name, BinLocationName, StringComparison.Ordinal)
+ ? BinSlotRadiusMetres
+ : HomeSlotRadiusMetres;
+ bool occupied = false;
+ for (int ii = 0; ii < parts.Count && !occupied; ii++)
+ {
+ BinPickingPartSnapshot part = parts[ii];
+ occupied = part.Location != BinPickingPartLocation.Held &&
+ Math.Abs(part.WorldX - x) <= radius &&
+ Math.Abs(part.WorldY - y) <= radius;
+ }
+ if (state.Occupied.Value == occupied)
+ {
+ continue;
+ }
+ state.Occupied.Value = occupied;
+ state.Occupied.ClearChangeMasks(m_systemContext, false);
+ }
+ }
+
+ ///
+ /// Gets one extent of a part, or zero when the catalogue does not know it.
+ ///
+ private static double PartSize(string classLabel, int axis)
+ {
+ BinPickingPart? part = BinPickingPartsCatalog.TryGet(classLabel);
+ return (part?.Size[axis]) ?? 0.0;
+ }
+
+ ///
+ /// Pushes a part's world position onto its variable so the OpenUSD live binding, and
+ /// any other subscriber, follows it.
+ ///
+ ///
+ /// The value is a rather than a bare
+ /// double[3] on purpose. §5.8 of the OpenUSD companion specification defines
+ /// a translation source as a structured 3D coordinate, and the connector's
+ /// translation profile fails closed on anything else - so a plain array left every
+ /// part's translation unresolved and the parts never moved in the viewport, however
+ /// faithfully the server tracked them.
+ ///
+ private void PublishPartPosition(string classLabel, double worldX, double worldY, double worldZ)
+ {
+ if (m_systemContext == null ||
+ !m_partPositionNodes.TryGetValue(classLabel, out BaseDataVariableState? node))
+ {
+ return;
+ }
+ node.Value = new Variant(new ExtensionObject(new ThreeDCartesianCoordinates
+ {
+ X = worldX,
+ Y = worldY,
+ Z = worldZ
+ }));
+ node.ClearChangeMasks(m_systemContext, false);
+ }
+
+ private void PublishSnapshot(SimulatedArmSnapshot snapshot)
+ {
+ TrackHeldPart(snapshot);
+ if (m_systemContext == null)
+ {
+ return;
+ }
+ for (int ii = 0; ii < m_axes.Count && ii < snapshot.JointAngles.Count; ii++)
+ {
+ global::Opc.Ua.RobotIntent.AxisState axis = m_axes[ii];
+ if (axis.Position != null)
+ {
+ axis.Position.Value = snapshot.JointAngles[ii] * 180.0 / Math.PI;
+ axis.Position.ClearChangeMasks(m_systemContext, true);
+ }
+ }
+ if (m_gripperLeftSlideValue != null)
+ {
+ m_gripperLeftSlideValue.Value = ToVariant(
+ GripperSlide(snapshot.GripperOpening, 1.0));
+ m_gripperLeftSlideValue.ClearChangeMasks(m_systemContext, true);
+ }
+ if (m_gripperRightSlideValue != null)
+ {
+ m_gripperRightSlideValue.Value = ToVariant(
+ GripperSlide(snapshot.GripperOpening, -1.0));
+ m_gripperRightSlideValue.ClearChangeMasks(m_systemContext, true);
+ }
+ if (m_levelingValue != null && snapshot.JointAngles.Count >= 3)
+ {
+ m_levelingValue.Value = LevelingDegrees(snapshot.JointAngles.Span);
+ m_levelingValue.ClearChangeMasks(m_systemContext, true);
+ }
+ }
+
+ private static double LevelingDegrees(ReadOnlySpan jointAngles)
+ {
+ return ((Math.PI / 2.0) - jointAngles[1] - jointAngles[2]) * 180.0 / Math.PI;
+ }
+
+ ///
+ /// Converts the jaw opening into one slide's USD translation.
+ ///
+ private static ThreeDCartesianCoordinates GripperSlide(double opening, double direction)
+ {
+ double centre = Math.Clamp(opening * 0.5, GripperClosedHalfGap, GripperOpenHalfGap);
+ return new ThreeDCartesianCoordinates
+ {
+ X = 0.0,
+ Y = direction * centre,
+ Z = 0.0
+ };
+ }
+
+ private static Variant ToVariant(ThreeDCartesianCoordinates value)
+ {
+ return new Variant(new ExtensionObject(value));
+ }
+
+ private static Pose3DDataType Pose(string frameId, double x, double y, double z, double rzDegrees = 0.0)
+ {
+ return PoseMath.FromThreeDFrame(
+ new ThreeDFrame
+ {
+ CartesianCoordinates = new ThreeDCartesianCoordinates
+ {
+ X = x,
+ Y = y,
+ Z = z
+ },
+ Orientation = new ThreeDOrientation
+ {
+ C = rzDegrees * Math.PI / 180.0
+ }
+ },
+ frameId);
+ }
+
+ ///
+ /// Gets the name of the Location that holds a part's own starting spot in the bin.
+ ///
+ internal static string HomeLocationName(string classLabel)
+ {
+ return HomeLocationPrefix + classLabel;
+ }
+
+ ///
+ /// Builds this cell's Locations: the bin, the fixture, and one home slot per part.
+ ///
+ ///
+ /// A Place intent names a LocationType and carries no pose, so "the bin" is the
+ /// only way back unless each part has a Location of its own - and placing every
+ /// part at the bin puts them all on the one spot, which the support model then
+ /// stacks in the middle of it. The home slots are the authored scattered
+ /// positions, so a Place can return a part to where the cell first had it.
+ /// The bin and fixture coordinates are where those two actually stand in
+ /// Cell.usda; they used to be somewhere else entirely - the Bin at y = -0.28 when
+ /// the bin spans +/-0.12, the Fixture at (0.48, 0.26) when the fixture stood at
+ /// (-0.32, 0) - so "place it on the fixture" put the part down on bare bench a long
+ /// way from the fixture, and the render disagreed with the model about where the
+ /// cell's own furniture was. The Z is the surface a part stands on there.
+ ///
+ private static (string Name, double X, double Y, double Z, double Rz)[] BuildLocations()
+ {
+ IReadOnlyList parts = BinPickingPartsCatalog.Parts;
+ var locations = new List<(string Name, double X, double Y, double Z, double Rz)>(parts.Count + 2)
+ {
+ (BinLocationName, BinPickingPartsCatalog.BinCentreX, BinPickingPartsCatalog.BinCentreY,
+ BenchTopMetres, 0.0),
+ (FixtureLocationName, BinPickingCellGeometry.FixtureCentreX, 0.0,
+ FixturePlateTopMetres, 25.0)
+ };
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ BinPickingPart part = parts[ii];
+ locations.Add((
+ HomeLocationName(part.ClassLabel),
+ part.InitialWorldPosition[0],
+ part.InitialWorldPosition[1],
+ BenchTopMetres,
+ part.RotationZDegrees));
+ }
+ return [.. locations];
+ }
+
+ internal const string WorldFrameId = "world";
+ internal const string RobotBaseFrameId = "robot_base";
+ internal const string FlangeFrameId = "flange";
+ internal const string ToolFrameId = "gripper_tcp";
+ internal const string CameraFrameId = "camera_eih";
+
+ ///
+ /// The Location a part is picked from and returned to as a group.
+ ///
+ internal const string BinLocationName = "Bin";
+
+ ///
+ /// The Location parts are stacked on.
+ ///
+ internal const string FixtureLocationName = "Fixture";
+
+ private const string HomeLocationPrefix = "Home";
+
+ ///
+ /// The robot stands on a 200 mm riser above the lowered work surface. Keeping these
+ /// in the shared geometry class makes the USD scene, frame tree, collision model and
+ /// support model describe one cell.
+ ///
+ internal const double RobotBaseHeightMetres = BinPickingCellGeometry.RobotBaseHeightMetres;
+ internal const double BenchTopMetres = BinPickingCellGeometry.BenchTopMetres;
+ private const double FixturePlateTopMetres = BinPickingCellGeometry.FixturePlateTopMetres;
+ private const double FixturePegTopMetres = BinPickingCellGeometry.FixturePegTopMetres;
+
+ ///
+ /// How far above a Location the tool travels to when it is going to pick something
+ /// up. Far enough to read as an approach rather than a collision. A Place does not
+ /// use this: it descends to the height that leaves the part resting on whatever is
+ /// under it, so releasing does not drop the part from the approach height.
+ ///
+ private const double ApproachHeightMetres = 0.20;
+ private const double PlaceReleaseClearanceMetres = 0.017;
+ private const double GripperClosedHalfGap = 0.009;
+ private const double GripperOpenHalfGap = 0.040;
+ private const double PalletizerWorkToolRollRadians = Math.PI / 2.0;
+
+ ///
+ /// How close a part has to be to a Location to count as standing in it. The bin is a
+ /// tray parts are scattered across, so it reuses the catalogue's footprint; a home
+ /// slot is one part's own spot, so its radius only has to cover the millimetre-scale
+ /// settle a release leaves behind.
+ ///
+ private const double BinSlotRadiusMetres = BinPickingPartsCatalog.BinHalfExtent;
+ private const double HomeSlotRadiusMetres = 0.02;
+
+ ///
+ /// How far a part may be from the tool and still be grasped by it. Wide enough to
+ /// cover a Location's footprint, since a Pick travels to the Location rather than to
+ /// the part, and far narrower than the 0.70 m between the bin and the fixture, so a
+ /// grasp can never reach across the bench for something.
+ ///
+ private const double GraspReachRadiusMetres = 0.12;
+
+ ///
+ /// The simulator's DefaultJointSpeed is 0.9 rad/s; Position and the limits are
+ /// published in degrees, so the speed limit is too.
+ ///
+ private const double MaxAxisSpeedDegreesPerSecond = 0.9 * 180.0 / Math.PI;
+ private const uint PayloadSlotCount = 8u;
+
+ private static readonly (string Name, double X, double Y, double Z, double Rz)[] s_locations =
+ BuildLocations();
+
+ ///
+ /// The solids in this cell that never move and that a part can come to rest on, in
+ /// the world frame. Sizes are full extents, matching Cell.usda.
+ ///
+ private static readonly SimulatedSupportSolid[] s_supportFixtures =
+ [
+ new("Bench", 0.0, 0.0, 1.4, 0.9, BenchTopMetres),
+ new("FixturePlate", BinPickingCellGeometry.FixtureCentreX, 0.0,
+ 0.14, 0.14, FixturePlateTopMetres),
+ new("FixturePegA", BinPickingCellGeometry.FixtureCentreX -
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ 0.018, 0.018, FixturePegTopMetres),
+ new("FixturePegB", BinPickingCellGeometry.FixtureCentreX +
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ BinPickingCellGeometry.FixturePegOffsetMetres,
+ 0.018, 0.018, FixturePegTopMetres),
+ new("FixturePegC", BinPickingCellGeometry.FixtureCentreX,
+ -BinPickingCellGeometry.FixturePegOffsetMetres,
+ 0.018, 0.018, FixturePegTopMetres)
+ ];
+
+ private static readonly string[] s_axes = ["J1", "J2", "J3", "J4"];
+ private static readonly double[] s_axisX = [1.0, 0.0, 0.0];
+ private static readonly double[] s_axisZ = [0.0, 0.0, 1.0];
+ private static readonly double[] s_axisY = [0.0, 1.0, 0.0];
+
+ private readonly ILogger m_logger;
+ private readonly SimulatedArmExecutor m_executor;
+ private readonly BinPickingPalletizerKinematics m_kinematics;
+ private readonly BinPickingWorldState m_worldState;
+ private readonly IBinPickingTargetProvider m_targetProvider;
+ private readonly ArrayOf m_toolDownOrientation;
+
+ private readonly SimulatedSupportModel m_support =
+ new(ArrayOf.Create(s_supportFixtures.AsSpan()), BenchTopMetres);
+
+ private string m_carriedClass = string.Empty;
+ private string m_pickTargetClass = string.Empty;
+ private readonly List m_axes = [];
+ private readonly List m_locations = [];
+ private readonly Dictionary m_locationNodes = new(StringComparer.Ordinal);
+
+ private readonly Dictionary m_locationStates =
+ new(StringComparer.Ordinal);
+
+ private BaseVariableState? m_gripperLeftSlideValue;
+ private BaseVariableState? m_gripperRightSlideValue;
+ private BaseVariableState? m_levelingValue;
+ private AsyncCustomNodeManager? m_manager;
+ private IIntentControllerBuilder? m_controller;
+ private ServerSystemContext? m_systemContext;
+ private ushort m_instanceNamespaceIndex;
+ }
+
+ internal static partial class BinPickingRobotCellLog
+ {
+ [LoggerMessage(EventId = BinPickingCellEventIds.Configurator + 1,
+ Level = LogLevel.Information,
+ Message = "Robot Intent side of BinPickingCell ready " +
+ "({AxisCount} axes, {LocationCount} locations, facets {Facets}).")]
+ public static partial void RobotCellReady(
+ this ILogger logger,
+ int axisCount, int locationCount, ArrayOf facets);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Configurator + 2,
+ Level = LogLevel.Warning,
+ Message = "Grasp at {ToolPosition} found no {ClassLabel} under the tool; " +
+ "the gripper closed on nothing.")]
+ public static partial void GraspFoundNothing(
+ this ILogger logger,
+ string classLabel, string toolPosition);
+
+ [LoggerMessage(EventId = BinPickingCellEventIds.Configurator + 3,
+ Level = LogLevel.Information,
+ Message = "Arm travel: {Message}.")]
+ public static partial void ArmTravel(
+ this ILogger logger,
+ string message);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingTargetProvider.cs b/samples/Robotics/BinPickingCell/BinPickingTargetProvider.cs
new file mode 100644
index 0000000000..0d53472e3c
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingTargetProvider.cs
@@ -0,0 +1,264 @@
+/* ========================================================================
+ * 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 Opc.Ua;
+using Opc.Ua.RobotIntent;
+using Opc.Ua.Vision;
+
+namespace Vision.BinPickingCell
+{
+ internal readonly record struct BinPickingTarget(
+ string ClassLabel,
+ double WorldX,
+ double WorldY,
+ double WorldZ,
+ DateTime TimestampUtc,
+ string ResultId,
+ string SourceFrameId);
+
+ internal interface IBinPickingTargetProvider
+ {
+ void PublishWorldState(
+ string resultId,
+ DateTimeUtc timestamp,
+ IReadOnlyList parts);
+
+ void PublishDetections(
+ string resultId,
+ DateTimeUtc timestamp,
+ ArrayOf detections,
+ VisionPose3DDataType cameraInWorld,
+ string cameraFrameId);
+
+ bool TryResolve(string classLabel, out BinPickingTarget target);
+ }
+
+ ///
+ /// Provides the pose a Pick should target for the class selected by Vision.
+ ///
+ internal sealed class BinPickingTargetProvider : IBinPickingTargetProvider
+ {
+ public BinPickingTargetProvider(
+ BinPickingWorldState worldState,
+ BinPickingCellOptions options)
+ {
+ m_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ m_options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public void PublishWorldState(
+ string resultId,
+ DateTimeUtc timestamp,
+ IReadOnlyList parts)
+ {
+ DateTime created = timestamp.IsNull ? DateTime.UtcNow : timestamp.ToDateTime();
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ BinPickingPartSnapshot part = parts[ii];
+ if (part.Location != BinPickingPartLocation.InBin)
+ {
+ continue;
+ }
+ m_targets[part.Part.ClassLabel] = new BinPickingTarget(
+ part.Part.ClassLabel,
+ part.WorldX,
+ part.WorldY,
+ part.WorldZ,
+ created,
+ resultId,
+ WorldFrameId);
+ }
+ }
+
+ public void PublishDetections(
+ string resultId,
+ DateTimeUtc timestamp,
+ ArrayOf detections,
+ VisionPose3DDataType cameraInWorld,
+ string cameraFrameId)
+ {
+ if (string.IsNullOrWhiteSpace(cameraFrameId))
+ {
+ throw new ArgumentException("A camera frame id is required.", nameof(cameraFrameId));
+ }
+ ReadOnlySpan cameraPosition = cameraInWorld.Position.Span;
+ ReadOnlySpan cameraOrientation = cameraInWorld.Orientation.Span;
+ if (cameraPosition.Length < 3 || cameraOrientation.Length < 4)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument,
+ "The camera-in-world calibration must carry a 3D position and quaternion.");
+ }
+ DateTime created = timestamp.IsNull ? DateTime.UtcNow : timestamp.ToDateTime();
+ IReadOnlyList parts = m_worldState.Snapshot();
+ ReadOnlySpan values = detections.Span;
+ var acceptedTargets = new List(values.Length);
+ for (int ii = 0; ii < values.Length; ii++)
+ {
+ VisionDetectionDataType detection = values[ii];
+ if (!detection.HasPose || string.IsNullOrWhiteSpace(detection.ClassLabel))
+ {
+ continue;
+ }
+ if (!string.Equals(
+ detection.Pose.FrameId,
+ cameraFrameId,
+ StringComparison.Ordinal))
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument,
+ $"Detection {ii} Pose.FrameId '{detection.Pose.FrameId}' does not match " +
+ $"the calibrated camera frame '{cameraFrameId}'.");
+ }
+ ReadOnlySpan local = detection.Pose.Position.Span;
+ if (local.Length < 3)
+ {
+ continue;
+ }
+ ArrayOf rotated = PoseMath.RotateVector(cameraOrientation, local);
+ ReadOnlySpan offset = rotated.Span;
+ double worldX = cameraPosition[0] + offset[0];
+ double worldY = cameraPosition[1] + offset[1];
+ double worldZ = cameraPosition[2] + offset[2];
+ BinPickingPartSnapshot? part = FindPart(parts, detection.ClassLabel);
+ if (part == null || part.Location == BinPickingPartLocation.Held)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument,
+ FormattableString.Invariant(
+ $"Detection {ii} class '{detection.ClassLabel}' is not available to pick."));
+ }
+ double residual = Distance(part, worldX, worldY, worldZ);
+ if (residual > MaximumWorldResidualMetres)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument,
+ string.Format(
+ CultureInfo.InvariantCulture,
+ "Detection {0} class '{1}' is {2:F3} m from the simulated part, " +
+ "above the {3:F3} m limit.",
+ ii,
+ detection.ClassLabel,
+ residual,
+ MaximumWorldResidualMetres));
+ }
+ acceptedTargets.Add(new BinPickingTarget(
+ detection.ClassLabel,
+ worldX,
+ worldY,
+ worldZ,
+ created,
+ resultId,
+ cameraFrameId));
+ }
+ for (int ii = 0; ii < acceptedTargets.Count; ii++)
+ {
+ BinPickingTarget target = acceptedTargets[ii];
+ m_targets[target.ClassLabel] = target;
+ }
+ }
+
+ public bool TryResolve(string classLabel, out BinPickingTarget target)
+ {
+ if (m_targets.TryGetValue(classLabel, out target) &&
+ DateTime.UtcNow - target.TimestampUtc <= TargetLifetime)
+ {
+ return true;
+ }
+ _ = m_targets.TryRemove(classLabel, out _);
+ if (m_options.InferenceLocation != BinPickingInferenceLocation.OnServer)
+ {
+ target = default;
+ return false;
+ }
+
+ IReadOnlyList parts = m_worldState.Snapshot();
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ BinPickingPartSnapshot part = parts[ii];
+ if (string.Equals(part.Part.ClassLabel, classLabel, StringComparison.Ordinal))
+ {
+ target = new BinPickingTarget(
+ classLabel,
+ part.WorldX,
+ part.WorldY,
+ part.WorldZ,
+ DateTime.UtcNow,
+ "simulation-world-state",
+ WorldFrameId);
+ return true;
+ }
+ }
+ target = default;
+ return false;
+ }
+
+ private static BinPickingPartSnapshot? FindPart(
+ IReadOnlyList parts,
+ string classLabel)
+ {
+ for (int ii = 0; ii < parts.Count; ii++)
+ {
+ if (string.Equals(
+ parts[ii].Part.ClassLabel,
+ classLabel,
+ StringComparison.Ordinal))
+ {
+ return parts[ii];
+ }
+ }
+ return null;
+ }
+
+ private static double Distance(
+ BinPickingPartSnapshot part,
+ double worldX,
+ double worldY,
+ double worldZ)
+ {
+ double x = part.WorldX - worldX;
+ double y = part.WorldY - worldY;
+ double z = part.WorldZ - worldZ;
+ return Math.Sqrt((x * x) + (y * y) + (z * z));
+ }
+
+ private const string WorldFrameId = "world";
+ private const double MaximumWorldResidualMetres = 0.08;
+ private static readonly TimeSpan TargetLifetime = TimeSpan.FromSeconds(10);
+ private readonly BinPickingWorldState m_worldState;
+ private readonly BinPickingCellOptions m_options;
+
+ private readonly ConcurrentDictionary m_targets =
+ new(StringComparer.Ordinal);
+ }
+}
diff --git a/samples/Robotics/BinPickingCell/BinPickingVisionCell.cs b/samples/Robotics/BinPickingCell/BinPickingVisionCell.cs
new file mode 100644
index 0000000000..f8e68f7630
--- /dev/null
+++ b/samples/Robotics/BinPickingCell/BinPickingVisionCell.cs
@@ -0,0 +1,531 @@
+/* ========================================================================
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Opc.Ua.Vision;
+using Opc.Ua.Vision.OpenUsd;
+using Opc.Ua.Vision.Server;
+using Opc.Ua.Vision.Server.Builders;
+
+namespace Vision.BinPickingCell
+{
+ ///
+ /// Vision side of the bin-picking cell. Materialises the frame tree,
+ /// the eye-in-hand camera sensor twin, the intrinsic and hand-eye
+ /// calibrations, and the media endpoints from the OPC UA
+ /// Robotics-Vision Addendum's worked example.
+ ///
+ ///
+ ///
+ /// The concrete values (focal lengths, distortion coefficients,
+ /// hand-eye pose, residual errors) are the ones the addendum ships;
+ /// they identify this cell as the reference example rather than an
+ /// arbitrary rig. The sensor is registered as a
+ /// twin: it renders
+ /// from a USD stage via the OpenUSD offscreen capture provider, and
+ /// carries an IVisionSimulatedType interface pointing at the
+ /// stage and camera prim so a client can see the twin metadata.
+ ///
+ ///
+ /// The frame identifiers world, robot_base,
+ /// flange, gripper_tcp and camera_eih match the
+ /// addendum and the frame names published by
+ /// — a client can walk from the
+ /// vision-side calibration to the robot-side frame without any
+ /// translation table.
+ ///
+ ///
+ /// The vision-side flange transform is authored at the "scan
+ /// pose" the arm would hold to point the eye-in-hand camera at the
+ /// bin. In a live cell the flange frame is dynamic and reflects the
+ /// current joint state; for this static-sample demo it is pinned to
+ /// the scan pose so a consumer composing
+ /// camera → flange → robot_base → world lands on the parts'
+ /// authored world positions — which is exactly what the
+ /// reports for
+ /// each detection's Pose.
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1812",
+ Justification = "Instantiated by the DI container via AddSingleton.")]
+ internal sealed class BinPickingVisionCell
+ {
+ public BinPickingVisionCell(
+ ILogger logger,
+ BinPickingMediaProvider mediaProvider,
+ BinPickingCellStage stage,
+ BinPickingGroundTruthInferenceProvider inferenceProvider,
+ BinPickingAgentInferenceProvider agentProvider,
+ BinPickingCellOptions options)
+ {
+ m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ m_mediaProvider = mediaProvider ?? throw new ArgumentNullException(nameof(mediaProvider));
+ m_stage = stage ?? throw new ArgumentNullException(nameof(stage));
+ m_inferenceProvider = inferenceProvider
+ ?? throw new ArgumentNullException(nameof(inferenceProvider));
+ m_agentProvider = agentProvider ?? throw new ArgumentNullException(nameof(agentProvider));
+ m_options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ ///
+ /// Configures the Vision node manager for this cell.
+ ///
+ /// is null.
+ public async ValueTask ConfigureAsync(IVisionBuildContext context, CancellationToken cancellationToken)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+ AddFrames(context);
+ AddSensor(context);
+ AddPipeline(context);
+ await AttachSimulatedTwinAsync(context, cancellationToken).ConfigureAwait(false);
+ await FinalizePipelineAsync(context, cancellationToken).ConfigureAwait(false);
+ m_logger.VisionCellReady(
+ m_frames.Count,
+ m_stage.CellStagePath,
+ m_mediaProvider.Backend,
+ m_options.InferenceLocation);
+ }
+
+ private void AddFrames(IVisionBuildContext context)
+ {
+ IVisionNodeBuilder nodes = context.Nodes;
+ nodes.AddFrame("World", frame => frame
+ .WithFrameId(BinPickingRobotCell.WorldFrameId)
+ .WithRole(VisionFrameRoleEnum.World)
+ .WithTransform(Pose(BinPickingRobotCell.WorldFrameId, 0.0, 0.0, 0.0)));
+ m_frames.Add(BinPickingRobotCell.WorldFrameId);
+
+ nodes.AddFrame("RobotBase", frame => frame
+ .WithFrameId(BinPickingRobotCell.RobotBaseFrameId)
+ .WithRole(VisionFrameRoleEnum.Base)
+ .WithParent(BinPickingRobotCell.WorldFrameId)
+ .WithTransform(Pose(
+ BinPickingRobotCell.WorldFrameId,
+ 0.0,
+ 0.0,
+ BinPickingRobotCell.RobotBaseHeightMetres)));
+ m_frames.Add(BinPickingRobotCell.RobotBaseFrameId);
+
+ nodes.AddFrame("Flange", frame => frame
+ .WithFrameId(BinPickingRobotCell.FlangeFrameId)
+ .WithRole(VisionFrameRoleEnum.MechanicalInterface)
+ .WithParent(BinPickingRobotCell.RobotBaseFrameId)
+ .WithTransform(FlangeScanPose()));
+ m_frames.Add(BinPickingRobotCell.FlangeFrameId);
+
+ nodes.AddFrame("GripperTcp", frame => frame
+ .WithFrameId(BinPickingRobotCell.ToolFrameId)
+ .WithRole(VisionFrameRoleEnum.Tool)
+ .WithParent(BinPickingRobotCell.FlangeFrameId)
+ .WithTransform(Pose(
+ BinPickingRobotCell.FlangeFrameId,
+ BinPickingPalletizerGeometry.FlangeToTcpMetres,
+ 0.0,
+ 0.0)));
+ m_frames.Add(BinPickingRobotCell.ToolFrameId);
+
+ nodes.AddFrame("CameraEih", frame => frame
+ .WithFrameId(BinPickingRobotCell.CameraFrameId)
+ .WithRole(VisionFrameRoleEnum.Camera)
+ .WithParent(BinPickingRobotCell.FlangeFrameId)
+ .WithTransform(HandEyeTransform()));
+ m_frames.Add(BinPickingRobotCell.CameraFrameId);
+ }
+
+ private void AddPipeline(IVisionBuildContext context)
+ {
+ NodeId deployment = new NodeId(DeploymentBrowseName, context.InstanceNamespaceIndex);
+ bool offServer = m_options.InferenceLocation == BinPickingInferenceLocation.EdgeOffServer;
+ context.Nodes.AddPipeline(PipelineBrowseName, pipe =>
+ {
+ pipe.WithPipelineId(PipelineId)
+ .WithSensor(FindSensor(context, SensorTwinBrowseName)?.NodeId ?? NodeId.Null)
+ .WithDeployment(deployment);
+ if (offServer)
+ {
+ // Off-server perception: publish the OffServer facet, and register the same
+ // agent object as both provider (so RunInference explains the mode with
+ // BadNotSupported) and feedback sink (so SubmitDetections/Correction arrive
+ // at a single owner). The ground-truth provider is not wired — the two
+ // paths never run at the same time.
+ pipe.UseInferenceProvider(m_agentProvider, onServer: false)
+ .UseFeedbackSink(m_agentProvider);
+ }
+ else
+ {
+ // On-server ground truth: publish the OnServer facet and register the
+ // deterministic detector. No feedback sink — a client cannot submit
+ // detections when nothing on the Server side is designed to consume them.
+ pipe.UseInferenceProvider(m_inferenceProvider, onServer: true);
+ }
+ });
+ }
+
+ private async ValueTask FinalizePipelineAsync(
+ IVisionBuildContext context, CancellationToken cancellationToken)
+ {
+ InferencePipelineState pipeline = FindPipeline(context, PipelineBrowseName)
+ ?? throw new InvalidOperationException(
+ "Pipeline '" + PipelineBrowseName + "' was not registered on the Vision node manager.");
+ ImageSensorState sensor = FindSensor(context, SensorTwinBrowseName)
+ ?? throw new InvalidOperationException(
+ "Sensor '" + SensorTwinBrowseName + "' was not registered on the Vision node manager.");
+ // The Vision builder creates and registers the Results folder for any
+ // pipeline that has an inference provider, so the cell only looks it up.
+ FolderState results = pipeline.Results
+ ?? throw new InvalidOperationException(
+ "The Vision builder must create the pipeline's Results folder.");
+ await ValueTask.CompletedTask.ConfigureAwait(false);
+ VisionIntrinsicsDataType intrinsics = BuildIntrinsics();
+ NodeId deployment = new NodeId(DeploymentBrowseName, context.InstanceNamespaceIndex);
+ var target = new BinPickingInferenceTarget(
+ context.Manager,
+ context.Context,
+ context.InstanceNamespaceIndex,
+ pipeline.NodeId,
+ sensor.NodeId,
+ deployment,
+ results,
+ BinPickingRobotCell.CameraFrameId,
+ PixelFormat,
+ intrinsics.Fx,
+ intrinsics.Fy,
+ intrinsics.Cx,
+ intrinsics.Cy,
+ intrinsics.Width,
+ intrinsics.Height,
+ CameraInWorldPose());
+ if (m_options.InferenceLocation == BinPickingInferenceLocation.EdgeOffServer)
+ {
+ m_agentProvider.Attach(target);
+ }
+ else
+ {
+ m_inferenceProvider.Attach(target);
+ }
+ }
+
+ private static InferencePipelineState? FindPipeline(IVisionBuildContext context, string browseName)
+ {
+ FolderState? pipelines = context.Root.Pipelines;
+ if (pipelines == null)
+ {
+ return null;
+ }
+ var children = new List();
+ pipelines.GetChildren(context.Context, children);
+ var qualified = new QualifiedName(browseName, context.InstanceNamespaceIndex);
+ foreach (BaseInstanceState child in children)
+ {
+ if (child is InferencePipelineState pipeline && pipeline.BrowseName == qualified)
+ {
+ return pipeline;
+ }
+ }
+ return null;
+ }
+
+ private void AddSensor(IVisionBuildContext context)
+ {
+ IVisionNodeBuilder nodes = context.Nodes;
+ VisionIntrinsicsDataType intrinsics = BuildIntrinsics();
+ nodes.AddImageSensor(SensorTwinBrowseName, sensor => sensor
+ .WithSensorId("cam-eih-01")
+ .WithModality(VisionSensorModalityEnum.Area2D)
+ .WithRealityKind(VisionRealityKindEnum.Simulated)
+ .WithManufacturer("OPC Foundation")
+ .WithModel("Simulated Eye-in-Hand Camera")
+ .WithSerialNumber("SIM-EIH-2448-2048-0001")
+ .WithDeviceUri("opcua-openusd://binpicking-cell/cameras/camera_eih")
+ .WithFrameId(BinPickingRobotCell.CameraFrameId)
+ .WithResolution(SensorWidth, SensorHeight)
+ .WithPixelFormat(PixelFormat)
+ .WithIntrinsics(intrinsics)
+ .WithOptics(optics => optics
+ .WithFocalLength(0.01224)
+ .WithAperture(2.8)
+ .WithWorkingDistance(0.35)
+ .WithLensType("Fixed C-mount")
+ .WithMountType("C"))
+ .AddIntrinsicCalibration(IntrinsicCalibrationBrowseName, calibration => calibration
+ .WithCalibrationId("intr-cam-eih-01-612")
+ .WithMethod("Zhang")
+ .WithResidualError(0.21)
+ .WithIntrinsics(intrinsics))
+ .AddExtrinsicCalibration(HandEyeCalibrationBrowseName, calibration => calibration
+ .WithCalibrationId("hand-eye-cam-eih-01")
+ .WithResidualError(0.0008)
+ .WithMount(VisionCalibrationMountEnum.EyeInHand)
+ .WithFrames(BinPickingRobotCell.CameraFrameId, BinPickingRobotCell.FlangeFrameId)
+ .WithTransform(HandEyeTransform()))
+ .AddStreamEndpoint(StreamEndpointBrowseName, endpoint => endpoint
+ .WithEndpointId("stream-live")
+ .WithEndpointUri("rtsp://simulated-eih.local:554/main")
+ .WithProtocol(VisionStreamProtocolEnum.Rtsp)
+ .WithCodec(VisionVideoCodecEnum.H264)
+ .WithResolution(SensorWidth, SensorHeight)
+ .WithFrameRate(15.0)
+ .WithBitrate(24_000_000u)
+ .WithDefaultProfileName("main"))
+ .AddClipEndpoint(ClipEndpointBrowseName, endpoint => endpoint
+ .WithEndpointId("clip-pick-frames")
+ .WithEndpointUri("opcua-inline://binpicking-cell/clips")
+ .WithClipFormat(VisionClipFormatEnum.Png)
+ .WithQuality(90u)
+ .WithResolution(SensorWidth, SensorHeight)
+
+ // The PNG this cell renders is well under a megabyte, but allow
+ // headroom for a busier scene rather than have the Server refuse its
+ // own frames. Note this ceiling was never what refused GetClip - the
+ // frame was already under it - see MaxInlineClipBytes.
+ .WithInlineDelivery(enabled: true, maxInlineClipSize: MaxInlineClipBytes)
+ .WithDefaultProfileName("PickFrames"))
+ .UseMediaProvider(m_mediaProvider));
+ }
+
+ private async ValueTask AttachSimulatedTwinAsync(
+ IVisionBuildContext context, CancellationToken cancellationToken)
+ {
+ ImageSensorState? sensor = FindSensor(context, SensorTwinBrowseName);
+ if (sensor == null)
+ {
+ throw new InvalidOperationException(
+ "Sensor '" + SensorTwinBrowseName + "' was not registered on the Vision node manager.");
+ }
+ IVisionSimulatedState simulated = context.Context.CreateInstanceOfIVisionSimulatedType(
+ sensor,
+ new QualifiedName("Simulated", context.VisionNamespaceIndex));
+ simulated.ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasInterface;
+ simulated.NodeId = context.Context.RequireNodeIdFactory().New(context.Context, simulated);
+ simulated.CreateOrReplaceSimulatorUri(context.Context, null).Value =
+ "opcua-openusd://binpicking-cell";
+ simulated.CreateOrReplaceStageIdentifier(context.Context, null).Value = m_stage.CellStagePath;
+ simulated.CreateOrReplacePrimPath(context.Context, null).Value = CameraPrimPath;
+ simulated.CreateOrReplaceGroundTruthAvailable(context.Context, null).Value = true;
+ sensor.AddChild(simulated);
+ await context.Manager.AddPredefinedNodeAsync(simulated, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static ImageSensorState? FindSensor(IVisionBuildContext context, string browseName)
+ {
+ FolderState? sensors = context.Root.Sensors;
+ if (sensors == null)
+ {
+ return null;
+ }
+ var children = new List();
+ sensors.GetChildren(context.Context, children);
+ var qualified = new QualifiedName(browseName, context.InstanceNamespaceIndex);
+ foreach (BaseInstanceState child in children)
+ {
+ if (child is ImageSensorState imageSensor && imageSensor.BrowseName == qualified)
+ {
+ return imageSensor;
+ }
+ }
+ return null;
+ }
+
+ private static VisionIntrinsicsDataType BuildIntrinsics()
+ {
+ // Calibrated on the native 2448x2048 grid, then scaled to the binned grid the
+ // camera actually delivers. Binning is an exact integer decimation, so the
+ // focal lengths and principal point divide by the bin factor and the
+ // Brown-Conrady coefficients - which are expressed in normalised image
+ // coordinates - carry over unchanged.
+ const double bin = SensorBinning;
+ return new VisionIntrinsicsDataType
+ {
+ Fx = 2140.5 / bin,
+ Fy = 2139.8 / bin,
+ Cx = 1223.1 / bin,
+ Cy = 1021.7 / bin,
+ Skew = 0.0,
+ DistortionModel = VisionDistortionModelEnum.BrownConrady,
+ DistortionCoefficients = new[]
+ {
+ -0.1721,
+ 0.0934,
+ 0.0002,
+ -0.0001,
+ -0.0188
+ }.ToArrayOf(),
+ Width = SensorWidth,
+ Height = SensorHeight
+ };
+ }
+
+ private static VisionPose3DDataType Pose(string frameId, double x, double y, double z)
+ {
+ return new VisionPose3DDataType
+ {
+ FrameId = frameId,
+ Position = new[] { x, y, z }.ToArrayOf(),
+ Orientation = s_identityOrientation.ToArrayOf(),
+ Covariance = ArrayOf.Empty
+ };
+ }
+
+ private static VisionPose3DDataType HandEyeTransform()
+ {
+ return new VisionPose3DDataType
+ {
+ FrameId = BinPickingRobotCell.FlangeFrameId,
+ Position = s_handEyePosition.ToArrayOf(),
+ Orientation = s_handEyeOrientation.ToArrayOf(),
+ Covariance = ArrayOf.Empty
+ };
+ }
+
+ private static VisionPose3DDataType FlangeScanPose()
+ {
+ return new VisionPose3DDataType
+ {
+ FrameId = BinPickingRobotCell.RobotBaseFrameId,
+ Position = s_flangeScanPosition.ToArrayOf(),
+ Orientation = s_flangeScanOrientation.ToArrayOf(),
+ Covariance = ArrayOf