diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index bd1cd19..0936a2d 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -14,8 +14,11 @@
"Bash(find:*)",
"Bash(ls:*)",
"Bash(/mnt/c/Program\\ Files/dotnet/dotnet build)",
- "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)"
+ "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)",
+ "PowerShell(git diff *)",
+ "PowerShell(dotnet build *)",
+ "Bash(gh issue list *)"
],
"deny": []
}
-}
\ No newline at end of file
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ccbdbe4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,68 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Magick Crop & Measure is a WPF desktop app (Windows 10+) that corrects perspective distortion in photos and provides measurement tools. It uses ImageMagick for image processing, Emgu.CV (OpenCV wrapper) for shape detection, and WPF-UI (Fluent Design) for the UI.
+
+## Build Commands
+
+```bash
+# Build the solution (from repo root)
+dotnet build MagickCrop.sln
+
+# Build a specific platform (arm64, x64, x86)
+dotnet build MagickCrop/MagickCrop.csproj -p:Platform=x64
+
+# Run (requires Windows with .NET 10)
+dotnet run --project MagickCrop/MagickCrop.csproj
+
+# Run tests
+dotnet test MagickCrop.Tests/
+
+# Publish self-contained for x64
+dotnet publish MagickCrop/MagickCrop.csproj -r win-x64 --self-contained -o bld/x64/MagickCrop-Self-Contained
+```
+
+The MSIX packaging project (`MagickCrop-Package/MagickCrop-Package.wapproj`) is used for Store submissions and requires Visual Studio.
+
+## Architecture
+
+### Key Namespaces
+
+- `MagickCrop` — root namespace; `MainWindow.xaml.cs` and `MainWindow.QuadrilateralHover.cs` are partial classes for the main window
+- `MagickCrop.ViewModels` — `MainWindowViewModel` (CommunityToolkit.Mvvm `ObservableObject`) + `IMainWindowView` interface
+- `MagickCrop.Helpers` — stateless static helpers for image processing operations
+- `MagickCrop.Controls` — WPF `UserControl` subclasses for measurement overlays and interactive controls
+- `MagickCrop.Models` — DTOs, `UndoRedo` stack, `AspectRatio`, `DraggingMode` enum
+- `MagickCrop.Models.MeasurementControls` — serializable DTOs for each measurement type
+- `MagickCrop.Services` — `RecentProjectsManager` (JSON-backed project persistence in `%LocalAppData%\MagickCrop`)
+- `MagickCrop.Behaviors` — `PinchZoomBehavior` attached property
+
+### View / ViewModel Split
+
+`MainWindow` implements `IMainWindowView`, which exposes just the properties the ViewModel needs (image source, busy state, local-adjustment region). `MainWindowViewModel` holds all commands (CommunityToolkit source-generated `[RelayCommand]`) and observable state. UI-only logic (dragging handles, polygon rendering) stays in the code-behind.
+
+### Image Processing Pipeline
+
+All image mutations write to a **temp file** on disk and reload from it. Undo/redo (`UndoRedo` + `MagickImageUndoRedoItem`) stores before/after file paths and reloads from disk on undo/redo — there is no in-memory image stack.
+
+Key helpers:
+- `QuadrilateralDetector` — Emgu.CV-based contour detection, returns `DetectedQuadrilateral` with confidence score
+- `GridStraightenHelper` — polynomial distortion via ImageMagick for grid-based warp correction
+- `UnWarpCorrector` — barrel/pincushion correction using transfinite interpolation + polynomial distortion
+- `EdgeCorrectionHelper` — wavy-edge straightening using the same transfinite interpolation approach
+- `ImageExtensions` / `MagickExtensions` — extension methods bridging WPF types and Magick.NET types
+
+### Measurement Controls
+
+Each measurement type (Distance, Angle, Rectangle, Polygon, Circle, VerticalLine, HorizontalLine) is a `UserControl` with a corresponding `*Dto` model for JSON serialization. Controls live on a WPF `Canvas` overlaid on the image.
+
+### Project Persistence
+
+`RecentProjectsManager` saves projects as JSON files (including measurement state as `MagickCropMeasurementPackage`) with thumbnails under `%LocalAppData%\MagickCrop\Projects`. An auto-save timer fires every 5 seconds when a project is open.
+
+### UI Framework
+
+Uses **WPF-UI** (`Wpf.Ui`) for `FluentWindow`, theming, and Fluent controls. `MainWindow` extends `FluentWindow`. Theme follows system (light/dark) via `ApplicationThemeManager`.
diff --git a/MagickCrop-Package/MagickCrop-Package.wapproj b/MagickCrop-Package/MagickCrop-Package.wapproj
index 86d4529..60866bb 100644
--- a/MagickCrop-Package/MagickCrop-Package.wapproj
+++ b/MagickCrop-Package/MagickCrop-Package.wapproj
@@ -165,7 +165,7 @@
-
+
diff --git a/MagickCrop-Package/Package.appxmanifest b/MagickCrop-Package/Package.appxmanifest
index 6201c8a..2083f04 100644
--- a/MagickCrop-Package/Package.appxmanifest
+++ b/MagickCrop-Package/Package.appxmanifest
@@ -1,4 +1,4 @@
-
+
+ Version="1.12.0.0" />
MagickCrop
diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml b/MagickCrop/Controls/MarkupShapeControl.xaml
new file mode 100644
index 0000000..28bd83a
--- /dev/null
+++ b/MagickCrop/Controls/MarkupShapeControl.xaml
@@ -0,0 +1,75 @@
+
+
+
diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml.cs b/MagickCrop/Controls/MarkupShapeControl.xaml.cs
new file mode 100644
index 0000000..ca71d83
--- /dev/null
+++ b/MagickCrop/Controls/MarkupShapeControl.xaml.cs
@@ -0,0 +1,215 @@
+using MagickCrop.Models;
+using MagickCrop.Models.MeasurementControls;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Shapes;
+
+namespace MagickCrop.Controls;
+
+public partial class MarkupShapeControl : UserControl
+{
+ private Point point1 = new(100, 100);
+ private Point point2 = new(300, 300);
+ private int pointDraggingIndex = -1;
+
+ public event MouseButtonEventHandler? MeasurementPointMouseDown;
+
+ public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e);
+ public event RemoveControlRequestedEventHandler? RemoveControlRequested;
+
+ private MarkupShapeType shapeType = MarkupShapeType.Rectangle;
+ public MarkupShapeType ShapeType
+ {
+ get => shapeType;
+ set
+ {
+ shapeType = value;
+ UpdateShapeVisibility();
+ UpdatePositions();
+ }
+ }
+
+ private Color strokeColor = Colors.Red;
+ public Color StrokeColor
+ {
+ get => strokeColor;
+ set
+ {
+ strokeColor = value;
+ UpdateColors();
+ }
+ }
+
+ private double strokeThickness = 3.0;
+ public double StrokeThickness
+ {
+ get => strokeThickness;
+ set
+ {
+ strokeThickness = value;
+ UpdateThickness();
+ UpdatePositions();
+ }
+ }
+
+ public MarkupShapeControl()
+ {
+ InitializeComponent();
+ UpdateShapeVisibility();
+ UpdatePositions();
+ UpdateColors();
+ }
+
+ private void UpdateShapeVisibility()
+ {
+ ShapeLine.Visibility = shapeType is MarkupShapeType.Line or MarkupShapeType.Arrow
+ ? Visibility.Visible : Visibility.Collapsed;
+ ArrowHead.Visibility = shapeType == MarkupShapeType.Arrow
+ ? Visibility.Visible : Visibility.Collapsed;
+ ShapeRectangle.Visibility = shapeType == MarkupShapeType.Rectangle
+ ? Visibility.Visible : Visibility.Collapsed;
+ ShapeEllipse.Visibility = shapeType == MarkupShapeType.Ellipse
+ ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private void UpdateColors()
+ {
+ SolidColorBrush brush = new(strokeColor);
+ ShapeLine.Stroke = brush;
+ ArrowHead.Fill = new SolidColorBrush(strokeColor);
+ ShapeRectangle.Stroke = new SolidColorBrush(strokeColor);
+ ShapeEllipse.Stroke = new SolidColorBrush(strokeColor);
+ Point1Handle.Fill = new SolidColorBrush(strokeColor);
+ Point2Handle.Fill = new SolidColorBrush(strokeColor);
+ }
+
+ private void UpdateThickness()
+ {
+ ShapeLine.StrokeThickness = strokeThickness;
+ ShapeRectangle.StrokeThickness = strokeThickness;
+ ShapeEllipse.StrokeThickness = strokeThickness;
+ }
+
+ private void UpdatePositions()
+ {
+ Canvas.SetLeft(Point1Handle, point1.X - Point1Handle.Width / 2);
+ Canvas.SetTop(Point1Handle, point1.Y - Point1Handle.Height / 2);
+ Canvas.SetLeft(Point2Handle, point2.X - Point2Handle.Width / 2);
+ Canvas.SetTop(Point2Handle, point2.Y - Point2Handle.Height / 2);
+
+ switch (shapeType)
+ {
+ case MarkupShapeType.Line:
+ case MarkupShapeType.Arrow:
+ ShapeLine.X1 = point1.X;
+ ShapeLine.Y1 = point1.Y;
+ ShapeLine.X2 = point2.X;
+ ShapeLine.Y2 = point2.Y;
+ if (shapeType == MarkupShapeType.Arrow)
+ UpdateArrowHead();
+ break;
+
+ case MarkupShapeType.Rectangle:
+ double rx = Math.Min(point1.X, point2.X);
+ double ry = Math.Min(point1.Y, point2.Y);
+ Canvas.SetLeft(ShapeRectangle, rx);
+ Canvas.SetTop(ShapeRectangle, ry);
+ ShapeRectangle.Width = Math.Max(1, Math.Abs(point2.X - point1.X));
+ ShapeRectangle.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y));
+ break;
+
+ case MarkupShapeType.Ellipse:
+ double ex = Math.Min(point1.X, point2.X);
+ double ey = Math.Min(point1.Y, point2.Y);
+ Canvas.SetLeft(ShapeEllipse, ex);
+ Canvas.SetTop(ShapeEllipse, ey);
+ ShapeEllipse.Width = Math.Max(1, Math.Abs(point2.X - point1.X));
+ ShapeEllipse.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y));
+ break;
+ }
+ }
+
+ private void UpdateArrowHead()
+ {
+ double dx = point2.X - point1.X;
+ double dy = point2.Y - point1.Y;
+ double len = Math.Sqrt(dx * dx + dy * dy);
+ if (len < 1) return;
+
+ double arrowSize = Math.Max(14, strokeThickness * 4);
+ double angle = Math.Atan2(dy, dx);
+
+ Point tip = point2;
+ Point b1 = new(
+ tip.X - arrowSize * Math.Cos(angle - Math.PI / 6),
+ tip.Y - arrowSize * Math.Sin(angle - Math.PI / 6));
+ Point b2 = new(
+ tip.X - arrowSize * Math.Cos(angle + Math.PI / 6),
+ tip.Y - arrowSize * Math.Sin(angle + Math.PI / 6));
+
+ ArrowHead.Points = [tip, b1, b2];
+ }
+
+ private void HandlePoint_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is not Ellipse ellipse || ellipse.Tag is not string s)
+ return;
+ pointDraggingIndex = int.Parse(s);
+ MeasurementPointMouseDown?.Invoke(sender, e);
+ }
+
+ public void MovePoint(int pointIndex, Point newPosition)
+ {
+ if (pointIndex == 0) point1 = newPosition;
+ else if (pointIndex == 1) point2 = newPosition;
+ UpdatePositions();
+ }
+
+ public void StartDraggingPoint(int pointIndex)
+ {
+ pointDraggingIndex = pointIndex;
+ MeasurementPointMouseDown?.Invoke(
+ pointIndex == 0 ? Point1Handle : Point2Handle, null!);
+ }
+
+ public int GetActivePointIndex() => pointDraggingIndex;
+
+ public void ResetActivePoint() => pointDraggingIndex = -1;
+
+ public (Point Point1, Point Point2) GetPoints() => (point1, point2);
+
+ private void RemoveMenuItem_Click(object sender, RoutedEventArgs e)
+ {
+ RemoveControlRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ public MarkupShapeDto ToDto()
+ {
+ return new MarkupShapeDto
+ {
+ ShapeType = shapeType,
+ Point1 = point1,
+ Point2 = point2,
+ StrokeColor = strokeColor.ToString(),
+ StrokeThickness = strokeThickness
+ };
+ }
+
+ public void FromDto(MarkupShapeDto dto)
+ {
+ shapeType = dto.ShapeType;
+ point1 = dto.Point1;
+ point2 = dto.Point2;
+
+ try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); }
+ catch { strokeColor = Colors.Red; }
+
+ strokeThickness = dto.StrokeThickness;
+ UpdateShapeVisibility();
+ UpdateColors();
+ UpdateThickness();
+ UpdatePositions();
+ }
+}
diff --git a/MagickCrop/Controls/MarkupTextControl.xaml b/MagickCrop/Controls/MarkupTextControl.xaml
new file mode 100644
index 0000000..c6d063d
--- /dev/null
+++ b/MagickCrop/Controls/MarkupTextControl.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MagickCrop/Controls/MarkupTextControl.xaml.cs b/MagickCrop/Controls/MarkupTextControl.xaml.cs
new file mode 100644
index 0000000..99e0fdf
--- /dev/null
+++ b/MagickCrop/Controls/MarkupTextControl.xaml.cs
@@ -0,0 +1,220 @@
+using MagickCrop.Models.MeasurementControls;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Threading;
+
+namespace MagickCrop.Controls;
+
+public partial class MarkupTextControl : UserControl
+{
+ private Point dragOffset;
+ private Point positionBeforeDrag;
+ private bool isDragging;
+ private string textBeforeEdit = "Text";
+
+ public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e);
+ public event RemoveControlRequestedEventHandler? RemoveControlRequested;
+
+ public delegate void TextMovedEventHandler(object sender, Point before, Point after);
+ public event TextMovedEventHandler? TextMoved;
+
+ public event EventHandler? EditCommitted;
+ public event EventHandler? EditCancelled;
+
+ public bool IsEditing => EditBox.Visibility == Visibility.Visible;
+
+ ///
+ /// The text as it was when the current or most recent edit began.
+ ///
+ public string TextBeforeEdit => textBeforeEdit;
+
+ private Color textColor = Colors.Red;
+ public Color TextColor
+ {
+ get => textColor;
+ set
+ {
+ textColor = value;
+ SolidColorBrush brush = new(textColor);
+ DisplayText.Foreground = brush;
+ EditBox.Foreground = brush;
+ EditBox.CaretBrush = brush;
+ }
+ }
+
+ private double markupFontSize = 16.0;
+ public double MarkupFontSize
+ {
+ get => markupFontSize;
+ set
+ {
+ markupFontSize = value;
+ DisplayText.FontSize = markupFontSize;
+ EditBox.FontSize = markupFontSize;
+ }
+ }
+
+ public string MarkupText
+ {
+ get => DisplayText.Text;
+ set
+ {
+ DisplayText.Text = value;
+ EditBox.Text = value;
+ }
+ }
+
+ public MarkupTextControl()
+ {
+ InitializeComponent();
+ }
+
+ public void EnterEditMode()
+ {
+ textBeforeEdit = DisplayText.Text;
+ EditBox.Text = DisplayText.Text;
+ DisplayText.Visibility = Visibility.Collapsed;
+ EditBox.Visibility = Visibility.Visible;
+
+ // The control may have just been added to the canvas and not be loaded
+ // yet, in which case Focus() fails — defer until layout has run
+ Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () =>
+ {
+ EditBox.Focus();
+ EditBox.SelectAll();
+ });
+ }
+
+ public void CommitEdit() => FinishEdit(accepted: true);
+
+ public void CancelEdit() => FinishEdit(accepted: false);
+
+ private void FinishEdit(bool accepted)
+ {
+ if (!IsEditing)
+ return;
+
+ string text = EditBox.Text.Trim();
+ if (text.Length == 0)
+ accepted = false; // committing empty text is a cancel
+
+ // Collapse first: the focus shift it triggers re-enters via LostFocus,
+ // which the IsEditing guard above turns into a no-op
+ EditBox.Visibility = Visibility.Collapsed;
+ DisplayText.Visibility = Visibility.Visible;
+
+ if (accepted)
+ {
+ DisplayText.Text = text;
+ EditBox.Text = text;
+ EditCommitted?.Invoke(this, EventArgs.Empty);
+ }
+ else
+ {
+ DisplayText.Text = textBeforeEdit;
+ EditBox.Text = textBeforeEdit;
+ EditCancelled?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private void EditBox_LostFocus(object sender, RoutedEventArgs e)
+ {
+ CommitEdit();
+ }
+
+ private void EditBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ CommitEdit();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ CancelEdit();
+ e.Handled = true;
+ }
+ }
+
+ private void Border_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (IsEditing || e.ChangedButton != MouseButton.Left)
+ return;
+
+ if (e.ClickCount == 2)
+ {
+ EnterEditMode();
+ e.Handled = true;
+ return;
+ }
+
+ isDragging = true;
+ dragOffset = e.GetPosition(this);
+ positionBeforeDrag = new Point(Canvas.GetLeft(this), Canvas.GetTop(this));
+ // Capture on the Border (the sender) so its MouseMove/MouseUp handlers
+ // keep receiving events; capturing the UserControl routes events away
+ // from the Border and the drag never updates or releases
+ ((UIElement)sender).CaptureMouse();
+ e.Handled = true;
+ }
+
+ private void Border_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (!isDragging || Parent is not Canvas canvas)
+ return;
+
+ Point parentPos = e.GetPosition(canvas);
+ Canvas.SetLeft(this, parentPos.X - dragOffset.X);
+ Canvas.SetTop(this, parentPos.Y - dragOffset.Y);
+ }
+
+ private void Border_MouseUp(object sender, MouseButtonEventArgs e)
+ {
+ if (!isDragging)
+ return;
+
+ isDragging = false;
+ ((UIElement)sender).ReleaseMouseCapture();
+ e.Handled = true;
+
+ Point positionAfterDrag = new(Canvas.GetLeft(this), Canvas.GetTop(this));
+ if (Math.Abs(positionAfterDrag.X - positionBeforeDrag.X) > 0.01
+ || Math.Abs(positionAfterDrag.Y - positionBeforeDrag.Y) > 0.01)
+ {
+ TextMoved?.Invoke(this, positionBeforeDrag, positionAfterDrag);
+ }
+ }
+
+ private void Border_LostMouseCapture(object sender, MouseEventArgs e)
+ {
+ isDragging = false;
+ }
+
+ private void RemoveMenuItem_Click(object sender, RoutedEventArgs e)
+ {
+ RemoveControlRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ public MarkupTextDto ToDto()
+ {
+ return new MarkupTextDto
+ {
+ Text = DisplayText.Text,
+ PositionX = Canvas.GetLeft(this),
+ PositionY = Canvas.GetTop(this),
+ TextColor = textColor.ToString(),
+ FontSize = markupFontSize
+ };
+ }
+
+ public void FromDto(MarkupTextDto dto)
+ {
+ MarkupText = dto.Text;
+ MarkupFontSize = dto.FontSize;
+
+ try { TextColor = (Color)ColorConverter.ConvertFromString(dto.TextColor); }
+ catch { TextColor = Colors.Red; }
+ }
+}
diff --git a/MagickCrop/Controls/RgbHistogramControl.xaml b/MagickCrop/Controls/RgbHistogramControl.xaml
new file mode 100644
index 0000000..30e05f0
--- /dev/null
+++ b/MagickCrop/Controls/RgbHistogramControl.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MagickCrop/Controls/RgbHistogramControl.xaml.cs b/MagickCrop/Controls/RgbHistogramControl.xaml.cs
new file mode 100644
index 0000000..e22402a
--- /dev/null
+++ b/MagickCrop/Controls/RgbHistogramControl.xaml.cs
@@ -0,0 +1,188 @@
+using ImageMagick;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Shapes;
+
+namespace MagickCrop.Controls;
+
+public partial class RgbHistogramControl : UserControl
+{
+ private long[]? _rBins, _gBins, _bBins;
+ private CancellationTokenSource? _cts;
+ private bool _isThresholdActive;
+
+ public bool IsThresholdActive
+ {
+ get => _isThresholdActive;
+ set { _isThresholdActive = value; UpdateThresholdLine(); }
+ }
+
+ public static readonly DependencyProperty ImagePathProperty =
+ DependencyProperty.Register(
+ nameof(ImagePath),
+ typeof(string),
+ typeof(RgbHistogramControl),
+ new PropertyMetadata(null, OnImagePathChanged));
+
+ public string? ImagePath
+ {
+ get => (string?)GetValue(ImagePathProperty);
+ set => SetValue(ImagePathProperty, value);
+ }
+
+ public static readonly DependencyProperty ThresholdValueProperty =
+ DependencyProperty.Register(
+ nameof(ThresholdValue),
+ typeof(double),
+ typeof(RgbHistogramControl),
+ new PropertyMetadata(128.0, OnThresholdValueChanged));
+
+ public double ThresholdValue
+ {
+ get => (double)GetValue(ThresholdValueProperty);
+ set => SetValue(ThresholdValueProperty, value);
+ }
+
+ public RgbHistogramControl()
+ {
+ InitializeComponent();
+ }
+
+ private static void OnImagePathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ ((RgbHistogramControl)d).LoadHistogramAsync((string?)e.NewValue);
+ }
+
+ private static void OnThresholdValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ ((RgbHistogramControl)d).UpdateThresholdLine();
+ }
+
+ private async void LoadHistogramAsync(string? path)
+ {
+ _cts?.Cancel();
+ _cts?.Dispose();
+ _cts = new CancellationTokenSource();
+ CancellationToken token = _cts.Token;
+
+ if (string.IsNullOrEmpty(path) || !File.Exists(path))
+ {
+ _rBins = _gBins = _bBins = null;
+ Render();
+ return;
+ }
+
+ long[] r = new long[256], g = new long[256], b = new long[256];
+
+ try
+ {
+ await Task.Run(() =>
+ {
+ using MagickImage image = new(path);
+ // Resize to limit memory and speed up computation
+ if (image.Width > 512 || image.Height > 512)
+ image.Resize(new MagickGeometry(512, 512) { Greater = true });
+
+ // ToByteArray scales Q16 values to 0-255 per channel
+ byte[]? data = image.GetPixelsUnsafe().ToByteArray(PixelMapping.RGB);
+ if (data is null) return;
+
+ for (int i = 0; i + 2 < data.Length; i += 3)
+ {
+ token.ThrowIfCancellationRequested();
+ r[data[i]]++;
+ g[data[i + 1]]++;
+ b[data[i + 2]]++;
+ }
+ }, token);
+ }
+ catch (OperationCanceledException) { return; }
+
+ // A newer load may have started while this one was finishing
+ if (token.IsCancellationRequested)
+ return;
+
+ _rBins = r;
+ _gBins = g;
+ _bBins = b;
+ Render();
+ }
+
+ private void Render()
+ {
+ HistogramCanvas.Children.Clear();
+
+ if (_rBins is null)
+ {
+ NoImageText.Visibility = Visibility.Visible;
+ return;
+ }
+
+ NoImageText.Visibility = Visibility.Collapsed;
+
+ double w = HistogramCanvas.ActualWidth;
+ double h = HistogramCanvas.ActualHeight;
+ if (w <= 0 || h <= 0) return;
+
+ long max = 0;
+ for (int i = 0; i < 256; i++)
+ {
+ max = Math.Max(max, _rBins[i]);
+ max = Math.Max(max, _gBins![i]);
+ max = Math.Max(max, _bBins![i]);
+ }
+ if (max == 0) return;
+
+ DrawChannel(_rBins, Color.FromArgb(80, 220, 50, 50), Color.FromArgb(180, 220, 50, 50), w, h, max);
+ DrawChannel(_gBins!, Color.FromArgb(80, 50, 200, 80), Color.FromArgb(180, 50, 200, 80), w, h, max);
+ DrawChannel(_bBins!, Color.FromArgb(80, 50, 120, 220), Color.FromArgb(180, 50, 120, 220), w, h, max);
+
+ UpdateThresholdLine();
+ }
+
+ private void UpdateThresholdLine()
+ {
+ double w = HistogramCanvas.ActualWidth;
+ if (_rBins is null || !_isThresholdActive || w <= 0)
+ {
+ ThresholdIndicator.Visibility = Visibility.Collapsed;
+ return;
+ }
+ double x = ThresholdValue / 255.0 * w;
+ ThresholdIndicator.Margin = new Thickness(x, 0, 0, 0);
+ ThresholdIndicator.Visibility = Visibility.Visible;
+ }
+
+ private void DrawChannel(long[] bins, Color fillColor, Color strokeColor, double w, double h, long max)
+ {
+ var points = new PointCollection(258)
+ {
+ new Point(0, h)
+ };
+ for (int i = 0; i < 256; i++)
+ {
+ double x = i / 255.0 * w;
+ double y = h - (bins[i] / (double)max * h);
+ points.Add(new Point(x, y));
+ }
+ points.Add(new Point(w, h));
+
+ var polygon = new Polygon
+ {
+ Points = points,
+ Fill = new SolidColorBrush(fillColor),
+ Stroke = new SolidColorBrush(strokeColor),
+ StrokeThickness = 1,
+ IsHitTestVisible = false,
+ };
+ HistogramCanvas.Children.Add(polygon);
+ }
+
+ private void HistogramCanvas_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ Render();
+ UpdateThresholdLine();
+ }
+}
diff --git a/MagickCrop/Helpers/WhiteboardInkConverter.cs b/MagickCrop/Helpers/WhiteboardInkConverter.cs
new file mode 100644
index 0000000..3b6e580
--- /dev/null
+++ b/MagickCrop/Helpers/WhiteboardInkConverter.cs
@@ -0,0 +1,550 @@
+using Emgu.CV;
+using Emgu.CV.CvEnum;
+using Emgu.CV.Structure;
+using Emgu.CV.Util;
+using System.Drawing;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Windows.Ink;
+using System.Windows.Input;
+using WpfColor = System.Windows.Media.Color;
+
+namespace MagickCrop.Helpers;
+
+public static class WhiteboardInkConverter
+{
+ private const int MinComponentPixels = 50;
+ private const double SimplifyEpsilon = 2.0;
+ private const int DefaultSpeckleMinArea = 250;
+
+ // HSV saturation threshold: pixels with S > this are considered colored ink
+ // (blue/red/green markers on white; white background has S ≈ 0)
+ private const int SaturationThreshold = 30;
+
+ public static async Task> ConvertToStrokesAsync(
+ string imagePath, double displayWidth, double displayHeight)
+ {
+ return await Task.Run(() => Convert(imagePath, displayWidth, displayHeight));
+ }
+
+ ///
+ /// Removes small isolated marks (speckles/dirt) from a whiteboard image.
+ /// Detects marks via HSV saturation (colored ink) and grayscale darkness.
+ /// Returns the path to a cleaned temp PNG, or null if no speckles were found.
+ ///
+ public static async Task RemoveSpecklesAsync(string imagePath, int minArea = DefaultSpeckleMinArea)
+ {
+ return await Task.Run(() => RemoveSpeckles(imagePath, minArea));
+ }
+
+ ///
+ /// Estimates the dominant stroke width in image pixels using the distance transform
+ /// at skeleton pixels (75th-percentile diameter). Useful for diagnosing why conversion
+ /// produces strokes that are too thick or too thin.
+ ///
+ public static async Task DetectStrokeWidthAsync(string imagePath)
+ {
+ return await Task.Run(() => DetectStrokeWidth(imagePath));
+ }
+
+ ///
+ /// Fills hollow interiors of thick whiteboard marker strokes so each stroke is a solid
+ /// blob before skeletonization. Thick markers often produce "ring" shaped regions in the
+ /// binary image; this preprocessing step corrects that so the centerline is found cleanly.
+ /// Returns the path to a modified temp PNG, or null if no hollow regions were found.
+ ///
+ public static async Task FillHollowStrokesAsync(string imagePath)
+ {
+ return await Task.Run(() => FillHollowStrokes(imagePath));
+ }
+
+ // Builds a binary mask where white = ink stroke, black = background.
+ // Detects BOTH colored markers (via HSV saturation) and dark markers (via adaptive
+ // grayscale threshold), then OR-combines them. This is critical for colored markers
+ // (blue, red, etc.) which are not "dark" in grayscale and are missed by the gray-only path.
+ private static Mat CreateStrokeMask(Mat bgr)
+ {
+ using Mat hsv = new();
+ CvInvoke.CvtColor(bgr, hsv, ColorConversion.Bgr2Hsv);
+
+ Mat[] hsvChannels = hsv.Split();
+ using Mat satChannel = hsvChannels[1]; // S: 0=gray/white, 255=fully saturated color
+ using Mat valChannel = hsvChannels[2]; // V: brightness
+ hsvChannels[0].Dispose();
+
+ // Colored strokes: pixels with meaningful saturation
+ using Mat satMask = new();
+ CvInvoke.Threshold(satChannel, satMask, SaturationThreshold, 255, ThresholdType.Binary);
+
+ // Exclude pixels that are near-pure-white (background bleeds through at S boundary)
+ using Mat whiteMask = new();
+ CvInvoke.Threshold(valChannel, whiteMask, 245, 255, ThresholdType.Binary);
+ using Mat notWhite = new();
+ CvInvoke.BitwiseNot(whiteMask, notWhite);
+
+ using Mat coloredStrokes = new();
+ CvInvoke.BitwiseAnd(satMask, notWhite, coloredStrokes);
+
+ // Dark strokes: adaptive threshold on grayscale (catches black markers and edges)
+ using Mat gray = new();
+ CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray);
+ using Mat darkMask = new();
+ CvInvoke.AdaptiveThreshold(gray, darkMask, 255,
+ AdaptiveThresholdType.GaussianC, ThresholdType.BinaryInv, 11, 5);
+
+ // Combined: colored OR dark
+ Mat combined = new();
+ CvInvoke.BitwiseOr(coloredStrokes, darkMask, combined);
+ return combined;
+ }
+
+ private static string? RemoveSpeckles(string imagePath, int minArea)
+ {
+ using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor);
+ if (bgr.IsEmpty) return null;
+
+ using Mat binary = CreateStrokeMask(bgr);
+
+ using Mat labels = new();
+ int numLabels = CvInvoke.ConnectedComponents(binary, labels, LineType.EightConnected, DepthType.Cv32S);
+
+ int rows = labels.Rows;
+ int cols = labels.Cols;
+ int labelStep = labels.Step;
+ byte[] labelsData = new byte[rows * labelStep];
+ Marshal.Copy(labels.DataPointer, labelsData, 0, labelsData.Length);
+
+ int[] areaCounts = new int[numLabels];
+ for (int y = 0; y < rows; y++)
+ {
+ int ry = y * labelStep;
+ for (int x = 0; x < cols; x++)
+ {
+ int lbl = BitConverter.ToInt32(labelsData, ry + x * 4);
+ if (lbl > 0) areaCounts[lbl]++;
+ }
+ }
+
+ bool[] isSpeckle = new bool[numLabels];
+ bool anySpeckle = false;
+ for (int i = 1; i < numLabels; i++)
+ {
+ if (areaCounts[i] < minArea)
+ {
+ isSpeckle[i] = true;
+ anySpeckle = true;
+ }
+ }
+
+ if (!anySpeckle) return null;
+
+ // Estimate background color from near-white pixels
+ using Mat gray = new();
+ CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray);
+ using Mat lightMask = new();
+ CvInvoke.Threshold(gray, lightMask, 200, 255, ThresholdType.Binary);
+ MCvScalar bgColor = CvInvoke.Mean(bgr, lightMask);
+
+ using Mat speckleMask = new(bgr.Size, DepthType.Cv8U, 1);
+ speckleMask.SetTo(new MCvScalar(0));
+ int mStep = speckleMask.Step;
+ byte[] maskData = new byte[rows * mStep];
+
+ for (int y = 0; y < rows; y++)
+ {
+ int ry = y * labelStep;
+ int my = y * mStep;
+ for (int x = 0; x < cols; x++)
+ {
+ int lbl = BitConverter.ToInt32(labelsData, ry + x * 4);
+ if (lbl > 0 && isSpeckle[lbl])
+ maskData[my + x] = 255;
+ }
+ }
+ Marshal.Copy(maskData, 0, speckleMask.DataPointer, maskData.Length);
+
+ using Mat result = bgr.Clone();
+ result.SetTo(bgColor, speckleMask);
+
+ string tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".png");
+ CvInvoke.Imwrite(tempPath, result);
+ return tempPath;
+ }
+
+ private static double DetectStrokeWidth(string imagePath)
+ {
+ using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor);
+ if (bgr.IsEmpty) return 0;
+
+ using Mat binary = CreateStrokeMask(bgr);
+
+ using Mat morphKernel = CvInvoke.GetStructuringElement(
+ MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1));
+ using Mat closed = new();
+ CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel,
+ new Point(-1, -1), 1, BorderType.Default, new MCvScalar());
+
+ using Mat skeleton = Skeletonize(closed);
+ using Mat dist = new();
+ CvInvoke.DistanceTransform(closed, dist, null, DistType.L2, 5);
+
+ int rows = skeleton.Rows, cols = skeleton.Cols;
+ int skelStep = skeleton.Step, distStep = dist.Step;
+
+ byte[] skelData = new byte[rows * skelStep];
+ Marshal.Copy(skeleton.DataPointer, skelData, 0, skelData.Length);
+ byte[] distData = new byte[rows * distStep];
+ Marshal.Copy(dist.DataPointer, distData, 0, distData.Length);
+
+ List radii = [];
+ for (int y = 0; y < rows; y++)
+ {
+ int sy = y * skelStep, dy = y * distStep;
+ for (int x = 0; x < cols; x++)
+ {
+ if (skelData[sy + x] == 0) continue;
+ float r = BitConverter.ToSingle(distData, dy + x * 4);
+ if (r > 0.5f) radii.Add(r);
+ }
+ }
+
+ if (radii.Count == 0) return 3.0;
+ radii.Sort();
+ return Math.Round(radii[(int)(radii.Count * 0.75)] * 2.0, 1);
+ }
+
+ private static string? FillHollowStrokes(string imagePath)
+ {
+ using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor);
+ if (bgr.IsEmpty) return null;
+
+ using Mat binary = CreateStrokeMask(bgr);
+
+ using Mat morphKernel = CvInvoke.GetStructuringElement(
+ MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1));
+ using Mat closed = new();
+ CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel,
+ new Point(-1, -1), 1, BorderType.Default, new MCvScalar());
+
+ // RETR_EXTERNAL finds only outermost contours; filling them covers hollow interiors
+ using VectorOfVectorOfPoint contours = new();
+ using Mat hierarchy = new();
+ CvInvoke.FindContours(closed, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple);
+
+ using Mat filledMask = new(bgr.Size, DepthType.Cv8U, 1);
+ filledMask.SetTo(new MCvScalar(0));
+ CvInvoke.DrawContours(filledMask, contours, -1, new MCvScalar(255), -1);
+
+ // Hollow pixels: inside a filled contour but not in the original mask
+ using Mat notClosed = new();
+ CvInvoke.BitwiseNot(closed, notClosed);
+ using Mat hollowMask = new();
+ CvInvoke.BitwiseAnd(filledMask, notClosed, hollowMask);
+
+ if (CvInvoke.CountNonZero(hollowMask) == 0) return null;
+
+ MCvScalar inkColor = CvInvoke.Mean(bgr, closed);
+
+ using Mat result = bgr.Clone();
+ result.SetTo(inkColor, hollowMask);
+
+ string tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".png");
+ CvInvoke.Imwrite(tempPath, result);
+ return tempPath;
+ }
+
+ private static List Convert(string imagePath, double displayWidth, double displayHeight)
+ {
+ using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor);
+ if (bgr.IsEmpty) return [];
+
+ WpfColor penColor = EstimatePenColor(bgr);
+
+ using Mat binary = CreateStrokeMask(bgr);
+
+ using Mat morphKernel = CvInvoke.GetStructuringElement(
+ MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1));
+ using Mat closed = new();
+ CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel,
+ new Point(-1, -1), 1, BorderType.Default, new MCvScalar());
+
+ using Mat skeleton = Skeletonize(closed);
+
+ using Mat dist = new();
+ CvInvoke.DistanceTransform(closed, dist, null, DistType.L2, 5);
+
+ double strokeThickness = EstimateStrokeThickness(dist, skeleton, displayWidth / bgr.Width);
+
+ using Mat labels = new();
+ int numLabels = CvInvoke.ConnectedComponents(
+ skeleton, labels, LineType.EightConnected, DepthType.Cv32S);
+
+ double scaleX = displayWidth / bgr.Width;
+ double scaleY = displayHeight / bgr.Height;
+
+ Dictionary> components = CollectComponents(labels, numLabels);
+ List result = [];
+
+ foreach ((int _, List? pixels) in components)
+ {
+ if (pixels.Count < MinComponentPixels) continue;
+
+ foreach (List path in ExtractPathsFromSkeleton(pixels))
+ {
+ List simplified = SimplifyPath(path, SimplifyEpsilon);
+ if (simplified.Count < 2) continue;
+
+ StylusPointCollection spc = new(
+ simplified.Select(p => new StylusPoint(p.X * scaleX, p.Y * scaleY, 0.5f)));
+
+ DrawingAttributes attrs = new()
+ {
+ Color = penColor,
+ Width = strokeThickness,
+ Height = strokeThickness,
+ StylusTip = StylusTip.Ellipse,
+ };
+
+ result.Add(new Stroke(spc, attrs));
+ }
+ }
+
+ return result;
+ }
+
+ // Samples colored (high-saturation) pixels to estimate ink color.
+ // Falls back to dark-pixel detection for black/gray markers.
+ private static WpfColor EstimatePenColor(Mat bgr)
+ {
+ using Mat hsv = new();
+ CvInvoke.CvtColor(bgr, hsv, ColorConversion.Bgr2Hsv);
+
+ Mat[] hsvChannels = hsv.Split();
+ using Mat satChannel = hsvChannels[1];
+ using Mat valChannel = hsvChannels[2];
+ hsvChannels[0].Dispose();
+
+ using Mat satMask = new();
+ CvInvoke.Threshold(satChannel, satMask, SaturationThreshold + 10, 255, ThresholdType.Binary);
+
+ // Exclude very bright pixels (background bleed-through near edges)
+ using Mat whiteMask = new();
+ CvInvoke.Threshold(valChannel, whiteMask, 240, 255, ThresholdType.Binary);
+ using Mat notWhite = new();
+ CvInvoke.BitwiseNot(whiteMask, notWhite);
+ using Mat strokeMask = new();
+ CvInvoke.BitwiseAnd(satMask, notWhite, strokeMask);
+
+ if (CvInvoke.CountNonZero(strokeMask) >= 500)
+ {
+ MCvScalar mean = CvInvoke.Mean(bgr, strokeMask);
+ return WpfColor.FromRgb(
+ (byte)Math.Clamp(mean.V2, 0, 255),
+ (byte)Math.Clamp(mean.V1, 0, 255),
+ (byte)Math.Clamp(mean.V0, 0, 255));
+ }
+
+ // Fallback for black/gray markers: sample dark pixels
+ using Mat gray = new();
+ CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray);
+ using Mat darkMask = new();
+ CvInvoke.Threshold(gray, darkMask, 76, 255, ThresholdType.BinaryInv);
+ MCvScalar darkMean = CvInvoke.Mean(bgr, darkMask);
+ return WpfColor.FromRgb(
+ (byte)Math.Clamp(darkMean.V2, 0, 255),
+ (byte)Math.Clamp(darkMean.V1, 0, 255),
+ (byte)Math.Clamp(darkMean.V0, 0, 255));
+ }
+
+ // Collects the distance-transform value at every skeleton pixel, sorts them, and returns
+ // the 75th-percentile value × 2 as the stroke diameter.
+ private static double EstimateStrokeThickness(Mat dist, Mat skeleton, double imageToDisplayScale)
+ {
+ int rows = skeleton.Rows;
+ int cols = skeleton.Cols;
+ int skelStep = skeleton.Step;
+ int distStep = dist.Step;
+
+ byte[] skelData = new byte[rows * skelStep];
+ Marshal.Copy(skeleton.DataPointer, skelData, 0, skelData.Length);
+
+ byte[] distData = new byte[rows * distStep];
+ Marshal.Copy(dist.DataPointer, distData, 0, distData.Length);
+
+ List radii = [];
+ for (int y = 0; y < rows; y++)
+ {
+ int sy = y * skelStep;
+ int dy = y * distStep;
+ for (int x = 0; x < cols; x++)
+ {
+ if (skelData[sy + x] == 0) continue;
+ float r = BitConverter.ToSingle(distData, dy + x * 4);
+ if (r > 0.5f) radii.Add(r);
+ }
+ }
+
+ if (radii.Count == 0) return 3.0 * imageToDisplayScale;
+
+ radii.Sort();
+ float p75 = radii[(int)(radii.Count * 0.75)];
+
+ return Math.Clamp(p75 * 2.0 * imageToDisplayScale, 1.5, 50.0);
+ }
+
+ private static Mat Skeletonize(Mat binary)
+ {
+ using Mat crossKernel = CvInvoke.GetStructuringElement(
+ MorphShapes.Cross, new Size(3, 3), new Point(1, 1));
+
+ Mat skeleton = new(binary.Size, DepthType.Cv8U, 1);
+ skeleton.SetTo(new MCvScalar(0));
+
+ Mat remaining = binary.Clone();
+ using Mat eroded = new();
+ using Mat temp = new();
+
+ try
+ {
+ while (CvInvoke.CountNonZero(remaining) > 0)
+ {
+ CvInvoke.Erode(remaining, eroded, crossKernel,
+ new Point(-1, -1), 1, BorderType.Default, new MCvScalar());
+ CvInvoke.Dilate(eroded, temp, crossKernel,
+ new Point(-1, -1), 1, BorderType.Default, new MCvScalar());
+ CvInvoke.Subtract(remaining, temp, temp);
+ CvInvoke.BitwiseOr(skeleton, temp, skeleton);
+ eroded.CopyTo(remaining);
+ }
+ }
+ finally
+ {
+ remaining.Dispose();
+ }
+
+ return skeleton;
+ }
+
+ private static Dictionary> CollectComponents(Mat labels, int numLabels)
+ {
+ int rows = labels.Rows;
+ int cols = labels.Cols;
+ int step = labels.Step;
+
+ byte[] rawData = new byte[rows * step];
+ Marshal.Copy(labels.DataPointer, rawData, 0, rawData.Length);
+
+ Dictionary> components = new(numLabels);
+ for (int y = 0; y < rows; y++)
+ {
+ int rowOffset = y * step;
+ for (int x = 0; x < cols; x++)
+ {
+ int lbl = BitConverter.ToInt32(rawData, rowOffset + x * 4);
+ if (lbl <= 0) continue;
+ if (!components.TryGetValue(lbl, out List? pts))
+ {
+ pts = [];
+ components[lbl] = pts;
+ }
+ pts.Add(new Point(x, y));
+ }
+ }
+
+ return components;
+ }
+
+ // Extracts ordered path segments from a skeleton connected component by traversing
+ // the pixel adjacency graph. Splits at junction pixels (3+ neighbors) so each
+ // returned list is a smooth, non-branching sequence of pixels suitable for a stroke.
+ private static List> ExtractPathsFromSkeleton(List pixels)
+ {
+ if (pixels.Count < 2) return [];
+
+ HashSet pixelSet = [.. pixels];
+
+ List GetNeighbors(Point p)
+ {
+ List ns = [];
+ for (int dy = -1; dy <= 1; dy++)
+ for (int dx = -1; dx <= 1; dx++)
+ {
+ if (dx == 0 && dy == 0) continue;
+ Point n = new(p.X + dx, p.Y + dy);
+ if (pixelSet.Contains(n)) ns.Add(n);
+ }
+ return ns;
+ }
+
+ Dictionary> adj = new(pixels.Count);
+ foreach (Point p in pixels)
+ adj[p] = GetNeighbors(p);
+
+ List endpoints = [.. pixels.Where(p => adj[p].Count == 1)];
+ HashSet junctions = [.. pixels.Where(p => adj[p].Count >= 3)];
+
+ HashSet<(Point, Point)> used = [];
+ List> paths = [];
+
+ void Follow(Point start, Point first)
+ {
+ if (used.Contains((start, first))) return;
+
+ List path = [start, first];
+ used.Add((start, first));
+ used.Add((first, start));
+
+ Point prev = start;
+ Point cur = first;
+
+ while (adj[cur].Count == 2)
+ {
+ Point next = default;
+ bool found = false;
+ foreach (Point n in adj[cur])
+ {
+ if (n == prev || used.Contains((cur, n))) continue;
+ next = n;
+ found = true;
+ break;
+ }
+ if (!found) break;
+
+ used.Add((cur, next));
+ used.Add((next, cur));
+ path.Add(next);
+ prev = cur;
+ cur = next;
+ }
+
+ if (path.Count >= 3)
+ paths.Add(path);
+ }
+
+ foreach (Point ep in endpoints)
+ foreach (Point n in adj[ep])
+ Follow(ep, n);
+
+ foreach (Point junc in junctions)
+ foreach (Point n in adj[junc])
+ Follow(junc, n);
+
+ // Catch isolated loops (all pixels degree 2, no endpoints or junctions)
+ foreach (Point p in pixels)
+ foreach (Point n in adj[p])
+ if (!used.Contains((p, n)))
+ Follow(p, n);
+
+ return paths;
+ }
+
+ private static List SimplifyPath(List points, double epsilon)
+ {
+ if (points.Count <= 2) return points;
+
+ using VectorOfPoint input = new([.. points]);
+ using VectorOfPoint approx = new();
+ CvInvoke.ApproxPolyDP(input, approx, epsilon, false);
+ return [.. approx.ToArray()];
+ }
+}
diff --git a/MagickCrop/MagickCrop.csproj b/MagickCrop/MagickCrop.csproj
index 9dbdcb6..111fdd9 100644
--- a/MagickCrop/MagickCrop.csproj
+++ b/MagickCrop/MagickCrop.csproj
@@ -15,7 +15,7 @@
win-arm64;win-x86;win-x64
false
true
- 1.11.0
+ 1.12.0
true
false
false
@@ -40,16 +40,16 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml
index 1bdc377..2dbb5c3 100644
--- a/MagickCrop/MainWindow.xaml
+++ b/MagickCrop/MainWindow.xaml
@@ -121,6 +121,17 @@
IsEnabled="False"
IsHitTestVisible="False"
Visibility="Collapsed" />
+
+ Visibility="{Binding HasOpenedFileName,
+ Converter={StaticResource BoolToVis}}" />
+
+
+