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
25 changes: 19 additions & 6 deletions src/OpenIPC.Viewer.App/Controls/DetectionOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public sealed class DetectionOverlay : Control
public static readonly StyledProperty<double> SourceAspectProperty =
AvaloniaProperty.Register<DetectionOverlay, double>(nameof(SourceAspect));

// Digital zoom (ZoomPanHost.ViewTransform) applied to the box geometry only,
// so zoomed boxes follow the picture while strokes and labels keep their size.
public static readonly StyledProperty<Matrix> ViewTransformProperty =
AvaloniaProperty.Register<DetectionOverlay, Matrix>(nameof(ViewTransform), Matrix.Identity);

// Distinct hues per class id so different objects read apart at a glance.
private static readonly Color[] Palette =
{
Expand All @@ -36,7 +41,8 @@ public sealed class DetectionOverlay : Control

static DetectionOverlay()
{
AffectsRender<DetectionOverlay>(DetectionsProperty, ShowBoxesProperty, SourceAspectProperty);
AffectsRender<DetectionOverlay>(DetectionsProperty, ShowBoxesProperty, SourceAspectProperty,
ViewTransformProperty);
}

public IReadOnlyList<Detection>? Detections
Expand All @@ -57,6 +63,12 @@ public double SourceAspect
set => SetValue(SourceAspectProperty, value);
}

public Matrix ViewTransform
{
get => GetValue(ViewTransformProperty);
set => SetValue(ViewTransformProperty, value);
}

public override void Render(DrawingContext context)
{
base.Render(context);
Expand All @@ -76,11 +88,12 @@ public override void Render(DrawingContext context)
var color = Palette[((d.ClassId % Palette.Length) + Palette.Length) % Palette.Length];
var pen = new Pen(new SolidColorBrush(color), 2);

var x = contentX + d.X * contentW;
var y = contentY + d.Y * contentH;
var bw = d.Width * contentW;
var bh = d.Height * contentH;
var box = new Rect(x, y, bw, bh);
var box = new Rect(
contentX + d.X * contentW, contentY + d.Y * contentH,
d.Width * contentW, d.Height * contentH).TransformToAABB(ViewTransform);
if (!box.Intersects(new Rect(0, 0, w, h))) continue; // zoomed off screen
var x = box.X;
var y = box.Y;
context.DrawRectangle(null, pen, box);

var label = $"{d.ClassName} {d.Confidence:0.00}";
Expand Down
24 changes: 24 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,31 @@ public IVideoSession? Session
set => SetValue(SessionProperty, value);
}

// Decoded frame size, 0 until the first frame. Lets zoom hosts fit the
// letterboxed picture without the page's VM having to know it.
public static readonly DirectProperty<RtspVideoView, int> FrameWidthProperty =
AvaloniaProperty.RegisterDirect<RtspVideoView, int>(nameof(FrameWidth), o => o.FrameWidth);

public static readonly DirectProperty<RtspVideoView, double> FrameAspectProperty =
AvaloniaProperty.RegisterDirect<RtspVideoView, double>(nameof(FrameAspect), o => o.FrameAspect);

private int _frameWidth;
private double _frameAspect;

public int FrameWidth { get => _frameWidth; private set => SetAndRaise(FrameWidthProperty, ref _frameWidth, value); }
public double FrameAspect { get => _frameAspect; private set => SetAndRaise(FrameAspectProperty, ref _frameAspect, value); }

private readonly Image _image;
private WriteableBitmap? _bitmap;
private IDisposable? _frameSub;

// The bitmap the latest frame was copied into, for secondary views of the
// same picture (the digital-zoom minimap). Read it on the UI thread only.
public Bitmap? CurrentFrame => _bitmap;

// Raised on the UI thread after each frame lands in CurrentFrame.
public event EventHandler? FrameRendered;

public RtspVideoView()
{
InitializeComponent();
Expand Down Expand Up @@ -59,6 +80,7 @@ private void OnFrame(VideoFrame frame)
Marshal.Copy(frame.Bgra, 0, locked.Address, frame.Stride * frame.Height);
}
_image.InvalidateVisual();
FrameRendered?.Invoke(this, EventArgs.Empty);
});
}

