Skip to content

feat(zoom):digital zoom - #74

Merged
keyldev merged 2 commits into
mainfrom
feat/digital-zoom
Sep 25, 2026
Merged

keyldev merged 2 commits into
mainfrom
feat/digital-zoom

Conversation

@keyldev

@keyldev keyldev commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

#73

  • 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
  • Same ZoomPanHost as the live view, plus a zoom-to-area toggle on the video
  • RtspVideoView exposes FrameWidth/FrameAspect so hosts fit the picture without VM help
  • Minimap + level chip moved into a shared ZoomOverlay control

Related

Type

  • Bug fix
  • Feature
  • Refactor / cleanup
  • Docs / CI
  • Other:

Checklist

  • Builds with 0 warnings (TreatWarningsAsErrors=true).
  • Tests pass (dotnet test); new Core logic has unit tests.
  • No layering violation — App references Core only (Infrastructure / Video / Devices wired via DI in a head).
  • Scope stays within one phase (didn't pull work from a later phase's "Не входит").
  • README / docs updated if public commands, options, or setup changed.

Platforms tested

  • Windows
  • Linux
  • macOS
  • Android
  • iOS
  • CI build only

Screenshots / notes

- 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
- Same ZoomPanHost as the live view, plus a zoom-to-area toggle on the video
- RtspVideoView exposes FrameWidth/FrameAspect so hosts fit the picture without VM help
- Minimap + level chip moved into a shared ZoomOverlay control
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add shared digital zoom to live and recorded video

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Adds anchored zoom, panning, area selection, and gesture controls up to 8×.
• Shares zoom controls and minimap across live and recorded video views.
• Keeps detection overlays aligned and verifies viewport mathematics with unit tests.
Diagram

graph TD
P["Video Pages"] --> H["Zoom Pan Host"] --> V["Zoom Viewport"]
H --> R["Video Frame"]
H --> D["Detection Overlay"]
H --> O["Zoom Overlay"]
R --> O
Loading
High-Level Assessment

The selected architecture is appropriate: framework-independent viewport mathematics remains unit-testable in Core, while one reusable Avalonia host centralizes gesture arbitration and rendering for both pages. Retaining page-specific ViewModel zoom state or duplicating controls would increase coupling and create inconsistent behavior, so those approaches were reasonably dismissed.

Files changed (14) +1099 / -80

Enhancement (11) +922 / -28
DetectionOverlay.csTransform detection geometry with the zoomed video +19/-6

Transform detection geometry with the zoomed video

• Adds a view-transform property and applies it to detection rectangles so boxes remain attached to zoomed content. Off-screen boxes are skipped while strokes and labels retain constant display size.

src/OpenIPC.Viewer.App/Controls/DetectionOverlay.cs

RtspVideoView.axaml.csExpose decoded frame metadata and render updates +24/-0

Expose decoded frame metadata and render updates

• Publishes frame width, aspect ratio, and the current bitmap for zoom hosts and minimaps. Raises a UI-thread event whenever a decoded frame has been rendered.

src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs

ZoomMinimap.csAdd interactive zoom minimap navigation +155/-0

Add interactive zoom minimap navigation

• Introduces a responsive frame thumbnail that outlines and shades the visible viewport. Users can press or drag within it to recenter the zoomed video, with thumbnail repainting throttled to four times per second.

src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs

ZoomOverlay.axamlDefine shared minimap and zoom controls +47/-0

Define shared minimap and zoom controls

• Adds shared zoom chrome containing the minimap and compact zoom-out, level, zoom-in, and reset controls.

src/OpenIPC.Viewer.App/Controls/ZoomOverlay.axaml

ZoomOverlay.axaml.csConnect shared zoom chrome to its host +55/-0

Connect shared zoom chrome to its host

• Binds the overlay to a zoom host and video source, refreshes the displayed level, and hides controls at the default zoom. Routes control actions to zoom and reset operations.

src/OpenIPC.Viewer.App/Controls/ZoomOverlay.axaml.cs

ZoomPanHost.csAdd reusable gesture-driven digital zoom host +382/-0

Add reusable gesture-driven digital zoom host

• Introduces a shared host for anchored wheel and pinch zoom, drag panning, double-tap toggling, and marquee-based area zoom. It exposes viewport state, clamps interactions through Core mathematics, coordinates gesture conflicts, and enables bicubic filtering when frames are upscaled.

src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs

Localizer.csLocalize digital zoom actions +4/-0

Localize digital zoom actions

