Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/OpenIPC.Viewer.Analytics/ObjectDetectionEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ public void Detach(CameraId cameraId)
reg.Dispose();
}

public bool IsAttached(CameraId cameraId) => _cameras.ContainsKey(cameraId);

private void OnFrame(CameraId cameraId, CameraRegistration reg, in VideoFrame frame)
{
var settings = reg.Settings();
Expand Down
8 changes: 8 additions & 0 deletions src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public sealed class SingleCameraPageFactory
private readonly OpenIPC.Viewer.Core.Platform.IAudioInput _audioInput;
private readonly IAudioBackchannelClient _backchannel;
private readonly IReachabilityProbe _reachability;
private readonly OpenIPC.Viewer.Core.Analytics.IAnalyticsEngine _analytics;
private readonly AnalyticsBootstrap _analyticsBootstrap;
private readonly ILoggerFactory _loggerFactory;

public SingleCameraPageFactory(
Expand All @@ -43,6 +45,8 @@ public SingleCameraPageFactory(
OpenIPC.Viewer.Core.Platform.IAudioInput audioInput,
IAudioBackchannelClient backchannel,
IReachabilityProbe reachability,
OpenIPC.Viewer.Core.Analytics.IAnalyticsEngine analytics,
AnalyticsBootstrap analyticsBootstrap,
ILoggerFactory loggerFactory)
{
_coordinator = coordinator;
Expand All @@ -59,12 +63,16 @@ public SingleCameraPageFactory(
_audioInput = audioInput;
_backchannel = backchannel;
_reachability = reachability;
_analytics = analytics;
_analyticsBootstrap = analyticsBootstrap;
_loggerFactory = loggerFactory;
}

public SingleCameraPageViewModel Create(Camera camera) =>
new(camera, _coordinator, _directory, _onvif, _majestic, _schema, _majesticSsh, _recordings, _userSettings, _dialogs, _snapshots, _audio,
new PushToTalkController(_audioInput, _backchannel),
_reachability,
_analytics,
_analyticsBootstrap,
_loggerFactory.CreateLogger<SingleCameraPageViewModel>());
}
69 changes: 68 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Globalization;
Expand All @@ -13,6 +14,7 @@
using OpenIPC.Viewer.App.Messages;
using OpenIPC.Viewer.App.Services;
using OpenIPC.Viewer.App.ViewModels.Majestic;
using OpenIPC.Viewer.Core.Analytics;
using OpenIPC.Viewer.Core.Entities;
using OpenIPC.Viewer.Core.Majestic;
using OpenIPC.Viewer.Core.Onvif;
Expand Down Expand Up @@ -40,10 +42,19 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis
private readonly AudioMonitor _audio;
private readonly PushToTalkController _talk;
private readonly IReachabilityProbe _reachability;
private readonly IAnalyticsEngine _analytics;
private readonly AnalyticsBootstrap _analyticsBootstrap;
private readonly ILogger<SingleCameraPageViewModel> _logger;
private Camera _camera;
private DispatcherTimer? _recTimer;

private IDisposable? _detectionsSub;
// True when WE attached this camera's frames to the analytics engine (the
// fallback path — normally the grid tile's feed is still running and we
// only mirror its results). Guards the detach so closing this page never
// strips a live tile registration.
private bool _analyticsAttachedHere;

// On a stream fault, TCP-probe the camera so we can tell a wedged-but-alive
// camera (Attention) from one that's truly gone (Offline). Short timeout.
private static readonly TimeSpan ReachabilityProbeTimeout = TimeSpan.FromSeconds(2);
Expand Down Expand Up @@ -92,9 +103,23 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis
public string StatusHeading => Localizer.Instance[
Status == CameraStatus.Attention ? "Stream.Interrupted" : "Stream.Disconnected"];

[ObservableProperty] private SessionTelemetry? _telemetry;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SourceAspect))]
private SessionTelemetry? _telemetry;
[ObservableProperty] private string? _errorMessage;

// AI detections (Phase 15) continue onto this page: boxes computed by the
// engine (usually still fed by the grid tile's substream) are normalized
// 0..1, so they land correctly on the mainstream picture here too.
[ObservableProperty] private IReadOnlyList<Detection> _detections = Array.Empty<Detection>();

public bool AnalyticsEnabled => _camera.AnalyticsOrDefault.Enabled;

// Source frame aspect (width/height) so DetectionOverlay maps boxes into
// the letterboxed video rect; 0 until telemetry → overlay uses full bounds.
public double SourceAspect =>
Telemetry is { Width: > 0, Height: > 0 } t ? (double)t.Width / t.Height : 0;