Expand All @@ -73,6 +95,8 @@ private void EnsureBitmap(int width, int height)
PixelFormat.Bgra8888,
AlphaFormat.Premul);
_image.Source = _bitmap;
FrameWidth = width;
FrameAspect = height > 0 ? (double)width / height : 0;
}

protected override void OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs e)
Expand Down
155 changes: 155 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;

namespace OpenIPC.Viewer.App.Controls;

// Thumbnail of the whole frame with the zoomed-in part outlined, so the user
// knows where they are at 6×. Press or drag on it to move the view there.
// Width follows the host (a fifth of it, within limits) so it stays small on a
// phone preview and readable on a desktop window.
public sealed class ZoomMinimap : Control
{
public static readonly StyledProperty<ZoomPanHost?> HostProperty =
AvaloniaProperty.Register<ZoomMinimap, ZoomPanHost?>(nameof(Host));

public static readonly StyledProperty<RtspVideoView?> VideoProperty =
AvaloniaProperty.Register<ZoomMinimap, RtspVideoView?>(nameof(Video));

private const double MinThumbWidth = 96;
private const double MaxThumbWidth = 200;
private const double HostFraction = 0.2;

// The thumbnail repaints a few times a second, not per frame: downscaling
// a 4K frame at full frame rate for a 160px preview is wasted work.
private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(250);

private static readonly IBrush BackgroundBrush = new SolidColorBrush(Color.FromArgb(0xB0, 0, 0, 0));
private static readonly IBrush ShadeBrush = new SolidColorBrush(Color.FromArgb(0x70, 0, 0, 0));
private static readonly IPen FramePen = new Pen(new SolidColorBrush(Color.FromArgb(0x80, 0xFF, 0xFF, 0xFF)), 1);
private static readonly IPen ViewportPen = new Pen(Brushes.White, 1.5);

private DateTime _lastFrameRepaint;

static ZoomMinimap()
{
HostProperty.Changed.AddClassHandler<ZoomMinimap>((m, e) => m.OnHostChanged(
e.OldValue as ZoomPanHost, e.NewValue as ZoomPanHost));
VideoProperty.Changed.AddClassHandler<ZoomMinimap>((m, e) => m.OnVideoChanged(
e.OldValue as RtspVideoView, e.NewValue as RtspVideoView));
}

public ZoomPanHost? Host { get => GetValue(HostProperty); set => SetValue(HostProperty, value); }
public RtspVideoView? Video { get => GetValue(VideoProperty); set => SetValue(VideoProperty, value); }

private void OnHostChanged(ZoomPanHost? oldHost, ZoomPanHost? newHost)
{
if (oldHost is not null)
{
oldHost.ViewportChanged -= OnViewportChanged;
oldHost.PropertyChanged -= OnHostPropertyChanged;
}
if (newHost is not null)
{
newHost.ViewportChanged += OnViewportChanged;
newHost.PropertyChanged += OnHostPropertyChanged;
}
InvalidateMeasure();
InvalidateVisual();
}

private void OnVideoChanged(RtspVideoView? oldVideo, RtspVideoView? newVideo)
{
if (oldVideo is not null) oldVideo.FrameRendered -= OnFrameRendered;
if (newVideo is not null) newVideo.FrameRendered += OnFrameRendered;
InvalidateVisual();
}

private void OnViewportChanged(object? sender, EventArgs e) => InvalidateVisual();

private void OnHostPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == BoundsProperty || e.Property == ZoomPanHost.ContentAspectProperty)
InvalidateMeasure();
}

private void OnFrameRendered(object? sender, EventArgs e)
{
if (!IsEffectivelyVisible) return;
var now = DateTime.UtcNow;
if (now - _lastFrameRepaint < FrameInterval) return;
_lastFrameRepaint = now;
InvalidateVisual();
}

private double Aspect => Host is { ContentAspect: > 0 } h ? h.ContentAspect : 16.0 / 9.0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. The map distorts non-widescreen video 🐞 Bug ≡ Correctness

