diff --git a/Splitter-UI/Models/PreviewData.cs b/Splitter-UI/Models/PreviewData.cs index ad9a930..3323dd3 100644 --- a/Splitter-UI/Models/PreviewData.cs +++ b/Splitter-UI/Models/PreviewData.cs @@ -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 DetectedBoxes { get; } - public Rect? CropRect { get; } + public Rect? CropRect { get; } + public Point2f GravitateTo { get; } - public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList boxes, Rect? crop) + public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList boxes, Rect? crop, Point2f gravitateTo) { - Frame = frame; + Frame = frame; DetectedBoxes = boxes; - CropRect = crop; + CropRect = crop; + GravitateTo = gravitateTo; } } \ No newline at end of file diff --git a/Splitter-UI/ViewModels/InspectorPaneViewModel.cs b/Splitter-UI/ViewModels/InspectorPaneViewModel.cs index ba0a093..ce73139 100644 --- a/Splitter-UI/ViewModels/InspectorPaneViewModel.cs +++ b/Splitter-UI/ViewModels/InspectorPaneViewModel.cs @@ -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 Files { get; set; } = []; + public List 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; } diff --git a/Splitter-UI/ViewModels/JobViewModel.cs b/Splitter-UI/ViewModels/JobViewModel.cs index 213a134..99dce2d 100644 --- a/Splitter-UI/ViewModels/JobViewModel.cs +++ b/Splitter-UI/ViewModels/JobViewModel.cs @@ -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)) diff --git a/Splitter-UI/ViewModels/MainViewModel.cs b/Splitter-UI/ViewModels/MainViewModel.cs index 9f2c2ad..96ef448 100644 --- a/Splitter-UI/ViewModels/MainViewModel.cs +++ b/Splitter-UI/ViewModels/MainViewModel.cs @@ -19,6 +19,8 @@ public partial class MainViewModel : ViewModelBase Preview.Selected = file; Inspector.Selected = file; }; + + Inspector.Files = FileList.Files; } [RelayCommand] diff --git a/Splitter-UI/ViewModels/PreviewPaneViewModel.cs b/Splitter-UI/ViewModels/PreviewPaneViewModel.cs index 399533b..8939652 100644 --- a/Splitter-UI/ViewModels/PreviewPaneViewModel.cs +++ b/Splitter-UI/ViewModels/PreviewPaneViewModel.cs @@ -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) { diff --git a/Splitter-UI/Views/PreviewCanvas.cs b/Splitter-UI/Views/PreviewCanvas.cs index 9ddfde6..5b4a804 100644 --- a/Splitter-UI/Views/PreviewCanvas.cs +++ b/Splitter-UI/Views/PreviewCanvas.cs @@ -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(nameof(Sar)); public static readonly StyledProperty RotateAngleProperty = AvaloniaProperty.Register(nameof(RotateAngle)); + public static readonly StyledProperty GravitateToProperty = + AvaloniaProperty.Register(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( @@ -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); } - } diff --git a/Splitter-UI/Views/PreviewPane.axaml b/Splitter-UI/Views/PreviewPane.axaml index 7518d0a..bf17759 100644 --- a/Splitter-UI/Views/PreviewPane.axaml +++ b/Splitter-UI/Views/PreviewPane.axaml @@ -13,7 +13,8 @@ Grid.Row="0" Preview="{Binding Preview}" Sar="{Binding Sar}" - RotateAngle="{Binding Rotate}" /> + RotateAngle="{Binding Rotate}" + GravitateTo="{Binding GravitateTo}"/>