• Adds English and Russian strings for zoom-to-area guidance and resetting zoom.

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

Theme.axamlAdd zoom-to-area toolbar icon +1/-0

Add zoom-to-area toolbar icon

• Registers a scan-and-search geometry used by the new area-selection toggles.

src/OpenIPC.Viewer.App/Themes/Theme.axaml

RecordingPlayerPage.axamlEnable digital zoom in the recording player +20/-1

Enable digital zoom in the recording player

• Wraps recorded video in the shared zoom host and binds decoded frame dimensions. Adds the minimap controls and a zoom-to-area toggle.

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

SingleCameraPage.axamlIntegrate shared zoom into the live camera page +31/-21

Integrate shared zoom into the live camera page

• Replaces the ViewModel-driven scale transform with the shared zoom host. Connects detection geometry, zoom chrome, source metadata, and the area-selection toolbar toggle to the host.

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

DigitalZoomViewport.csImplement clamped digital zoom viewport mathematics +184/-0

Implement clamped digital zoom viewport mathematics

• Adds framework-independent calculations for anchored zoom, panning, rectangle fitting, minimap recentering, coordinate conversion, and normalized visible regions. Zoom is constrained from 1× to 8× and clamped to the letterboxed picture.

src/OpenIPC.Viewer.Core/Video/DigitalZoomViewport.cs

Refactor (2) +23 / -52
SingleCameraPageViewModel.csRemove page ViewModel zoom state +5/-26

Remove page ViewModel zoom state

• Removes center-only zoom properties and commands from the camera ViewModel. Digital zoom becomes view-owned state managed by the reusable zoom host.

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

SingleCameraPage.axaml.csCoordinate page gestures with the zoom host +18/-26

Coordinate page gestures with the zoom host

• Delegates zoom, pan, pinch, and double-tap handling to the shared host. Prevents PTZ holds and camera swipes from conflicting with zoom gestures, and requires a wheel modifier in scrolling mobile layouts.

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

Tests (1) +154 / -0
DigitalZoomViewportTests.csVerify digital zoom viewport behavior +154/-0

Verify digital zoom viewport behavior

• Covers anchor preservation, zoom limits, pan clamping, letterbox handling, area fitting, minimap coordinates, recentering, and resize stability.

tests/OpenIPC.Viewer.Core.Tests/Video/DigitalZoomViewportTests.cs

@keyldev
keyldev merged commit 788d572 into main Sep 25, 2026
5 checks passed
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Pinching zooms around the wrong point 🐞 Bug ≡ Correctness
Description
ZoomPanHost.OnPinch passes PinchEventArgs.ScaleOrigin.X and .Y directly to
DigitalZoomViewport.ZoomAt, although those values are normalized coordinates rather than host
DIPs. On a typical 1000×500 video, a pinch centered at the picture center is treated as (0.5, 0.5)
instead of (500, 250), so every pinch is anchored near the upper-left corner.
Code

src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[370]

+            _viewport.ZoomAt(e.ScaleOrigin.X, e.ScaleOrigin.Y, factor);
Evidence
The viewport interprets its zoom anchor in view/screen units, while the existing timeline pinch
handler converts the same normalized Avalonia pinch origin to control coordinates before zooming.