ZoomMinimap.Aspect falls back to 16:9 and rendering stretches CurrentFrame to the entire minimap
bounds whenever Host.ContentAspect is unavailable or stale. The live host receives this aspect
from telemetry, whose view-model value is explicitly zero until dimensions arrive, even though the
rendered video has already exposed its decoded frame aspect.
Agent Prompt
Issue description
The minimap uses a hard-coded 16:9 fallback when the host has no content aspect, then stretches the decoded bitmap to those bounds. A frame whose aspect is not 16:9 is therefore displayed with incorrect geometry while live telemetry is unavailable or stale.

Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs[79-109]
- src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs[24-36]

Recommended Fix
Use `Video.FrameAspect` as the fallback aspect when `Host.ContentAspect` is not positive, and invalidate minimap measurement when the decoded frame aspect changes. Render the frame into an aspect-preserving destination rectangle so the thumbnail and its viewport indicator use the same picture geometry.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


protected override Size MeasureOverride(Size availableSize)
{
var hostWidth = Host?.Bounds.Width ?? 0;
var w = Math.Clamp(hostWidth * HostFraction, MinThumbWidth, MaxThumbWidth);
if (!double.IsInfinity(availableSize.Width)) w = Math.Min(w, availableSize.Width);
return new Size(w, Math.Round(w / Aspect));
}

public override void Render(DrawingContext context)
{
var bounds = new Rect(Bounds.Size);
if (bounds.Width <= 0 || bounds.Height <= 0) return;

context.DrawRectangle(BackgroundBrush, null, bounds, 3, 3);

if (Video?.CurrentFrame is { } frame)
{
using (context.PushRenderOptions(new RenderOptions { BitmapInterpolationMode = BitmapInterpolationMode.MediumQuality }))
context.DrawImage(frame, new Rect(frame.Size), bounds);
}

if (Host is { } host)
{
var (x, y, w, h) = host.VisibleContent;
var view = new Rect(x * bounds.Width, y * bounds.Height, w * bounds.Width, h * bounds.Height);

// Dim what's off screen so the outlined window reads at a glance.
context.DrawRectangle(ShadeBrush, null, new Rect(0, 0, bounds.Width, view.Top));
context.DrawRectangle(ShadeBrush, null, new Rect(0, view.Bottom, bounds.Width, bounds.Height - view.Bottom));
context.DrawRectangle(ShadeBrush, null, new Rect(0, view.Top, view.Left, view.Height));
context.DrawRectangle(ShadeBrush, null, new Rect(view.Right, view.Top, bounds.Width - view.Right, view.Height));
context.DrawRectangle(null, ViewportPen, view);
}

context.DrawRectangle(null, FramePen, bounds, 3, 3);
}

protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
e.Pointer.Capture(this);
MoveViewTo(e.GetPosition(this));
e.Handled = true;
Comment on lines +127 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

4. Secondary clicks move the zoomed view 🐞 Bug ≡ Correctness

ZoomMinimap.OnPointerPressed captures and recenters the viewport for every pointer press without
checking whether the primary button is pressed. A right- or middle-click over the minimap
consequently changes the user's zoom location, while the main zoom host correctly gates drag
behavior on IsLeftButtonPressed.
Agent Prompt
Issue description
The minimap starts a move operation for every pointer press, including secondary and middle mouse buttons. Those gestures unexpectedly recenter the video and capture the pointer.

Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs[127-132]

Recommended Fix
Read `e.GetCurrentPoint(this).Properties` at the start of `OnPointerPressed` and return unless `IsLeftButtonPressed` is true. Only capture the pointer and call `MoveViewTo` after that check.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
if (!ReferenceEquals(e.Pointer.Captured, this)) return;
MoveViewTo(e.GetPosition(this));
e.Handled = true;
}

protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
e.Pointer.Capture(null);
e.Handled = true;
}

private void MoveViewTo(Point p)
{
if (Host is null || Bounds.Width <= 0 || Bounds.Height <= 0) return;
Host.CenterOn(p.X / Bounds.Width, p.Y / Bounds.Height);
}
}
47 changes: 47 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/ZoomOverlay.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:OpenIPC.Viewer.App.Controls"
xmlns:svc="using:OpenIPC.Viewer.App.Services"
x:Class="OpenIPC.Viewer.App.Controls.ZoomOverlay"
IsVisible="False">

