Crop rectangle and gravitate to (meveable) point added to preview.

This commit is contained in:
Alexander Shabarshov 2026-05-24 13:35:10 +01:00
parent 61c94d4661
commit c6ca4fcbb6
8 changed files with 479 additions and 95 deletions

View File

@ -4,15 +4,17 @@ namespace Splitter_UI.Models;
public class PreviewData
{
public Avalonia.Media.Imaging.Bitmap? Frame { get; }
public Avalonia.Media.Imaging.Bitmap? Frame { get; }
public IReadOnlyList<OpenCvSharp.Rect> DetectedBoxes { get; }
public Rect? CropRect { get; }
public Rect? CropRect { get; }
public Point2f GravitateTo { get; }
public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<OpenCvSharp.Rect> boxes, Rect? crop)
public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<OpenCvSharp.Rect> boxes, Rect? crop, Point2f gravitateTo)
{
Frame = frame;
Frame = frame;
DetectedBoxes = boxes;
CropRect = crop;
CropRect = crop;
GravitateTo = gravitateTo;
}
}

View File

@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Splitter_UI.ViewModels;
@ -8,6 +9,8 @@ public partial class InspectorPaneViewModel : ObservableObject
[ObservableProperty]
private JobViewModel? _selected;
public ObservableCollection<JobViewModel> Files { get; set; } = [];
public List<string> DetectModes =>
[
"face", "body", "none"
@ -24,6 +27,22 @@ public partial class InspectorPaneViewModel : ObservableObject
if (Selected is null)
return;
foreach (JobViewModel job in Files.Where(x => !ReferenceEquals(x, Selected)))
{
job.Detect = Selected.Detect;
job.Rotate = Selected.Rotate;
job.CropText = Selected.CropText;
job.ForceFixed = Selected.ForceFixed;
job.GravitateText = Selected.GravitateText;
job.Mask = Selected.Mask;
job.OutputFolder = Selected.OutputFolder;
job.OverrideTargetDuration = Selected.OverrideTargetDuration;
job.PassthroughText = Selected.PassthroughText;
job.ParametersList.Clear();
foreach (var param in Selected.ParametersList)
job.ParametersList.Add(param);
}
}
public IRelayCommand RotateLeftCommand { get; }

View File