// Visible while the session is mid-connect (or backing off a reconnect). Gated
// on Session != null so the empty pre-activate window doesn't show a spinner
// out of nowhere. State changes flip both flags via NotifyPropertyChangedFor.
Expand Down Expand Up @@ -215,6 +240,8 @@ public SingleCameraPageViewModel(
AudioMonitor audio,
PushToTalkController talk,
IReachabilityProbe reachability,
IAnalyticsEngine analytics,
AnalyticsBootstrap analyticsBootstrap,
ILogger<SingleCameraPageViewModel> logger)
{
_camera = camera;
Expand All @@ -231,8 +258,16 @@ public SingleCameraPageViewModel(
_audio = audio;
_talk = talk;
_reachability = reachability;
_analytics = analytics;
_analyticsBootstrap = analyticsBootstrap;
_logger = logger;

// Mirror this camera's detection results (whoever feeds the engine —
// usually the still-running grid tile) onto the page overlay.
_detectionsSub = _analytics.Results
.Where(r => r.CameraId == _camera.Id)
.Subscribe(r => Dispatcher.UIThread.Post(() => Detections = r.Detections));

// Hydrate the shared monitor from the persisted prefs and reflect any
// later change (incl. from another page) back into the speaker UI.
_audio.Muted = _userSettings.Current.AudioMuted;
Expand Down Expand Up @@ -633,6 +668,8 @@ public async Task ActivateAsync(CancellationToken ct)
// only plays once unmuted; default is muted.
if (_audio.IsAvailable)
_audio.Attach(session, _camera.Id);

AttachAnalyticsFallback(session);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -815,6 +852,7 @@ private async Task ReloadStreamAsync()
_stateSub?.Dispose();
_telemetrySub?.Dispose();
_audioPresenceSub?.Dispose();
DetachAnalyticsFallback();
// Re-detect on the fresh session — a swapped camera may not have audio.
HasAudio = false;
if (Session is not null)
Expand Down Expand Up @@ -1300,6 +1338,33 @@ private async Task SaveSnapshotAsAsync()
}
}

// Fallback feed for the analytics engine: only when NOBODY is already
// feeding this camera (no grid tile — opened from the library, tile dropped
// by the session cap, stills mode, …). When a tile feed exists we only
// mirror its results; attaching over it would steal the registration and
// orphan the tile's detections when this page closes.
private void AttachAnalyticsFallback(IVideoSession session)
{
if (!_camera.AnalyticsOrDefault.Enabled || _analytics.IsAttached(_camera.Id))
return;
_ = _analyticsBootstrap.EnsureStartedAsync();
_analytics.Attach(
_camera.Id,
session.Frames,
() => _camera.AnalyticsOrDefault,
() => !_disposed && State == SessionState.Playing);
_analyticsAttachedHere = true;
}

private void DetachAnalyticsFallback()
{
if (!_analyticsAttachedHere)
return;
_analyticsAttachedHere = false;
_analytics.Detach(_camera.Id);
Detections = Array.Empty<Detection>();
}

[RelayCommand]
private void Back() =>
WeakReferenceMessenger.Default.Send(new GoBackToLibraryMessage());
Expand All @@ -1317,6 +1382,8 @@ public async ValueTask DisposeAsync()
_stateSub?.Dispose();
_telemetrySub?.Dispose();
_audioPresenceSub?.Dispose();
_detectionsSub?.Dispose();
DetachAnalyticsFallback();
_recordings.StateChanged -= OnRecordingsStateChanged;
_userSettings.Changed -= OnUserSettingsChanged;
_coordinator.Invalidated -= OnCoordinatorInvalidated;
Expand Down
14 changes: 14 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@
</controls:RtspVideoView.GestureRecognizers>
</controls:RtspVideoView>

<!-- AI detection boxes (Phase 15.5) — detection started on the grid tile
continues here; boxes are normalized so the substream-fed results
map onto the mainstream picture. Mirrors the video's zoom transform
so boxes stay glued to objects under digital zoom. -->
<controls:DetectionOverlay Detections="{Binding Detections}"
ShowBoxes="{Binding AnalyticsEnabled}"
SourceAspect="{Binding SourceAspect}"
RenderTransformOrigin="0.5,0.5"
IsHitTestVisible="False">
<controls:DetectionOverlay.RenderTransform>
<ScaleTransform ScaleX="{Binding ZoomLevel}" ScaleY="{Binding ZoomLevel}" />
</controls:DetectionOverlay.RenderTransform>
</controls:DetectionOverlay>

<!-- Top-left LIVE badge -->
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12"
Background="{StaticResource DangerBrush}"
Expand Down
6 changes: 6 additions & 0 deletions src/OpenIPC.Viewer.Core/Analytics/IAnalyticsEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@ void Attach(CameraId cameraId, IObservable<VideoFrame> frames,
Func<AnalyticsSettings> settings, Func<bool> isActive);

void Detach(CameraId cameraId);

// Whether some frame source currently feeds this camera. Lets a second
// view (single-camera page) attach its own session ONLY as a fallback —
// Attach replaces the existing registration, so blindly re-attaching
// would steal the grid tile's feed and orphan it on detach.
bool IsAttached(CameraId cameraId);
}
Loading