<UserControl.Styles>
<!-- Compact chip buttons, quiet until hovered. -->
<Style Selector="Button.zoom-chip">
<Setter Property="Width" Value="28" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.zoom-chip:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#33FFFFFF" />
<Setter Property="TextElement.Foreground" Value="White" />
</Style>
</UserControl.Styles>

<!-- Minimap of the whole frame (press/drag to move there) and a
−/level/+/reset chip. Shown only while the host is zoomed in. -->
<StackPanel Spacing="6">
<controls:ZoomMinimap Name="Minimap" HorizontalAlignment="Left" />
<Border HorizontalAlignment="Left" Background="#B0000000" CornerRadius="3" Padding="2">
<StackPanel Orientation="Horizontal" Spacing="2">
<Button Classes="zoom-chip" Content="−" Click="OnZoomOutClick"
ToolTip.Tip="{Binding [Viewer.ZoomOut], Source={x:Static svc:Localizer.Instance}}" />
<TextBlock Name="ZoomLabel"
MinWidth="36" TextAlignment="Center" VerticalAlignment="Center"
FontFamily="{StaticResource FontMono}" FontSize="11" Foreground="White" />
<Button Classes="zoom-chip" Content="+" Click="OnZoomInClick"
ToolTip.Tip="{Binding [Viewer.ZoomIn], Source={x:Static svc:Localizer.Instance}}" />
<Button Classes="zoom-chip" Click="OnZoomResetClick"
ToolTip.Tip="{Binding [Zoom.Reset], Source={x:Static svc:Localizer.Instance}}">
<Path Classes="lucide" Data="{StaticResource IconClose}" Stroke="White" Width="12" Height="12" />
</Button>
</StackPanel>
</Border>
</StackPanel>
</UserControl>
55 changes: 55 additions & 0 deletions src/OpenIPC.Viewer.App/Controls/ZoomOverlay.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;

namespace OpenIPC.Viewer.App.Controls;

// Digital-zoom chrome shared by the live and recording pages: minimap plus a
// −/level/+/reset chip for a ZoomPanHost. Hides itself while not zoomed.
public sealed partial class ZoomOverlay : UserControl
{
public static readonly StyledProperty<ZoomPanHost?> HostProperty =
AvaloniaProperty.Register<ZoomOverlay, ZoomPanHost?>(nameof(Host));

public static readonly StyledProperty<RtspVideoView?> VideoProperty =
AvaloniaProperty.Register<ZoomOverlay, RtspVideoView?>(nameof(Video));

public ZoomOverlay()
{
InitializeComponent();
}

static ZoomOverlay()
{
HostProperty.Changed.AddClassHandler<ZoomOverlay>((o, e) =>
o.OnHostChanged(e.OldValue as ZoomPanHost, e.NewValue as ZoomPanHost));
VideoProperty.Changed.AddClassHandler<ZoomOverlay>((o, _) => o.Minimap.Video = o.Video);
}

public ZoomPanHost? Host { get => GetValue(HostProperty); set => SetValue(HostProperty, value); }
public RtspVideoView? Video { get => GetValue(VideoProperty); set => SetValue(VideoProperty, value); }

private void OnHostChanged(ZoomPanHost? oldHost, ZoomPanHost? newHost)
{
if (oldHost is not null) oldHost.ViewportChanged -= OnViewportChanged;
if (newHost is not null) newHost.ViewportChanged += OnViewportChanged;
Minimap.Host = newHost;
Refresh();
}

private void OnViewportChanged(object? sender, EventArgs e) => Refresh();

private void Refresh()
{
IsVisible = Host?.IsZoomed ?? false;
ZoomLabel.Text = string.Format(CultureInfo.InvariantCulture, "{0:0.0}×", Host?.Zoom ?? 1.0);
}

private void OnZoomInClick(object? sender, RoutedEventArgs e) => Host?.ZoomIn();

private void OnZoomOutClick(object? sender, RoutedEventArgs e) => Host?.ZoomOut();

private void OnZoomResetClick(object? sender, RoutedEventArgs e) => Host?.Reset();
}
Loading
Loading