@ -16,7 +16,7 @@ public partial class JobViewModel : ObservableObject
private SingleJob Job { get; }
[ObservableProperty] private VideoInfo? _probe;
[ObservableProperty] private PreviewData? _preview = new(null, [], null);
[ObservableProperty] private PreviewData? _preview = new(null, [], null, new(0.5f, 0.5f));
[ObservableProperty] private ProgressInfo? _progress;
[ObservableProperty] private Bitmap? _thumbnail;
[ObservableProperty] private string _suggestedAction = "";
@ -84,6 +84,7 @@ public partial class JobViewModel : ObservableObject
Job.GravitateTo = new Point2f(x, y);
}
OnPropertyChanged();
OnPropertyChanged(nameof(GravitateTo));
}
}
@ -170,6 +171,20 @@ public partial class JobViewModel : ObservableObject
}
}
public Point2f GravitateTo
{
get => Job.GravitateTo ?? new Point2f(0.5f, 0.5f);
set
{
if (Job.GravitateTo != null && Math.Abs(Job.GravitateTo.Value.X - value.X) < 0.001 && Math.Abs(Job.GravitateTo.Value.Y - value.Y) < 0.001)
return;
Job.GravitateTo = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GravitateText));
}
}
public double? OverrideTargetDuration
{
get => Job.OverrideTargetDuration;
@ -188,14 +203,14 @@ public partial class JobViewModel : ObservableObject
_detectorFactory = detectorFactory;
_log = log;
ParametersList.Add(new ParameterEntry("DropoutToleranceFrames", ""));
ParametersList.Add(new ParameterEntry("EmaFactor", ""));
ParametersList.Add(new ParameterEntry("CameraEasing", ""));
ParametersList.Add(new ParameterEntry("LostFreezeFrames", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorSampleCount", ""));
ParametersList.Add(new ParameterEntry("DropoutToleranceFrames" , ""));
ParametersList.Add(new ParameterEntry("EmaFactor" , ""));
ParametersList.Add(new ParameterEntry("CameraEasing" , ""));
ParametersList.Add(new ParameterEntry("LostFreezeFrames" , ""));
ParametersList.Add(new ParameterEntry("RotationDetectorSampleCount" , ""));
ParametersList.Add(new ParameterEntry("RotationDetectorSampleLength", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorFrameWidth", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorFrameHeight", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorFrameWidth" , ""));
ParametersList.Add(new ParameterEntry("RotationDetectorFrameHeight" , ""));
foreach (var entry in ParametersList)
{
@ -204,7 +219,7 @@ public partial class JobViewModel : ObservableObject
ParametersList.CollectionChanged += OnParametersCollectionChanged;
StepForwardCommand = new RelayCommand(StepForward);
StepForwardCommand = new RelayCommand(StepForward);
StepBackwardCommand = new RelayCommand(StepBackward);
_debounceTimer = new DispatcherTimer
@ -224,13 +239,34 @@ public partial class JobViewModel : ObservableObject
if ( frame == null )
return;
Preview = new PreviewData(frame, [], null);
Preview = new PreviewData(frame, [], null, Job.GravitateTo ?? new (0.5f, 0.5f));
var detector = _detectorFactory(Job.Detect ?? "");
var detections = detector.DetectAll(frame.ToMatContinuous());
var boxes = detections.Select(x => new OpenCvSharp.Rect(x.box.X, x.box.Y, x.box.Width, x.box.Height)).ToList();
Preview = new PreviewData(frame, boxes, null);
Rect? crop = null;
if (detections.Count > 0)
{
var primaryDetection = detections
.OrderByDescending(d => d.box.Height * d.box.Width)
.FirstOrDefault();
var w = Probe.Width;
var h = Probe.Height;
var cropWidth = Job.Crop?.width ?? CommandLine.DefaultW;
var cropHeight = Job.Crop?.height ?? CommandLine.DefaultH;
var cx = primaryDetection.center.X - cropWidth / 2f;
var cy = primaryDetection.center.Y - cropHeight / 2f;
var r = new Rect(cx, cy, cropWidth, cropHeight);
crop = ClampCrop(r, w, h);
}
var boxes = detections.Select(x => x.box).ToList();
Preview = new PreviewData(frame, boxes, crop, Job.GravitateTo ?? new (0.5f, 0.5f));
}
catch (Exception ex)
{
@ -238,6 +274,26 @@ public partial class JobViewModel : ObservableObject
}
}
private static Rect ClampCrop(Rect r, float w, float h)
{
var x = r.X;
var y = r.Y;
var cw = r.Width;
var ch = r.Height;
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x + cw > w) x = w - cw;
if (y + ch > h) y = h - ch;
if (x < 0) x = 0;
if (y < 0) y = 0;
return new Rect(x, y, cw, ch);
}
private void OnParameterChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is ParameterEntry p && e.PropertyName == nameof(ParameterEntry.Value))

View File

@ -19,6 +19,8 @@ public partial class MainViewModel : ViewModelBase
Preview.Selected = file;
Inspector.Selected = file;
};
Inspector.Files = FileList.Files;
}
[RelayCommand]

View File

@ -12,6 +12,17 @@ public partial class PreviewPaneViewModel : ObservableObject
public PreviewData? Preview => Selected?.Preview;
public Point2f? Sar => Selected?.Probe?.Sar;
public int Rotate => Selected?.Rotate ?? 0;
public Point2f GravitateTo
{
get => Selected?.GravitateTo ?? new Point2f(0.5f, 0.5f);
set
{
if (Selected == null)
return;
Selected.GravitateTo = value;
OnPropertyChanged(nameof(GravitateTo));
}
}
partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue)
{

View File

@ -1,6 +1,7 @@
using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
using splitter.algo;
@ -15,6 +16,8 @@ public sealed class PreviewCanvas : Control
AvaloniaProperty.Register<PreviewCanvas, Point2f?>(nameof(Sar));
public static readonly StyledProperty<int> RotateAngleProperty =
AvaloniaProperty.Register<PreviewCanvas, int>(nameof(RotateAngle));
public static readonly StyledProperty<Point2f> GravitateToProperty =
AvaloniaProperty.Register<PreviewCanvas, Point2f>(nameof(GravitateTo));
public PreviewData? Preview
{
@ -34,6 +37,17 @@ public sealed class PreviewCanvas : Control
set => SetValue(RotateAngleProperty, value);
}
// GravitateTo is normalized (0..1)
public Point2f GravitateTo
{
get => GetValue(GravitateToProperty);
set => SetValue(GravitateToProperty, value);
}
private bool _dragging;
private Avalonia.Point _dragStartCanvas;
private Point2f _dragStartValue;
static PreviewCanvas()
{
PreviewProperty.Changed.AddClassHandler<PreviewCanvas>(
@ -42,6 +56,13 @@ public sealed class PreviewCanvas : Control
args.NewValue as PreviewData));
}
public PreviewCanvas()
{
PointerPressed += OnPointerPressed;
PointerMoved += OnPointerMoved;
PointerReleased += OnPointerReleased;
}
private void OnPreviewChanged(PreviewData? oldValue, PreviewData? newValue)
{
if (oldValue is INotifyPropertyChanged oldNotify)
@ -50,7 +71,6 @@ public sealed class PreviewCanvas : Control
if (newValue is INotifyPropertyChanged newNotify)
newNotify.PropertyChanged += PreviewPropertyChanged;
// Always marshal to UI thread
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
@ -60,7 +80,6 @@ public sealed class PreviewCanvas : Control
e.PropertyName == nameof(PreviewData.DetectedBoxes) ||
e.PropertyName == nameof(PreviewData.CropRect))
{
// Always marshal to UI thread
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
}
@ -68,6 +87,342 @@ public sealed class PreviewCanvas : Control
protected override Size MeasureOverride(Size availableSize) => availableSize;
protected override Size ArrangeOverride(Size finalSize) => finalSize;
// ------------------------------------------------------------
// Unified transform helpers
// ------------------------------------------------------------
private (double X, double Y) TransformPoint(
double x, double y,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
switch (rotate)
{
case 90:
(x, y) = (rawH - y, x);
break;
case 180:
x = rawW - x;
y = rawH - y;
break;
case 270:
(x, y) = (y, rawW - x);
break;
}
if (rotate == 0 || rotate == 180)
x *= pixelAspect;
else
y *= pixelAspect;
var sx = offsetX + x * scale;
var sy = offsetY + y * scale;
return (sx, sy);
}
private Rect TransformRect(
double x, double y, double w, double h,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
switch (rotate)
{
case 90:
(x, y) = (rawH - (y + h), x);
(w, h) = (h, w);
break;
case 180:
x = rawW - (x + w);
y = rawH - (y + h);
break;
case 270:
(x, y) = (y, rawW - (x + w));
(w, h) = (h, w);
break;
}
if (rotate == 0 || rotate == 180)
{
x *= pixelAspect;
w *= pixelAspect;
}
else
{
y *= pixelAspect;
h *= pixelAspect;
}
return new Rect(
offsetX + x * scale,
offsetY + y * scale,
w * scale,
h * scale);
}
// ------------------------------------------------------------
// Hit test for gravitate point (normalized)
// ------------------------------------------------------------
private bool HitGravitate(Avalonia.Point p, out Point2f value)
{
value = default;
var preview = Preview;
if (preview?.Frame is null)
return false;
var g = GravitateTo;
var rawW = preview.Frame.PixelSize.Width;
var rawH = preview.Frame.PixelSize.Height;
// normalized → pixel
double px = g.X * rawW;
double py = g.Y * rawH;
var rotate = RotateAngle;
var sar = Sar ?? new Point2f(1, 1);
var pixelAspect = sar.X / sar.Y;
var dispW = Bounds.Width;
var dispH = Bounds.Height;
double displayW, displayH;
if (rotate == 0 || rotate == 180)
{
displayW = rawW * pixelAspect;
displayH = rawH;
}
else
{
displayW = rawW;
displayH = rawH * pixelAspect;
}
var scale = Math.Min(dispW / displayW, dispH / displayH);
var offsetX = (dispW - displayW * scale) / 2;
var offsetY = (dispH - displayH * scale) / 2;
var (cx, cy) = TransformPoint(
px, py,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
const double radius = 10;
var hit = (p.X - cx) * (p.X - cx) + (p.Y - cy) * (p.Y - cy) <= radius * radius;
if (hit)
value = g;
return hit;
}
// ------------------------------------------------------------
// Pointer events
// ------------------------------------------------------------
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
var p = e.GetPosition(this);
if (HitGravitate(p, out var g))
{
_dragging = true;
_dragStartCanvas = p;
_dragStartValue = g; // normalized
e.Pointer.Capture(this);
}
}
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (!_dragging || Preview?.Frame is null)
return;
var p = e.GetPosition(this);
var dxCanvas = p.X - _dragStartCanvas.X;
var dyCanvas = p.Y - _dragStartCanvas.Y;
var preview = Preview;
var rawW = preview.Frame.PixelSize.Width;
var rawH = preview.Frame.PixelSize.Height;
var rotate = RotateAngle;
var sar = Sar ?? new Point2f(1, 1);
var pixelAspect = sar.X / sar.Y;
var dispW = Bounds.Width;
var dispH = Bounds.Height;
double displayW, displayH;
if (rotate == 0 || rotate == 180)
{
displayW = rawW * pixelAspect;
displayH = rawH;
}
else
{
displayW = rawW;
displayH = rawH * pixelAspect;
}
var scale = Math.Min(dispW / displayW, dispH / displayH);
double dx = dxCanvas / scale;
double dy = dyCanvas / scale;
if (rotate == 0 || rotate == 180)
dx /= pixelAspect;
else
dy /= pixelAspect;
// start normalized → pixel
double gx = _dragStartValue.X * rawW + dx;
double gy = _dragStartValue.Y * rawH + dy;
switch (rotate)
{
case 90:
(gx, gy) = (gy, rawH - gx);
break;
case 180:
gx = rawW - gx;
gy = rawH - gy;
break;
case 270:
(gx, gy) = (rawW - gy, gx);
break;
}
// pixel → normalized
var nx = (float)(gx / rawW);
var ny = (float)(gy / rawH);
if (nx < 0) nx = 0;
if (ny < 0) ny = 0;
if (nx > 1) nx = 1;
if (ny > 1) ny = 1;
GravitateTo = new Point2f(nx, ny);
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (_dragging)
{
_dragging = false;
e.Pointer.Capture(null);
}
}
// ------------------------------------------------------------
// Overlay renderers
// ------------------------------------------------------------
private void RenderCropRectangle(
DrawingContext context,
PreviewData preview,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
if (preview.CropRect is not { } crop)
return;
var rr = TransformRect(
crop.X, crop.Y, crop.Width, crop.Height,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
var pen = new Pen(Brushes.Yellow, 2);
context.DrawRectangle(null, pen, rr);
}
private void RenderGravitateTo(
DrawingContext context,
PreviewData preview,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
var g = GravitateTo;
// normalized → pixel
double px = g.X * rawW;
double py = g.Y * rawH;
var (sx, sy) = TransformPoint(
px, py,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
const double radius = 10;
var circle = new EllipseGeometry(
new Rect(sx - radius, sy - radius, radius * 2, radius * 2));
var pen = new Pen(Brushes.Yellow, 2);
var brush = Brushes.Yellow;
context.DrawGeometry(brush, pen, circle);
}
private void RenderDetectedBoxes(
DrawingContext context,
PreviewData preview,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
if (preview.DetectedBoxes is not { Count: > 0 })
return;
var pen = new Pen(Brushes.Lime, 2);
foreach (var r in preview.DetectedBoxes)
{
var rr = TransformRect(
r.X, r.Y, r.Width, r.Height,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
context.DrawRectangle(null, pen, rr);
}
}
// ------------------------------------------------------------
// Main Render
// ------------------------------------------------------------
public override void Render(DrawingContext context)
{
var preview = Preview;
@ -84,33 +439,22 @@ public sealed class PreviewCanvas : Control
if (dispW <= 0 || dispH <= 0)
return;
var rotate = RotateAngle; // 0, 90, 180, 270
var rotate = RotateAngle;
// SAR (always original, never rotated)
var sar = Sar ?? new Point2f(1, 1);
var sarX = sar.X;
var sarY = sar.Y;
if (sarX <= 0 || sarY <= 0)
{
sarX = 1;
sarY = 1;
}
var sarX = sar.X <= 0 ? 1 : sar.X;
var sarY = sar.Y <= 0 ? 1 : sar.Y;
var pixelAspect = sarX / sarY;
double displayW;
double displayH;
double displayW, displayH;
if (rotate == 0 || rotate == 180)
{
// encoded horizontal axis = rawW
displayW = rawW * pixelAspect;
displayH = rawH;
}
else
{
// encoded horizontal axis = rawH (bitmap already rotated)
displayW = rawW;
displayH = rawH * pixelAspect;
}
@ -123,64 +467,13 @@ public sealed class PreviewCanvas : Control
var offsetX = (dispW - scaledW) / 2;
var offsetY = (dispH - scaledH) / 2;
// draw frame
context.DrawImage(
frame,
new Rect(0, 0, rawW, rawH),
new Rect(offsetX, offsetY, scaledW, scaledH));
// overlays
if (preview.DetectedBoxes is { Count: > 0 })
{
var pen = new Pen(Brushes.Lime, 2);
foreach (var r in preview.DetectedBoxes)
{
double x = r.X;
double y = r.Y;
double w = r.Width;
double h = r.Height;
// rotate overlay coordinates (still using your existing logic)
switch (rotate)
{
case 90:
(x, y) = (rawH - (y + h), x);
(w, h) = (h, w);
break;
case 180:
x = rawW - (x + w);
y = rawH - (y + h);
break;
case 270:
(x, y) = (y, rawW - (x + w));
(w, h) = (h, w);
break;
}
// apply SAR to the axis that originated from encoded width
if (rotate == 0 || rotate == 180)
{
x *= pixelAspect;
w *= pixelAspect;
}
else
{
y *= pixelAspect;
h *= pixelAspect;
}
var rr = new Rect(
offsetX + x * scale,
offsetY + y * scale,
w * scale,
h * scale);
context.DrawRectangle(null, pen, rr);
}
}
RenderDetectedBoxes(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
RenderCropRectangle(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
RenderGravitateTo(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
}
}

View File

@ -13,7 +13,8 @@
Grid.Row="0"
Preview="{Binding Preview}"
Sar="{Binding Sar}"
RotateAngle="{Binding Rotate}" />
RotateAngle="{Binding Rotate}"
GravitateTo="{Binding GravitateTo}"/>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"

View File

@ -6,6 +6,10 @@ namespace splitter;
public sealed class CommandLine
{
// Default vertical Full HD for YouTube Shorts
public const int DefaultW = 607;
public const int DefaultH = 1080;
public SingleJob Master { get; } = new SingleJob();
public SingleJob[] Jobs { get; }
@ -231,13 +235,9 @@ public sealed class CommandLine
private static (int width, int height)? ParseCrop(string v)
{
// Default vertical Full HD for YouTube Shorts
const int defaultW = 607;
const int defaultH = 1080;
// Empty or whitespace → default crop
if (string.IsNullOrWhiteSpace(v))
return (defaultW, defaultH);
return (DefaultW, DefaultH);
var s = v.Trim().ToLowerInvariant();