Skip to content

Commit 44bb72d

Browse files
authored
Merge pull request #54 from OpenIPC/feat/single-view-detections
feat(analytics): detections continue on the single-camera page
2 parents 1d41dd7 + eee892b commit 44bb72d

5 files changed

Lines changed: 98 additions & 1 deletion

File tree

src/OpenIPC.Viewer.Analytics/ObjectDetectionEngine.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ public void Detach(CameraId cameraId)
119119
reg.Dispose();
120120
}
121121

122+
public bool IsAttached(CameraId cameraId) => _cameras.ContainsKey(cameraId);
123+
122124
private void OnFrame(CameraId cameraId, CameraRegistration reg, in VideoFrame frame)
123125
{
124126
var settings = reg.Settings();

src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public sealed class SingleCameraPageFactory
2626
private readonly OpenIPC.Viewer.Core.Platform.IAudioInput _audioInput;
2727
private readonly IAudioBackchannelClient _backchannel;
2828
private readonly IReachabilityProbe _reachability;
29+
private readonly OpenIPC.Viewer.Core.Analytics.IAnalyticsEngine _analytics;
30+
private readonly AnalyticsBootstrap _analyticsBootstrap;
2931
private readonly ILoggerFactory _loggerFactory;
3032

3133
public SingleCameraPageFactory(
@@ -43,6 +45,8 @@ public SingleCameraPageFactory(
4345
OpenIPC.Viewer.Core.Platform.IAudioInput audioInput,
4446
IAudioBackchannelClient backchannel,
4547
IReachabilityProbe reachability,
48+
OpenIPC.Viewer.Core.Analytics.IAnalyticsEngine analytics,
49+
AnalyticsBootstrap analyticsBootstrap,
4650
ILoggerFactory loggerFactory)
4751
{
4852
_coordinator = coordinator;
@@ -59,12 +63,16 @@ public SingleCameraPageFactory(
5963
_audioInput = audioInput;
6064
_backchannel = backchannel;
6165
_reachability = reachability;
66+
_analytics = analytics;
67+
_analyticsBootstrap = analyticsBootstrap;
6268
_loggerFactory = loggerFactory;
6369
}
6470

6571
public SingleCameraPageViewModel Create(Camera camera) =>
6672
new(camera, _coordinator, _directory, _onvif, _majestic, _schema, _majesticSsh, _recordings, _userSettings, _dialogs, _snapshots, _audio,
6773
new PushToTalkController(_audioInput, _backchannel),
6874
_reachability,
75+
_analytics,
76+
_analyticsBootstrap,
6977
_loggerFactory.CreateLogger<SingleCameraPageViewModel>());
7078
}

src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Collections.ObjectModel;
44
using System.IO;
55
using System.Linq;
6+
using System.Reactive.Linq;
67
using System.Threading;
78
using System.Threading.Tasks;
89
using System.Globalization;
@@ -13,6 +14,7 @@
1314
using OpenIPC.Viewer.App.Messages;
1415
using OpenIPC.Viewer.App.Services;
1516
using OpenIPC.Viewer.App.ViewModels.Majestic;
17+
using OpenIPC.Viewer.Core.Analytics;
1618
using OpenIPC.Viewer.Core.Entities;
1719
using OpenIPC.Viewer.Core.Majestic;
1820
using OpenIPC.Viewer.Core.Onvif;
@@ -40,10 +42,19 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis
4042
private readonly AudioMonitor _audio;
4143
private readonly PushToTalkController _talk;
4244
private readonly IReachabilityProbe _reachability;
45+
private readonly IAnalyticsEngine _analytics;
46+
private readonly AnalyticsBootstrap _analyticsBootstrap;
4347
private readonly ILogger<SingleCameraPageViewModel> _logger;
4448
private Camera _camera;
4549
private DispatcherTimer? _recTimer;
4650

51+
private IDisposable? _detectionsSub;
52+
// True when WE attached this camera's frames to the analytics engine (the
53+
// fallback path — normally the grid tile's feed is still running and we
54+
// only mirror its results). Guards the detach so closing this page never
55+
// strips a live tile registration.
56+
private bool _analyticsAttachedHere;
57+
4758
// On a stream fault, TCP-probe the camera so we can tell a wedged-but-alive
4859
// camera (Attention) from one that's truly gone (Offline). Short timeout.
4960
private static readonly TimeSpan ReachabilityProbeTimeout = TimeSpan.FromSeconds(2);
@@ -92,9 +103,23 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis
92103
public string StatusHeading => Localizer.Instance[
93104
Status == CameraStatus.Attention ? "Stream.Interrupted" : "Stream.Disconnected"];
94105

95-
[ObservableProperty] private SessionTelemetry? _telemetry;
106+
[ObservableProperty]
107+
[NotifyPropertyChangedFor(nameof(SourceAspect))]
108+
private SessionTelemetry? _telemetry;
96109
[ObservableProperty] private string? _errorMessage;
97110

111+
// AI detections (Phase 15) continue onto this page: boxes computed by the
112+
// engine (usually still fed by the grid tile's substream) are normalized
113+
// 0..1, so they land correctly on the mainstream picture here too.
114+
[ObservableProperty] private IReadOnlyList<Detection> _detections = Array.Empty<Detection>();
115+
116+
public bool AnalyticsEnabled => _camera.AnalyticsOrDefault.Enabled;
117+
118+
// Source frame aspect (width/height) so DetectionOverlay maps boxes into
119+
// the letterboxed video rect; 0 until telemetry → overlay uses full bounds.
120+
public double SourceAspect =>
121+
Telemetry is { Width: > 0, Height: > 0 } t ? (double)t.Width / t.Height : 0;
122+
98123
// Visible while the session is mid-connect (or backing off a reconnect). Gated
99124
// on Session != null so the empty pre-activate window doesn't show a spinner
100125
// out of nowhere. State changes flip both flags via NotifyPropertyChangedFor.
@@ -215,6 +240,8 @@ public SingleCameraPageViewModel(
215240
AudioMonitor audio,
216241
PushToTalkController talk,
217242
IReachabilityProbe reachability,
243+
IAnalyticsEngine analytics,
244+
AnalyticsBootstrap analyticsBootstrap,
218245
ILogger<SingleCameraPageViewModel> logger)
219246
{
220247
_camera = camera;
@@ -231,8 +258,16 @@ public SingleCameraPageViewModel(
231258
_audio = audio;
232259
_talk = talk;
233260
_reachability = reachability;
261+
_analytics = analytics;
262+
_analyticsBootstrap = analyticsBootstrap;
234263
_logger = logger;
235264

265+
// Mirror this camera's detection results (whoever feeds the engine —
266+
// usually the still-running grid tile) onto the page overlay.
267+
_detectionsSub = _analytics.Results
268+
.Where(r => r.CameraId == _camera.Id)
269+
.Subscribe(r => Dispatcher.UIThread.Post(() => Detections = r.Detections));
270+
236271
// Hydrate the shared monitor from the persisted prefs and reflect any
237272
// later change (incl. from another page) back into the speaker UI.
238273
_audio.Muted = _userSettings.Current.AudioMuted;
@@ -633,6 +668,8 @@ public async Task ActivateAsync(CancellationToken ct)
633668
// only plays once unmuted; default is muted.
634669
if (_audio.IsAvailable)
635670
_audio.Attach(session, _camera.Id);
671+
672+
AttachAnalyticsFallback(session);
636673
}
637674
catch (Exception ex)
638675
{
@@ -815,6 +852,7 @@ private async Task ReloadStreamAsync()
815852
_stateSub?.Dispose();
816853
_telemetrySub?.Dispose();
817854
_audioPresenceSub?.Dispose();
855+
DetachAnalyticsFallback();
818856
// Re-detect on the fresh session — a swapped camera may not have audio.
819857
HasAudio = false;
820858
if (Session is not null)
@@ -1300,6 +1338,33 @@ private async Task SaveSnapshotAsAsync()
13001338
}
13011339
}
13021340

1341+
// Fallback feed for the analytics engine: only when NOBODY is already
1342+
// feeding this camera (no grid tile — opened from the library, tile dropped
1343+
// by the session cap, stills mode, …). When a tile feed exists we only
1344+
// mirror its results; attaching over it would steal the registration and
1345+
// orphan the tile's detections when this page closes.
1346+
private void AttachAnalyticsFallback(IVideoSession session)
1347+
{
1348+
if (!_camera.AnalyticsOrDefault.Enabled || _analytics.IsAttached(_camera.Id))
1349+
return;
1350+
_ = _analyticsBootstrap.EnsureStartedAsync();
1351+
_analytics.Attach(
1352+
_camera.Id,
1353+
session.Frames,
1354+
() => _camera.AnalyticsOrDefault,
1355+
() => !_disposed && State == SessionState.Playing);
1356+
_analyticsAttachedHere = true;
1357+
}
1358+
1359+
private void DetachAnalyticsFallback()
1360+
{
1361+
if (!_analyticsAttachedHere)
1362+
return;
1363+
_analyticsAttachedHere = false;
1364+
_analytics.Detach(_camera.Id);
1365+
Detections = Array.Empty<Detection>();
1366+
}
1367+
13031368
[RelayCommand]
13041369
private void Back() =>
13051370
WeakReferenceMessenger.Default.Send(new GoBackToLibraryMessage());
@@ -1317,6 +1382,8 @@ public async ValueTask DisposeAsync()
13171382
_stateSub?.Dispose();
13181383
_telemetrySub?.Dispose();
13191384
_audioPresenceSub?.Dispose();
1385+
_detectionsSub?.Dispose();
1386+
DetachAnalyticsFallback();
13201387
_recordings.StateChanged -= OnRecordingsStateChanged;
13211388
_userSettings.Changed -= OnUserSettingsChanged;
13221389
_coordinator.Invalidated -= OnCoordinatorInvalidated;

src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@
5757
</controls:RtspVideoView.GestureRecognizers>
5858
</controls:RtspVideoView>
5959

60+
<!-- AI detection boxes (Phase 15.5) — detection started on the grid tile
61+
continues here; boxes are normalized so the substream-fed results
62+
map onto the mainstream picture. Mirrors the video's zoom transform
63+
so boxes stay glued to objects under digital zoom. -->
64+
<controls:DetectionOverlay Detections="{Binding Detections}"
65+
ShowBoxes="{Binding AnalyticsEnabled}"
66+
SourceAspect="{Binding SourceAspect}"
67+
RenderTransformOrigin="0.5,0.5"
68+
IsHitTestVisible="False">
69+
<controls:DetectionOverlay.RenderTransform>
70+
<ScaleTransform ScaleX="{Binding ZoomLevel}" ScaleY="{Binding ZoomLevel}" />
71+
</controls:DetectionOverlay.RenderTransform>
72+
</controls:DetectionOverlay>
73+
6074
<!-- Top-left LIVE badge -->
6175
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12"
6276
Background="{StaticResource DangerBrush}"

src/OpenIPC.Viewer.Core/Analytics/IAnalyticsEngine.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,10 @@ void Attach(CameraId cameraId, IObservable<VideoFrame> frames,
3131
Func<AnalyticsSettings> settings, Func<bool> isActive);
3232

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

0 commit comments

Comments
 (0)