src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[363-371]
src/OpenIPC.Viewer.App/Controls/TimelineControl.cs[253-268]
src/OpenIPC.Viewer.Core/Video/DigitalZoomViewport.cs[105-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

Issue description
`PinchEventArgs.ScaleOrigin` is normalized, but `ZoomPanHost.OnPinch` supplies it directly to viewport math that expects host-coordinate DIPs. This makes pinch zoom anchor near the top-left instead of the users' pinch midpoint.

Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[363-371]

Recommended Fix
Convert the normalized scale origin before calling `ZoomAt`, using `e.ScaleOrigin.X * Bounds.Width` and `e.ScaleOrigin.Y * Bounds.Height`. Keep the existing per-event scale ratio and handling behavior.

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



Remediation recommended

2. Zoomed videos trap page scrolling 🐞 Bug ≡ Correctness
Description
OnPointerWheelChanged rejects an unmodified wheel only when WheelZoomRequiresModifier is enabled
and the host is not already zoomed. After a user zooms in with Control held, ordinary wheel input
continues changing zoom and is marked handled instead of scrolling the narrow page.
Code

src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[R224-225]

+        var ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
+        if (WheelZoomRequiresModifier && !ctrl && !IsZoomed) return;
Evidence
The property is explicitly intended to let a scrolling page receive bare wheel input, and the
responsive page enables it below the mobile breakpoint. The new guard nevertheless falls through
whenever IsZoomed is true, after which it calls ZoomAt and marks the event handled.

src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[39-41]
src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[221-232]
src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml.cs[71-73]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`WheelZoomRequiresModifier` currently permits unmodified wheel zoom whenever the viewport is already zoomed, causing the surrounding scrolling page to stop receiving wheel input.

## Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[221-232]

## Recommended Fix
When `WheelZoomRequiresModifier` is true, return from `OnPointerWheelChanged` whenever Control is not pressed, regardless of `IsZoomed`. Keep the event unhandled on that path so the containing page can scroll.

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


3. The map distorts non-widescreen video 🐞 Bug ≡ Correctness
Description
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.
Code

src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs[88]

+    private double Aspect => Host is { ContentAspect: > 0 } h ? h.ContentAspect : 16.0 / 9.0;
Evidence
The minimap defaults to 16:9 and draws its bitmap into all bounds, whereas the video view publishes
the decoded frame aspect and the live page's host aspect is zero before telemetry is available.

src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs[79-109]
src/OpenIPC.Viewer.App/Controls/RtspVideoView.axaml.cs[24-36]
src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml[74-78]
src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs[107-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

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



Informational

4. Secondary clicks move the zoomed view 🐞 Bug ≡ Correctness
Description
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.
Code

src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs[R127-132]

+    protected override void OnPointerPressed(PointerPressedEventArgs e)
+    {
+        base.OnPointerPressed(e);
+        e.Pointer.Capture(this);
+        MoveViewTo(e.GetPosition(this));
+        e.Handled = true;
Evidence
The minimap unconditionally captures and moves on press, whereas the primary zoom surface explicitly
checks the left-button state before beginning pointer interaction.

src/OpenIPC.Viewer.App/Controls/ZoomMinimap.cs[127-140]
src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[235-257]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

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


Grey Divider

Context sources
Review mode: 🧠 Deep: This is a substantial, behavior-heavy feature spanning core viewport math, touch/mouse interaction, rendering, overlays, detection mapping, and multiple UI integration paths, creating many independent opportunities for subtle defects.

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +224 to +225
var ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
if (WheelZoomRequiresModifier && !ctrl && !IsZoomed) return;

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

2. Zoomed videos trap page scrolling 🐞 Bug ≡ Correctness

OnPointerWheelChanged rejects an unmodified wheel only when WheelZoomRequiresModifier is enabled
and the host is not already zoomed. After a user zooms in with Control held, ordinary wheel input
continues changing zoom and is marked handled instead of scrolling the narrow page.
Agent Prompt
## Issue description
`WheelZoomRequiresModifier` currently permits unmodified wheel zoom whenever the viewport is already zoomed, causing the surrounding scrolling page to stop receiving wheel input.

## Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[221-232]

## Recommended Fix
When `WheelZoomRequiresModifier` is true, return from `OnPointerWheelChanged` whenever Control is not pressed, regardless of `IsZoomed`. Keep the event unhandled on that path so the containing page can scroll.

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

_lastPinchScale = e.Scale;
if (factor > 0 && Math.Abs(factor - 1.0) > 0.0001)
{
_viewport.ZoomAt(e.ScaleOrigin.X, e.ScaleOrigin.Y, factor);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Pinching zooms around the wrong point 🐞 Bug ≡ Correctness

ZoomPanHost.OnPinch passes PinchEventArgs.ScaleOrigin.X and .Y directly to
DigitalZoomViewport.ZoomAt, although those values are normalized coordinates rather than host
DIPs. On a typical 1000×500 video, a pinch centered at the picture center is treated as (0.5, 0.5)
instead of (500, 250), so every pinch is anchored near the upper-left corner.
Agent Prompt
Issue description
`PinchEventArgs.ScaleOrigin` is normalized, but `ZoomPanHost.OnPinch` supplies it directly to viewport math that expects host-coordinate DIPs. This makes pinch zoom anchor near the top-left instead of the users' pinch midpoint.

Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/ZoomPanHost.cs[363-371]

Recommended Fix
Convert the normalized scale origin before calling `ZoomAt`, using `e.ScaleOrigin.X * Bounds.Width` and `e.ScaleOrigin.Y * Bounds.Height`. Keep the existing per-event scale ratio and handling behavior.

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

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

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

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

@keyldev
keyldev deleted the feat/digital-zoom branch September 26, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant