mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 00:31:32 +08:00
1bb4bdca1d
# Description Implement the released [OPC 40100-1](https://reference.opcfoundation.org/specs/OPC-40100-1) and [OPC 40100-2](https://reference.opcfoundation.org/specs/OPC-40100-2) specifications with generated models, provider-backed runtimes, typed clients, MCP workflows, and DI/Machinery integration. - **Part 1 stays in `Opc.Ua.Vision`** and its client/server packages alongside the distinct Perception model. Provide control/acquisition state machines, jobs and synchronized steps, recipe/product/configuration/result management, file transfers, diagnostics, events, and conditions. - **Part 2 is in `Opc.Ua.MachineVision`, `.Client`, and `.Server`.** Provide asset categories, identification, image-handling aspects, health/maintenance data, topology, and transactional updates. The base Vision assemblies do not reference these optional packages; the Part 2 wire model does not depend on Part 1. - Preserve the release-pinned model bytes, wire namespaces and NodeIds. Perception types are in `Opc.Ua.Vision.Perception`; the AMCM generated namespace remains `Opc.Ua.Vision.MachineVision.AMCM`, and asset client/server extension namespaces remain `Opc.Ua.Vision.Client` and `Opc.Ua.Vision.Server`. - Use `AddOpcUaMachineVision` and `AddOpcUaMachineVisionAMCM` for model loading and factory registration. Each has node-collection, encodeable-factory-builder and node-state-factory-builder overloads. These short entry points delegate to the generated implementations; existing namespace-derived generated names remain available for compatibility. - Support multiple independent named systems/assets under one namespace-owning manager. Reuse injectable providers, file binding, bounded memory stores, optional transactional `IFileSystemProvider` persistence, state machines, telemetry and conformance infrastructure. - Keep one `docs/Vision.md` guide, titled `Machine Vision (OPC 40100-1 and OPC 40100-2)`, and one `Opc.Ua.Mcp.Vision` package. Include deterministic server/client samples. - Expose 17 Machine Vision MCP tools, including capabilities/status, bounded relationships and observation, complete small-file uploads/downloads, management, explicit control, conditions, and chunked transfers. Catalogue counts: 39 Vision-specific, 43 with connection tools, 81 Vision+Robotics, 155 Full. Every Machine Vision wire parameter has a schema description. **Compatibility and limits:** `AddVision()` remains Perception-only unless released parts are selected. Standalone Part 2 uses `AddVision().AddMachineVision()`; Machinery composition uses `AddVisionToMachinery()`. Existing tool defaults and command signatures are preserved. Observation owns and cleans up its subscription: 5 seconds/50 notifications by default, capped at 60 seconds/100 notifications. Complete-file tools are capped at 1 MiB; larger files use chunked transfers. Domain Error values remain distinct from UA service failures. **Versioning:** the seven Vision/MachineVision/MCP packages and their Debug variants follow the repository root version instead of being forced to preview. Vision.OpenUsd and its descendants remain preview-only. Development builds still inherit the root prerelease version. No release version, tag, release branch, publication policy, or feed promotion is changed. **Review actions:** all fourteen review threads have action/evidence replies and are resolved. Changes include the unified guide, Perception namespace, separate Part 2 packages and final naming, multi-system ownership explanation, interrupted-write cleanup, observer failure isolation, complete MCP parameter descriptions, and explicit package inventory. The preview-layout migration topic and the requested ReleaseProcess caveat were removed. The test Publish collector now retries recognized transient failures within one cancellation budget and propagates other exceptions. Commit69b1f5db9applies the requested short loader names and renames standalone hosting to `AddMachineVision`, with callers, tests and documentation updated. The intervening master merge is preserved. ### Verification **Latest API naming changes, verified locally:** | Scope | Result | | --- | --- | | Full Vision suite, net10 | 572 passed, 1 existing skip, 0 failed | | Full Vision suite, net48 | 515 passed, 0 skipped, 0 failed | | Final loader/hosting cases after cross-TFM null-guard adjustment | 9 passed, 0 failed, 0 skipped | | Machine Vision MCP and profile cases | 198 passed, 0 failed, 0 skipped | | Main and MCP AOT test-project compilation | Both built with 0 warnings/errors | | MCP AOT companion, managed execution | 5 passed, 0 failed, 0 skipped; not claimed as native execution | `ModelLoaderNamesRegisterTypedFactories`, `ModelLoaderNamesRejectNullArguments`, and `GeneratedModelsLoadTogetherAndCreateTypedInstances` cover all six loader overloads, fluent return identities, typed instances, namespace coexistence and null guards. Existing standalone Part 2 tests verify idempotent registration, one-owner enforcement and null handling through `AddMachineVision`. No source-generator implementation, CLR namespace, wire identity or model payload was changed for these naming updates. The current69b1f5db9revision is running [CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826937), [package validation](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165823359), [MCP usability](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826874), [CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826906), and [Images CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826884). These runs are not claimed as completed results. **Earlier completed verification:** all seven default-TFM package archives were inspected after the package rename: exact IDs, 27 clean DLLs, correct dependencies/readmes/licenses, and unchanged pinned model hashes. [CI onc75ec4fa6](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38142519760) passed, including the formerly failing Linux batch and required summary. Its unchanged coverage gate passed at **88.56% lines, 78.96% branches, 84.28% patch** against the 75% patch floor. The later bounded-Publish-helper regressions reproduced 13 failures before the fix and passed afterward; the full mainline triggering fixture passed 67 tests on each of net10/net48. These are historical results, not current full-matrix results. <details> <summary>Executed API naming validation commands and outcomes</summary> Run from the worktree root using the established artifact trees. Dependency builds were performed before the no-build full net10 run; the focused loader/hosting selection was rebuilt and rerun after the final null-guard adjustment. ```powershell $files = 'C:\Users\mschier\.copilot\session-state\deb2ba10-b0e9-4a44-8783-8331bc850814\files' $env:DOTNET_CLI_USE_MSBUILD_SERVER = '0' $env:TEMP = 'C:\Temp\mv-deb2\followup' $env:TMP = $env:TEMP # Passed: 572; Failed: 0; Skipped: 1. dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-build --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net10" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup' # Passed: 9; Failed: 0; Skipped: 0, on the final source. dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~MachineVisionModelTests|FullyQualifiedName~StandalonePartTwoRegistrationUsesOneOwnerAndOneModelProvider|FullyQualifiedName~StandalonePartTwoRequiresRegistrationAndRejectsCompetingOwnership' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-model" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup' # Passed: 198; Failed: 0; Skipped: 0. dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~VisionMachineToolsTests|FullyQualifiedName~McpHostBuilderTests|FullyQualifiedName~McpToolProfileCompositionTests' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-mcp" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup' $env:TEMP = 'C:\Temp\mv-deb2\followup48' $env:TMP = $env:TEMP # Passed: 515; Failed: 0; Skipped: 0. dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Debug -f net48 --no-restore --artifacts-path "$files\solution-net48-artifacts" -p:CustomTestTarget=net48 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net48-final" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup48' ``` Opt-in long-running/stress/durable tiers have not been rerun locally; the unqualified **all tests** checklist item remains open. Original implementation matrix counts are not presented as current results. </details> ## Related Issues - Fixes #4732. The requester reviewed and approved the implementation plan and subsequent packaging/API decisions in the development session. The tracking issue records the design; maintainer architectural discussion/sign-off there is still pending. This PR remains a draft. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
429 lines
19 KiB
C#
429 lines
19 KiB
C#
/* ========================================================================
|
|
* 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 System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.AI.Server;
|
|
using Opc.Ua.Vision.Perception;
|
|
using Opc.Ua.Vision.Server;
|
|
|
|
namespace Vision.VisualInspectionCell
|
|
{
|
|
internal sealed class VisualInspectionFeedbackSink : IVisionFeedbackSink
|
|
{
|
|
public VisualInspectionFeedbackSink(
|
|
VisualInspectionResultPublisher publisher,
|
|
AINodeManagerRegistry aiRegistry,
|
|
InspectionVerdictPolicy verdictPolicy,
|
|
TimeProvider timeProvider,
|
|
ILogger<VisualInspectionFeedbackSink> logger)
|
|
{
|
|
m_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
|
m_aiRegistry = aiRegistry ?? throw new ArgumentNullException(nameof(aiRegistry));
|
|
m_verdictPolicy = verdictPolicy ?? throw new ArgumentNullException(nameof(verdictPolicy));
|
|
m_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public void Attach(VisualInspectionTarget target)
|
|
{
|
|
if (target == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(target));
|
|
}
|
|
if (Interlocked.CompareExchange(ref m_target, target, null) != null)
|
|
{
|
|
throw new InvalidOperationException("The feedback sink is already attached.");
|
|
}
|
|
if (m_logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
m_logger.FeedbackAttached(target.PipelineNodeId.ToString());
|
|
}
|
|
}
|
|
|
|
public async ValueTask<ServiceResult> SubmitDetectionsAsync(
|
|
VisionSubmitDetectionsRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(request));
|
|
}
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (request.SceneIsEmpty && request.Purpose == VisionFeedbackPurposeEnum.GroundTruthLabel)
|
|
{
|
|
await RecordLearningSampleAsync(
|
|
StableSampleId(request.Pipeline, FrameKey(request.FrameReference, "empty-scene"), "scene-empty"),
|
|
AILearningSampleKind.Negative,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else if (request.Purpose == VisionFeedbackPurposeEnum.GroundTruthLabel)
|
|
{
|
|
await RecordLearningSampleAsync(
|
|
StableSampleId(request.Pipeline, FrameKey(request.FrameReference, "geometry"), "geometry"),
|
|
AILearningSampleKind.Positive,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
public async ValueTask<ServiceResult> SubmitInspectionResultAsync(
|
|
VisionSubmitInspectionResultRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(request));
|
|
}
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
VisualInspectionTarget target = RequireTarget();
|
|
if (!request.Pipeline.Equals(target.PipelineNodeId))
|
|
{
|
|
return new ServiceResult(StatusCodes.BadNodeIdUnknown,
|
|
LocalizedText.From("The pipeline node id does not match the attached inspection pipeline."));
|
|
}
|
|
string resultId = string.IsNullOrEmpty(request.ResultId)
|
|
? StableResultId(request.Pipeline, request.Characteristics)
|
|
: request.ResultId;
|
|
if (!TryGetImageReference(resultId, out VisionImageReferenceDataType? frameReference))
|
|
{
|
|
return new ServiceResult(StatusCodes.BadInvalidState,
|
|
LocalizedText.From("SubmitImageReference must provide provenance before publishing a result."));
|
|
}
|
|
VisionResultEvaluationEnum evaluation = m_verdictPolicy.JudgeCharacteristics(request.Characteristics);
|
|
PublishedInspectionResult published = await m_publisher.PublishAsync(
|
|
target,
|
|
resultId,
|
|
TimestampOrNow(frameReference.Timestamp),
|
|
evaluation,
|
|
request.Characteristics,
|
|
"agent-edge-off-server",
|
|
frameReference,
|
|
string.Empty,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (evaluation == VisionResultEvaluationEnum.NotDecidable)
|
|
{
|
|
// An off-server agent supplied evidence, but production policy still remains external:
|
|
// the server only exposes the dialog and waits for the operator's OPC UA response.
|
|
m_operatorDialog?.RequestDisposition(published);
|
|
}
|
|
m_logger.AgentInspectionPublished(resultId, evaluation);
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
public async ValueTask<ServiceResult> SubmitCorrectionAsync(
|
|
VisionSubmitCorrectionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(request));
|
|
}
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
VisualInspectionTarget target = RequireTarget();
|
|
if (!request.Pipeline.Equals(target.PipelineNodeId))
|
|
{
|
|
return new ServiceResult(StatusCodes.BadNodeIdUnknown,
|
|
LocalizedText.From("The pipeline node id does not match the attached inspection pipeline."));
|
|
}
|
|
AILearningSampleKind kind = request.RetractAll
|
|
? AILearningSampleKind.Negative
|
|
: AILearningSampleKind.Positive;
|
|
string sampleId = GroundTruthSampleId(request.Pipeline, request.ResultId);
|
|
await RecordLearningSampleAsync(sampleId, kind, cancellationToken).ConfigureAwait(false);
|
|
if (request.CorrectedCharacteristics.Count > 0)
|
|
{
|
|
string resultId = FormattableString.Invariant($"correction-{Sanitize(request.ResultId)}");
|
|
VisionImageReferenceDataType frameReference = TryGetImageReference(request.ResultId,
|
|
out VisionImageReferenceDataType? image)
|
|
? image
|
|
: FrameFromPublished(request.ResultId);
|
|
await m_publisher.PublishAsync(
|
|
target,
|
|
resultId,
|
|
TimestampOrNow(frameReference.Timestamp),
|
|
m_verdictPolicy.JudgeCharacteristics(request.CorrectedCharacteristics),
|
|
request.CorrectedCharacteristics,
|
|
"operator-ground-truth",
|
|
frameReference,
|
|
FixtureFromPublished(request.ResultId),
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
m_logger.CorrectionRecorded(sampleId, kind);
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
public ValueTask<ServiceResult> SubmitImageReferenceAsync(
|
|
VisionSubmitImageReferenceRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(request));
|
|
}
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (string.IsNullOrEmpty(request.ResultId))
|
|
{
|
|
return ValueTask.FromResult(new ServiceResult(
|
|
StatusCodes.BadInvalidArgument,
|
|
LocalizedText.From("An image reference must name the result it belongs to.")));
|
|
}
|
|
lock (m_lock)
|
|
{
|
|
m_imageReferences[request.ResultId] = request.Image;
|
|
}
|
|
return ValueTask.FromResult(ServiceResult.Good);
|
|
}
|
|
|
|
public void AttachOperatorDialog(OperatorDialogController operatorDialog)
|
|
{
|
|
m_operatorDialog = operatorDialog ?? throw new ArgumentNullException(nameof(operatorDialog));
|
|
}
|
|
|
|
public ValueTask HandleOperatorDispositionAsync(
|
|
PublishedInspectionResult result,
|
|
OperatorDisposition disposition,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (result == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(result));
|
|
}
|
|
return HandleOperatorDispositionCoreAsync(result, disposition, cancellationToken);
|
|
}
|
|
|
|
private async ValueTask RecordLearningSampleAsync(
|
|
string sampleId,
|
|
AILearningSampleKind kind,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
AINodeManager? manager = m_aiRegistry.NodeManager;
|
|
if (manager == null)
|
|
{
|
|
m_logger.LearningSampleSkipped(sampleId);
|
|
return;
|
|
}
|
|
bool added = await manager.RecordLearningSampleAsync(sampleId, kind, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
m_logger.LearningSampleRecorded(sampleId, kind, added);
|
|
}
|
|
|
|
private VisualInspectionTarget RequireTarget()
|
|
{
|
|
return m_target ?? throw new InvalidOperationException("The feedback sink is not attached.");
|
|
}
|
|
|
|
private async ValueTask HandleOperatorDispositionCoreAsync(
|
|
PublishedInspectionResult result,
|
|
OperatorDisposition disposition,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
VisualInspectionTarget target = RequireTarget();
|
|
VisionResultEvaluationEnum evaluation = disposition switch
|
|
{
|
|
OperatorDisposition.AcceptAsOk => VisionResultEvaluationEnum.Ok,
|
|
OperatorDisposition.AcceptAsNotOk => VisionResultEvaluationEnum.NotOk,
|
|
_ => VisionResultEvaluationEnum.NotDecidable
|
|
};
|
|
if (evaluation == VisionResultEvaluationEnum.NotDecidable)
|
|
{
|
|
m_logger.OperatorDispositionIgnored(result.ResultId, disposition);
|
|
return;
|
|
}
|
|
|
|
AILearningSampleKind kind = disposition == OperatorDisposition.AcceptAsNotOk
|
|
? AILearningSampleKind.Negative
|
|
: AILearningSampleKind.Positive;
|
|
string sampleId = GroundTruthSampleId(target.PipelineNodeId, result.ResultId);
|
|
await RecordLearningSampleAsync(sampleId, kind, cancellationToken).ConfigureAwait(false);
|
|
string correctionId = FormattableString.Invariant(
|
|
$"operator-{Sanitize(result.ResultId)}-{disposition}");
|
|
await m_publisher.PublishAsync(
|
|
target,
|
|
correctionId,
|
|
TimestampOrNow(result.FrameReference.Timestamp),
|
|
evaluation,
|
|
result.Characteristics,
|
|
"operator-ground-truth",
|
|
result.FrameReference,
|
|
result.FixtureName,
|
|
cancellationToken).ConfigureAwait(false);
|
|
m_logger.OperatorDispositionRecorded(result.ResultId, disposition, sampleId, kind);
|
|
}
|
|
|
|
private static string StableSampleId(NodeId pipeline, string resultId, string purpose)
|
|
{
|
|
return FormattableString.Invariant($"{pipeline}|{resultId}|{purpose}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Identifies the ground truth held for one inspection result.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A correction can arrive by two routes - an operator answering the
|
|
/// disposition dialog, or a caller invoking <c>SubmitCorrection</c> - and
|
|
/// both describe the same decision about the same frame. Keying the sample
|
|
/// on the result alone makes the second arrival a duplicate that the
|
|
/// accounting rejects. Keying it on the route, as this once did, made one
|
|
/// human decision count twice and inflated the very number section 9.4
|
|
/// asks a Server to keep honestly.
|
|
/// </remarks>
|
|
private static string GroundTruthSampleId(NodeId pipeline, string resultId)
|
|
{
|
|
return StableSampleId(pipeline, resultId, "ground-truth");
|
|
}
|
|
|
|
private static string StableResultId(
|
|
NodeId pipeline,
|
|
ArrayOf<VisionCharacteristicDataType> characteristics)
|
|
{
|
|
string key = characteristics.Count == 0
|
|
? "empty"
|
|
: characteristics[0].CharacteristicId +
|
|
":" +
|
|
characteristics[0].Actual.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
return FormattableString.Invariant($"agent-insp-{Sanitize(pipeline.ToString())}-{Sanitize(key)}");
|
|
}
|
|
|
|
private static string FrameKey(VisionImageReferenceDataType frame, string fallback)
|
|
{
|
|
return string.IsNullOrEmpty(frame?.Uri) ? fallback : frame.Uri;
|
|
}
|
|
|
|
private bool TryGetImageReference(
|
|
string resultId,
|
|
[NotNullWhen(true)] out VisionImageReferenceDataType? image)
|
|
{
|
|
lock (m_lock)
|
|
{
|
|
return m_imageReferences.TryGetValue(resultId, out image);
|
|
}
|
|
}
|
|
|
|
private VisionImageReferenceDataType FrameFromPublished(string resultId)
|
|
{
|
|
return m_publisher.TryGetPublished(resultId, out PublishedInspectionResult? published) &&
|
|
published != null
|
|
? published.FrameReference
|
|
: new VisionImageReferenceDataType { Timestamp = TimestampOrNow(DateTimeUtc.MinValue) };
|
|
}
|
|
|
|
private string FixtureFromPublished(string resultId)
|
|
{
|
|
return m_publisher.TryGetPublished(resultId, out PublishedInspectionResult? published) &&
|
|
published != null
|
|
? published.FixtureName
|
|
: string.Empty;
|
|
}
|
|
|
|
private DateTimeUtc TimestampOrNow(DateTimeUtc timestamp)
|
|
{
|
|
return timestamp.IsNull ? DateTimeUtc.From(m_timeProvider.GetUtcNow()) : timestamp;
|
|
}
|
|
|
|
private static string Sanitize(string value)
|
|
{
|
|
return string.IsNullOrEmpty(value) ? "unknown" : value.Replace('-', '_');
|
|
}
|
|
|
|
private readonly VisualInspectionResultPublisher m_publisher;
|
|
private readonly AINodeManagerRegistry m_aiRegistry;
|
|
private readonly InspectionVerdictPolicy m_verdictPolicy;
|
|
private readonly TimeProvider m_timeProvider;
|
|
private readonly ILogger<VisualInspectionFeedbackSink> m_logger;
|
|
private readonly Lock m_lock = new();
|
|
private readonly Dictionary<string, VisionImageReferenceDataType> m_imageReferences = [];
|
|
private VisualInspectionTarget? m_target;
|
|
private OperatorDialogController? m_operatorDialog;
|
|
}
|
|
|
|
internal static partial class VisualInspectionFeedbackSinkLog
|
|
{
|
|
[LoggerMessage(EventId = VisualInspectionCellEventIds.Feedback + 1,
|
|
Level = LogLevel.Information,
|
|
Message = "Visual inspection feedback sink attached to pipeline {PipelineNodeId}.")]
|
|
public static partial void FeedbackAttached(this ILogger<VisualInspectionFeedbackSink> logger, string pipelineNodeId);
|
|
|
|
[LoggerMessage(EventId = VisualInspectionCellEventIds.Feedback + 2,
|
|
Level = LogLevel.Information,
|
|
Message = "Published off-server inspection result {ResultId}: {Verdict}.")]
|
|
public static partial void AgentInspectionPublished(
|
|
this ILogger<VisualInspectionFeedbackSink> logger,
|
|
string resultId,
|
|
VisionResultEvaluationEnum verdict);
|
|
|
|
[LoggerMessage(EventId = VisualInspectionCellEventIds.Feedback + 3,
|
|
Level = LogLevel.Information,
|
|
Message = "Recorded correction sample {SampleId} ({Kind}).")]
|
|
public static partial void CorrectionRecorded(
|
|
this ILogger<VisualInspectionFeedbackSink> logger,
|
|
string sampleId,
|
|
AILearningSampleKind kind);
|
|
|
|
[LoggerMessage(EventId = VisualInspectionCellEventIds.Feedback + 4,
|
|
Level = LogLevel.Information,
|
|
Message = "Learning sample {SampleId} ({Kind}) added={Added}.")]
|
|
public static partial void LearningSampleRecorded(
|
|
this ILogger<VisualInspectionFeedbackSink> logger,
|
|
string sampleId,
|
|
AILearningSampleKind kind,
|
|
bool added);
|
|
|
|
[LoggerMessage(EventId = VisualInspectionCellEventIds.Feedback + 5,
|
|
Level = LogLevel.Warning,
|
|
Message = "AI node manager not available; learning sample {SampleId} was not counted.")]
|
|
public static partial void LearningSampleSkipped(
|
|
this ILogger<VisualInspectionFeedbackSink> logger,
|
|
string sampleId);
|
|
|
|
[LoggerMessage(EventId = VisualInspectionCellEventIds.Feedback + 6,
|
|
Level = LogLevel.Information,
|
|
Message = "Operator disposition {Disposition} for {ResultId} recorded as {SampleId} ({Kind}).")]
|
|
public static partial void OperatorDispositionRecorded(
|
|
this ILogger<VisualInspectionFeedbackSink> logger,
|
|
string resultId,
|
|
OperatorDisposition disposition,
|
|
string sampleId,
|
|
AILearningSampleKind kind);
|
|
|
|
[LoggerMessage(EventId = VisualInspectionCellEventIds.Feedback + 7,
|
|
Level = LogLevel.Information,
|
|
Message = "Operator disposition {Disposition} for result {ResultId} is operational only; no ground-truth sample was recorded.")]
|
|
public static partial void OperatorDispositionIgnored(
|
|
this ILogger<VisualInspectionFeedbackSink> logger,
|
|
string resultId,
|
|
OperatorDisposition disposition);
|
|
}
|
|
}
|