From 6d0db0c9e8a99b1c9ec9b76353138aa397b96b5b Mon Sep 17 00:00:00 2001 From: keyldev Date: Fri, 25 Sep 2026 20:46:14 +0300 Subject: [PATCH 1/2] feat(live): digital zoom into a selected area of the live view - DigitalZoomViewport (Core): anchored zoom, pan and zoom-to-rect, clamped to the letterboxed picture, up to 8x - ZoomPanHost: wheel/pinch zoom at the cursor, drag to pan, Shift+drag or the zoom-to-area toggle draws a marquee, double-tap toggles - Bicubic filtering once zoomed past 1:1; minimap + zoom chip while zoomed - Detection boxes follow the zoom at constant stroke; old centre-only VM zoom removed --- .../Controls/DetectionOverlay.cs | 25 +- .../Controls/RtspVideoView.axaml.cs | 8 + .../Controls/ZoomMinimap.cs | 155 +++++++ .../Controls/ZoomPanHost.cs | 382 ++++++++++++++++++ src/OpenIPC.Viewer.App/Services/Localizer.cs | 4 + src/OpenIPC.Viewer.App/Themes/Theme.axaml | 1 + .../ViewModels/SingleCameraPageViewModel.cs | 31 +- .../Views/Pages/SingleCameraPage.axaml | 88 +++- .../Views/Pages/SingleCameraPage.axaml.cs | 46 +-- .../Video/DigitalZoomViewport.cs | 184 +++++++++ .../Video/DigitalZoomViewportTests.cs | 154 +++++++ 11 files changed, 1001 insertions(+), 77 deletions(-) create mode 100644 src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs create mode 100644 src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs create mode 100644 src/OpenIPC.Viewer.Core/Video/DigitalZoomViewport.cs create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Video/DigitalZoomViewportTests.cs diff --git a/src/OpenIPC.Viewer.App/Controls/DetectionOverlay.cs b/src/OpenIPC.Viewer.App/Controls/DetectionOverlay.cs index e165186..c9896cf 100644 --- a/src/OpenIPC.Viewer.App/Controls/DetectionOverlay.cs +++ b/src/OpenIPC.Viewer.App/Controls/DetectionOverlay.cs @@ -25,6 +25,11 @@ public sealed class DetectionOverlay : Control public static readonly StyledProperty SourceAspectProperty = AvaloniaProperty.Register(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 ViewTransformProperty = + AvaloniaProperty.Register(nameof(ViewTransform), Matrix.Identity); + // Distinct hues per class id so different objects read apart at a glance. private static readonly Color[] Palette = { @@ -36,7 +41,8 @@ public sealed class DetectionOverlay : Control static DetectionOverlay() { - AffectsRender(DetectionsProperty, ShowBoxesProperty, SourceAspectProperty); + AffectsRender(DetectionsProperty, ShowBoxesProperty, SourceAspectProperty, + ViewTransformProperty); } public IReadOnlyList? Detections @@ -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); @@ -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}"; diff --git a/src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs b/src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs index 799cc94..507ed6a 100644 --- a/src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs +++ b/src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs @@ -25,6 +25,13 @@ public IVideoSession? Session 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(); @@ -59,6 +66,7 @@ private void OnFrame(VideoFrame frame) Marshal.Copy(frame.Bgra, 0, locked.Address, frame.Stride * frame.Height); } _image.InvalidateVisual(); + FrameRendered?.Invoke(this, EventArgs.Empty); }); } diff --git a/src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs b/src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs new file mode 100644 index 0000000..afeedfd --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs @@ -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 HostProperty = + AvaloniaProperty.Register(nameof(Host)); + + public static readonly StyledProperty VideoProperty = + AvaloniaProperty.Register(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((m, e) => m.OnHostChanged( + e.OldValue as ZoomPanHost, e.NewValue as ZoomPanHost)); + VideoProperty.Changed.AddClassHandler((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; + + 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; + } + + 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); + } +} diff --git a/src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs b/src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs new file mode 100644 index 0000000..72c4bab --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using OpenIPC.Viewer.Core.Video; + +namespace OpenIPC.Viewer.App.Controls; + +// Digital zoom + pan for the live video. Children fill the host and share one +// render transform built from DigitalZoomViewport; the host owns the input: +// - wheel (or Ctrl+wheel when WheelZoomRequiresModifier) zooms at the cursor +// - pinch zooms around the pinch midpoint +// - drag pans while zoomed +// - Shift+drag, or any drag while IsRegionSelectArmed, draws a marquee and +// zooms to fit it +// - double-tap zooms in at the tap, or back out when already zoomed +// Overlays that must not scale with the picture (detection boxes, badges) live +// outside the host and map through ViewTransform themselves. +public sealed class ZoomPanHost : Panel +{ + public static readonly StyledProperty ContentAspectProperty = + AvaloniaProperty.Register(nameof(ContentAspect)); + + // Decoded frame width; lets the host tell upscaling (zoomed past 1:1) from + // downscaling and pick the bitmap filter accordingly. + public static readonly StyledProperty SourcePixelWidthProperty = + AvaloniaProperty.Register(nameof(SourcePixelWidth)); + + public static readonly StyledProperty MaxZoomProperty = + AvaloniaProperty.Register(nameof(MaxZoom), DigitalZoomViewport.DefaultMaxZoom); + + public static readonly StyledProperty IsRegionSelectArmedProperty = + AvaloniaProperty.Register(nameof(IsRegionSelectArmed), + defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + // On a scrolling page a bare wheel should scroll, not zoom — set this there. + public static readonly StyledProperty WheelZoomRequiresModifierProperty = + AvaloniaProperty.Register(nameof(WheelZoomRequiresModifier)); + + public static readonly DirectProperty ZoomProperty = + AvaloniaProperty.RegisterDirect(nameof(Zoom), o => o.Zoom); + + public static readonly DirectProperty IsZoomedProperty = + AvaloniaProperty.RegisterDirect(nameof(IsZoomed), o => o.IsZoomed); + + public static readonly DirectProperty ViewTransformProperty = + AvaloniaProperty.RegisterDirect(nameof(ViewTransform), o => o.ViewTransform); + + // Double-tap target zoom, and the step for the +/− buttons. + private const double DoubleTapZoom = 2.5; + private const double ButtonZoomFactor = 1.5; + private const double WheelZoomBase = 1.2; + + // A drag shorter than this is a tap, not a pan or a selection. + private const double DragThresholdPx = 6; + private const double MinSelectionPx = 12; + + private enum DragMode { None, Pending, Pan, Select } + + private readonly DigitalZoomViewport _viewport = new(); + private readonly MatrixTransform _transform = new(); + private readonly Border _marquee; + private readonly HashSet _touches = new(); + private Rect _marqueeRect; + private Cursor? _crossCursor; + private Cursor? _panCursor; + + private double _zoom = DigitalZoomViewport.MinZoom; + private bool _isZoomed; + private Matrix _viewTransform = Matrix.Identity; + + private DragMode _dragMode; + private bool _dragSelects; + private Point _dragStart; + private Point _dragLast; + private double _lastPinchScale = 1.0; + + public ZoomPanHost() + { + ClipToBounds = true; + // A transparent background makes the whole area hit-testable, so a pan + // can start on the letterbox bars too. + Background = Brushes.Transparent; + + _marquee = new Border + { + BorderThickness = new Thickness(1.5), + BorderBrush = new SolidColorBrush(Color.Parse("#5B6CD9")), + Background = new SolidColorBrush(Color.Parse("#335B6CD9")), + IsHitTestVisible = false, + IsVisible = false, + ZIndex = int.MaxValue, + }; + VisualChildren.Add(_marquee); + + GestureRecognizers.Add(new PinchGestureRecognizer()); + Pinch += OnPinch; + PinchEnded += OnPinchEnded; + DoubleTapped += OnDoubleTapped; + } + + static ZoomPanHost() + { + ContentAspectProperty.Changed.AddClassHandler((h, _) => + { + h._viewport.SetContentAspect(h.ContentAspect); + h.OnViewportChanged(); + }); + MaxZoomProperty.Changed.AddClassHandler((h, _) => + { + h._viewport.MaxZoom = h.MaxZoom; + h.OnViewportChanged(); + }); + SourcePixelWidthProperty.Changed.AddClassHandler((h, _) => h.UpdateInterpolation()); + IsRegionSelectArmedProperty.Changed.AddClassHandler((h, _) => h.UpdateCursor()); + } + + public double ContentAspect { get => GetValue(ContentAspectProperty); set => SetValue(ContentAspectProperty, value); } + public int SourcePixelWidth { get => GetValue(SourcePixelWidthProperty); set => SetValue(SourcePixelWidthProperty, value); } + public double MaxZoom { get => GetValue(MaxZoomProperty); set => SetValue(MaxZoomProperty, value); } + public bool IsRegionSelectArmed { get => GetValue(IsRegionSelectArmedProperty); set => SetValue(IsRegionSelectArmedProperty, value); } + public bool WheelZoomRequiresModifier { get => GetValue(WheelZoomRequiresModifierProperty); set => SetValue(WheelZoomRequiresModifierProperty, value); } + + public double Zoom { get => _zoom; private set => SetAndRaise(ZoomProperty, ref _zoom, value); } + public bool IsZoomed { get => _isZoomed; private set => SetAndRaise(IsZoomedProperty, ref _isZoomed, value); } + + // view → screen mapping for overlays drawn outside the host. + public Matrix ViewTransform { get => _viewTransform; private set => SetAndRaise(ViewTransformProperty, ref _viewTransform, value); } + + // Normalized part of the picture on screen (minimap). + public (double X, double Y, double Width, double Height) VisibleContent => _viewport.VisibleContent; + + // True while a drag is panning or drawing a marquee — the page uses it to + // keep its swipe-to-next-camera gesture out of the way. + public bool IsDragging => _dragMode is DragMode.Pan or DragMode.Select; + + public event EventHandler? ViewportChanged; + + public void Reset() + { + _viewport.Reset(); + OnViewportChanged(); + } + + public void ZoomIn() => ZoomAroundCenter(ButtonZoomFactor); + + public void ZoomOut() => ZoomAroundCenter(1 / ButtonZoomFactor); + + public void CenterOn(double normalizedX, double normalizedY) + { + _viewport.CenterOn(normalizedX, normalizedY); + OnViewportChanged(); + } + + private void ZoomAroundCenter(double factor) + { + _viewport.ZoomAt(Bounds.Width / 2, Bounds.Height / 2, factor); + OnViewportChanged(); + } + + protected override Size ArrangeOverride(Size finalSize) + { + foreach (var child in Children) + { + child.RenderTransformOrigin = RelativePoint.TopLeft; + child.RenderTransform = _transform; + child.Arrange(new Rect(finalSize)); + } + // Not in Children, so Panel never lays it out — place it here. + _marquee.Measure(_marqueeRect.Size); + _marquee.Arrange(_marqueeRect); + + if (finalSize.Width != _viewport.ViewWidth || finalSize.Height != _viewport.ViewHeight) + { + _viewport.SetViewSize(finalSize.Width, finalSize.Height); + OnViewportChanged(); + } + return finalSize; + } + + private void OnViewportChanged() + { + var z = _viewport.Zoom; + var m = new Matrix(z, 0, 0, z, -_viewport.OffsetX * z, -_viewport.OffsetY * z); + _transform.Matrix = m; + ViewTransform = m; + Zoom = z; + IsZoomed = _viewport.IsZoomed; + UpdateInterpolation(); + UpdateCursor(); + ViewportChanged?.Invoke(this, EventArgs.Empty); + } + + // Past 1:1 the picture is being upscaled: switch to the high-quality + // (bicubic) filter so zoomed detail stays smooth instead of blocky. Below + // 1:1 keep the renderer default — high quality there means rebuilding + // mipmaps of a 4K/8K frame on every frame, for no visible gain. + private void UpdateInterpolation() + { + var sourceWidth = SourcePixelWidth; + var contentWidth = _viewport.ContentRect.Width; + var scaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1.0; + var upscaling = sourceWidth > 0 && contentWidth > 0 + && _viewport.Zoom * contentWidth * scaling > sourceWidth * 1.01; + + var mode = upscaling ? BitmapInterpolationMode.HighQuality : BitmapInterpolationMode.Unspecified; + if (Avalonia.Media.RenderOptions.GetBitmapInterpolationMode(this) != mode) + Avalonia.Media.RenderOptions.SetBitmapInterpolationMode(this, mode); + } + + private void UpdateCursor() + { + Cursor = IsRegionSelectArmed ? _crossCursor ??= new Cursor(StandardCursorType.Cross) + : IsZoomed ? _panCursor ??= new Cursor(StandardCursorType.SizeAll) + : null; + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + var ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0; + if (WheelZoomRequiresModifier && !ctrl && !IsZoomed) return; + + var delta = e.Delta.Y != 0 ? e.Delta.Y : e.Delta.X; + if (delta == 0) return; + var p = e.GetPosition(this); + _viewport.ZoomAt(p.X, p.Y, Math.Pow(WheelZoomBase, delta)); + OnViewportChanged(); + e.Handled = true; + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (e.Pointer.Type == PointerType.Touch) _touches.Add(e.Pointer); + + // A second finger means a pinch — drop whatever the first one started. + if (_touches.Count > 1) + { + CancelDrag(); + return; + } + + var props = e.GetCurrentPoint(this).Properties; + if (!props.IsLeftButtonPressed) return; + + _dragSelects = IsRegionSelectArmed || (e.KeyModifiers & KeyModifiers.Shift) != 0; + if (!_dragSelects && !IsZoomed) return; + + // Capture only once the pointer really moves: capturing on press would + // retarget the release and break tap / double-tap recognition. + _dragMode = DragMode.Pending; + _dragStart = _dragLast = e.GetPosition(this); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_dragMode == DragMode.None || _touches.Count > 1) return; + + var p = e.GetPosition(this); + if (_dragMode == DragMode.Pending) + { + if (Math.Abs(p.X - _dragStart.X) < DragThresholdPx && Math.Abs(p.Y - _dragStart.Y) < DragThresholdPx) + return; + _dragMode = _dragSelects ? DragMode.Select : DragMode.Pan; + e.Pointer.Capture(this); + } + + if (_dragMode == DragMode.Pan) + { + _viewport.Pan(p.X - _dragLast.X, p.Y - _dragLast.Y); + OnViewportChanged(); + } + else + { + _marqueeRect = SelectionRect(p); + _marquee.IsVisible = true; + InvalidateArrange(); + } + _dragLast = p; + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + _touches.Remove(e.Pointer); + + if (_dragMode == DragMode.Select) + { + var r = SelectionRect(e.GetPosition(this)); + if (r.Width >= MinSelectionPx && r.Height >= MinSelectionPx) + { + _viewport.ZoomToRect(r.X, r.Y, r.Width, r.Height); + OnViewportChanged(); + // One selection per arming, like a camera app's "zoom to area". + IsRegionSelectArmed = false; + } + e.Handled = true; + } + else if (_dragMode == DragMode.Pan) + { + e.Handled = true; + } + + if (_dragMode != DragMode.None) + { + CancelDrag(); + if (ReferenceEquals(e.Pointer.Captured, this)) e.Pointer.Capture(null); + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + _touches.Remove(e.Pointer); + CancelDrag(); + } + + private void CancelDrag() + { + _dragMode = DragMode.None; + if (_marquee.IsVisible) + { + _marquee.IsVisible = false; + InvalidateArrange(); + } + } + + private Rect SelectionRect(Point p) + { + // Clip to the host so a drag that runs off the edge still selects up to it. + var x0 = Math.Clamp(Math.Min(_dragStart.X, p.X), 0, Bounds.Width); + var y0 = Math.Clamp(Math.Min(_dragStart.Y, p.Y), 0, Bounds.Height); + var x1 = Math.Clamp(Math.Max(_dragStart.X, p.X), 0, Bounds.Width); + var y1 = Math.Clamp(Math.Max(_dragStart.Y, p.Y), 0, Bounds.Height); + return new Rect(x0, y0, x1 - x0, y1 - y0); + } + + private void OnDoubleTapped(object? sender, TappedEventArgs e) + { + if (IsRegionSelectArmed) return; + if (IsZoomed) + { + _viewport.Reset(); + } + else + { + var p = e.GetPosition(this); + _viewport.SetZoomAt(p.X, p.Y, DoubleTapZoom); + } + OnViewportChanged(); + e.Handled = true; + } + + // PinchEventArgs.Scale is cumulative since the gesture began and + // ScaleOrigin is the starting midpoint in this control's coordinates — + // apply the per-event ratio around that point. + private void OnPinch(object? sender, PinchEventArgs e) + { + CancelDrag(); + var factor = _lastPinchScale > 0 ? e.Scale / _lastPinchScale : 1.0; + _lastPinchScale = e.Scale; + if (factor > 0 && Math.Abs(factor - 1.0) > 0.0001) + { + _viewport.ZoomAt(e.ScaleOrigin.X, e.ScaleOrigin.Y, factor); + OnViewportChanged(); + } + e.Handled = true; + } + + private void OnPinchEnded(object? sender, PinchEndedEventArgs e) + { + _lastPinchScale = 1.0; + _touches.Clear(); + e.Handled = true; + } +} diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 80fbf65..787533e 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -499,6 +499,8 @@ private static LangCode DetectSystem() ["Viewer.ZoomIn"] = "Zoom in", ["Viewer.ZoomOut"] = "Zoom out", + ["Zoom.Area"] = "Zoom to area — drag over the picture (or Shift+drag)", + ["Zoom.Reset"] = "Reset zoom", ["Viewer.Fit"] = "Fit", ["Viewer.RotateLeft"] = "Rotate left", ["Viewer.RotateRight"] = "Rotate right", @@ -993,6 +995,8 @@ private static LangCode DetectSystem() ["Viewer.ZoomIn"] = "Увеличить", ["Viewer.ZoomOut"] = "Уменьшить", + ["Zoom.Area"] = "Увеличить область — выделите её на картинке (или Shift+перетаскивание)", + ["Zoom.Reset"] = "Сбросить зум", ["Viewer.Fit"] = "Вписать", ["Viewer.RotateLeft"] = "Повернуть влево", ["Viewer.RotateRight"] = "Повернуть вправо", diff --git a/src/OpenIPC.Viewer.App/Themes/Theme.axaml b/src/OpenIPC.Viewer.App/Themes/Theme.axaml index 96eb7e7..ff777bf 100644 --- a/src/OpenIPC.Viewer.App/Themes/Theme.axaml +++ b/src/OpenIPC.Viewer.App/Themes/Theme.axaml @@ -56,6 +56,7 @@ intended outline. --> M21,21 L16.66,16.66 M11,3 A8,8 0 1 0 11,19 A8,8 0 1 0 11,3 Z + M3,7 V5 a2,2 0 0 1 2,-2 h2 M17,3 h2 a2,2 0 0 1 2,2 v2 M21,17 v2 a2,2 0 0 1 -2,2 h-2 M7,21 H5 a2,2 0 0 1 -2,-2 v-2 M9,12 A3,3 0 1 0 15,12 A3,3 0 1 0 9,12 Z M16,16 L14.1,14.1 M4,3 H7 A1,1 0 0 1 8,4 V7 A1,1 0 0 1 7,8 H4 A1,1 0 0 1 3,7 V4 A1,1 0 0 1 4,3 Z M17,3 H20 A1,1 0 0 1 21,4 V7 A1,1 0 0 1 20,8 H17 A1,1 0 0 1 16,7 V4 A1,1 0 0 1 17,3 Z M4,16 H7 A1,1 0 0 1 8,17 V20 A1,1 0 0 1 7,21 H4 A1,1 0 0 1 3,20 V17 A1,1 0 0 1 4,16 Z M21,16 h-3 a2,2 0 0 0 -2,2 v3 M21,21 v0.01 M12,7 v3 a2,2 0 0 1 -2,2 H7 M3,12 h0.01 M12,3 h0.01 M12,16 v0.01 M16,12 h1 M21,12 v0.01 M12,21 v-1 M18,6 L6,18 M6,6 L18,18 M13,21 h8 M15,5 L19,9 M21.174,6.812 a1,1 0 0 0 -3.986,-3.987 L3.842,16.174 a2,2 0 0 0 -0.5,0.83 l-1.321,4.352 a0.5,0.5 0 0 0 0.623,0.622 l4.353,-1.32 a2,2 0 0 0 0.83,-0.497 z diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index 477e1a2..0bc72cf 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -224,17 +224,11 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis [ObservableProperty] private bool _isRecording; [ObservableProperty] private string _recordingElapsed = "REC 00:00:00"; - // 9e — touch gestures. ZoomLevel drives the ScaleTransform on the video - // surface (digital zoom only, 1.0..MaxZoom). IsPtzOverlayVisible is a - // toggle hidden behind long-press on narrow viewports — the joystick - // takes a chunk of screen real estate that's fine on desktop but should - // stay out of the way on a phone until the user explicitly asks. - public const double MinZoom = 1.0; - public const double MaxZoom = 4.0; - public const double ZoomStep = 0.25; - - [ObservableProperty] private double _zoomLevel = MinZoom; - + // 9e — touch gestures. Digital zoom is view-only state owned by the page's + // ZoomPanHost. IsPtzOverlayVisible is a toggle hidden behind long-press on + // narrow viewports — the joystick takes a chunk of screen real estate + // that's fine on desktop but should stay out of the way on a phone until + // the user explicitly asks. [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsPtzPanelVisible))] private bool _isPtzOverlayVisible = true; @@ -690,21 +684,6 @@ private async Task SetHomeAsync() } } - [RelayCommand] - private void ResetZoom() => ZoomLevel = MinZoom; - - public void ApplyZoomDelta(double factor) - { - var next = Math.Clamp(ZoomLevel * factor, MinZoom, MaxZoom); - ZoomLevel = next; - } - - public void StepZoom(int steps) - { - var next = Math.Clamp(ZoomLevel + steps * ZoomStep, MinZoom, MaxZoom); - ZoomLevel = next; - } - public async Task NavigateRelativeAsync(int offset, CancellationToken ct) { if (offset == 0) return; diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml index 280ef14..9d9b042 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:OpenIPC.Viewer.App.ViewModels" xmlns:controls="using:OpenIPC.Viewer.App.Controls" - xmlns:input="using:Avalonia.Input" xmlns:svc="using:OpenIPC.Viewer.App.Services" x:Class="OpenIPC.Viewer.App.Views.Pages.SingleCameraPage" x:DataType="vm:SingleCameraPageViewModel"> @@ -29,6 +28,22 @@ + + + + ZoomPanHost owns digital zoom: wheel / pinch / double-tap zoom, drag + pans, Shift+drag (or the zoom-to-area toggle) zooms to a marquee. + Code-behind adds Holding (PTZ toggle) and swipe-to-next-camera on this + grid, skipping them while the host is using the pointer. --> - - - - - - - - + + + + map onto the mainstream picture. Follows the digital zoom through + ViewTransform so boxes stay glued to objects at constant stroke. --> - - - - + ViewTransform="{Binding #ZoomHost.ViewTransform}" + IsHitTestVisible="False" /> + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.App/Controls/ZoomOverlay.axaml.cs b/src/OpenIPC.Viewer.App/Controls/ZoomOverlay.axaml.cs new file mode 100644 index 0000000..ab53c15 --- /dev/null +++ b/src/OpenIPC.Viewer.App/Controls/ZoomOverlay.axaml.cs @@ -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 HostProperty = + AvaloniaProperty.Register(nameof(Host)); + + public static readonly StyledProperty VideoProperty = + AvaloniaProperty.Register(nameof(Video)); + + public ZoomOverlay() + { + InitializeComponent(); + } + + static ZoomOverlay() + { + HostProperty.Changed.AddClassHandler((o, e) => + o.OnHostChanged(e.OldValue as ZoomPanHost, e.NewValue as ZoomPanHost)); + VideoProperty.Changed.AddClassHandler((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(); +} diff --git a/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml index fa2e80e..afbd1c5 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml @@ -69,7 +69,26 @@ - + + + + + + + + + + - - - - - - - - - - - + + ZoomHost.ZoomIn(); - - private void OnZoomOutClick(object? sender, RoutedEventArgs e) => ZoomHost.ZoomOut(); - - private void OnZoomResetClick(object? sender, RoutedEventArgs e) => ZoomHost.Reset(); - private void OnVideoPointerPressed(object? sender, PointerPressedEventArgs e) { var props = e.GetCurrentPoint(VideoArea).Properties;