Files
HawkeyeVision/Plugins/LindtLeerformPlugin/Views/SplineHistogramEditor.cs
2026-04-08 13:10:34 +02:00

306 lines
11 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using LindtLeerformPlugin.Services;
namespace LindtLeerformPlugin.Views;
/// <summary>
/// Custom Avalonia control that draws a histogram as colored bars and overlays an editable
/// average spline (faint dashed gray) plus an optional editable cell spline (bright orange).
/// Five control points per spline; X positions are fixed and only Y is dragged.
/// </summary>
public class SplineHistogramEditor : Control
{
public static readonly StyledProperty<float[]?> HistogramProperty =
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(nameof(Histogram));
public static readonly StyledProperty<float[]?> CellSplineProperty =
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(
nameof(CellSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public static readonly StyledProperty<float[]?> AverageSplineProperty =
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(
nameof(AverageSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public static readonly StyledProperty<int[]?> BinsProperty =
AvaloniaProperty.Register<SplineHistogramEditor, int[]?>(nameof(Bins));
public float[]? Histogram
{
get => GetValue(HistogramProperty);
set => SetValue(HistogramProperty, value);
}
public float[]? CellSpline
{
get => GetValue(CellSplineProperty);
set => SetValue(CellSplineProperty, value);
}
public float[]? AverageSpline
{
get => GetValue(AverageSplineProperty);
set => SetValue(AverageSplineProperty, value);
}
public int[]? Bins
{
get => GetValue(BinsProperty);
set => SetValue(BinsProperty, value);
}
private const double LeftPad = 12;
private const double RightPad = 12;
private const double TopPad = 12;
private const double BottomPad = 18;
private const double HitRadius = 12;
private const double HandleRadius = 6;
private static readonly IBrush BackgroundBrush = new SolidColorBrush(Color.FromRgb(20, 20, 20));
private static readonly IPen AxisPen = new Pen(new SolidColorBrush(Color.FromRgb(80, 80, 80)), 1);
private static readonly IPen AverageSplinePen = new Pen(
new SolidColorBrush(Color.FromRgb(170, 170, 170)), 2,
dashStyle: new DashStyle(new double[] { 4, 4 }, 0));
private static readonly IBrush AverageHandleFill = new SolidColorBrush(Color.FromRgb(220, 220, 220));
private static readonly IPen AverageHandleStroke = new Pen(new SolidColorBrush(Color.FromRgb(80, 80, 80)), 1);
private static readonly IPen CellSplinePen = new Pen(new SolidColorBrush(Color.FromRgb(255, 140, 0)), 2.5);
private static readonly IBrush CellHandleFill = new SolidColorBrush(Color.FromRgb(255, 140, 0));
private static readonly IPen CellHandleStroke = new Pen(Brushes.Black, 1);
static SplineHistogramEditor()
{
AffectsRender<SplineHistogramEditor>(
HistogramProperty, CellSplineProperty, AverageSplineProperty, BinsProperty);
}
public SplineHistogramEditor()
{
// Render() fills the bounds, and Control hit-tests its full Bounds rectangle by
// default, so we just need to be focusable for pointer capture during drag.
Focusable = true;
}
// Cached handle pixel positions, refreshed at every Render(). Indexed [0..4].
private readonly Point[] _avgHandlePixels = new Point[SplineCurve.KnotCount];
private readonly Point[] _cellHandlePixels = new Point[SplineCurve.KnotCount];
private SplineKind _draggingKind = SplineKind.None;
private int _draggingIndex = -1;
/// <summary>Raised on pointer release after a spline handle drag has completed.</summary>
public event EventHandler? SplineDragCompleted;
private enum SplineKind { None, Average, Cell }
public override void Render(DrawingContext ctx)
{
var bounds = new Rect(Bounds.Size);
ctx.FillRectangle(BackgroundBrush, bounds);
var plotRect = new Rect(
LeftPad, TopPad,
Math.Max(1, Bounds.Width - LeftPad - RightPad),
Math.Max(1, Bounds.Height - TopPad - BottomPad));
DrawAxes(ctx, plotRect);
var hist = Histogram;
var bins = Bins;
var binCount = (hist?.Length) ?? (bins is { Length: 3 } ? bins[0] * bins[1] * bins[2] : 0);
// Fixed [0, 1] Y axis — no auto-zoom. Bins and splines are L1-normalised so 1.0
// is the natural ceiling and the user always sees an absolute scale.
const double displayMax = 1.0;
if (hist != null && hist.Length > 0 && bins is { Length: 3 })
DrawBars(ctx, plotRect, hist, bins, displayMax);
if (binCount <= 0)
binCount = 125; // safe default for spline rendering when histogram is missing
DrawSpline(ctx, plotRect, AverageSpline, AverageSplinePen, displayMax, binCount,
_avgHandlePixels, AverageHandleFill, AverageHandleStroke, drawHandles: AverageSpline is { Length: SplineCurve.KnotCount });
DrawSpline(ctx, plotRect, CellSpline, CellSplinePen, displayMax, binCount,
_cellHandlePixels, CellHandleFill, CellHandleStroke, drawHandles: CellSpline is { Length: SplineCurve.KnotCount });
}
private static void DrawAxes(DrawingContext ctx, Rect plot)
{
ctx.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
ctx.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
}
private static void DrawBars(DrawingContext ctx, Rect plot, float[] hist, int[] bins, double displayMax)
{
var totalBins = bins[0] * bins[1] * bins[2];
if (totalBins <= 0) return;
var barWidth = plot.Width / totalBins;
for (var i = 0; i < totalBins && i < hist.Length; i++)
{
var binB = i / (bins[1] * bins[2]);
var binG = (i / bins[2]) % bins[1];
var binR = i % bins[2];
var b = (byte)Math.Min(255, (int)((binB + 0.5) * 256 / bins[0]));
var g = (byte)Math.Min(255, (int)((binG + 0.5) * 256 / bins[1]));
var r = (byte)Math.Min(255, (int)((binR + 0.5) * 256 / bins[2]));
var brush = new SolidColorBrush(Color.FromRgb(r, g, b));
var v = hist[i];
if (v < 0) v = 0;
var h = v / displayMax * plot.Height;
if (h < 0) h = 0;
if (h > plot.Height) h = plot.Height;
var x = plot.Left + i * barWidth;
var y = plot.Bottom - h;
ctx.FillRectangle(brush, new Rect(x, y, Math.Max(1, barWidth), h));
}
}
private void DrawSpline(DrawingContext ctx, Rect plot, float[]? knots, IPen curvePen,
double displayMax, int binCount, Point[] handleCache, IBrush handleFill, IPen handleStroke,
bool drawHandles)
{
if (knots == null || knots.Length < 2 || displayMax <= 0)
{
for (var k = 0; k < handleCache.Length; k++) handleCache[k] = new Point(double.NaN, double.NaN);
return;
}
// Sample the spline along the plot width and stroke a polyline.
var steps = Math.Max(64, (int)plot.Width);
var geometry = new StreamGeometry();
using (var sgc = geometry.Open())
{
for (var s = 0; s <= steps; s++)
{
var t = (double)s / steps;
var y = SplineCurve.Evaluate(knots, t);
var px = plot.Left + t * plot.Width;
var py = plot.Bottom - Math.Clamp(y / displayMax, 0, 1) * plot.Height;
var p = new Point(px, py);
if (s == 0) sgc.BeginFigure(p, false);
else sgc.LineTo(p);
}
sgc.EndFigure(false);
}
ctx.DrawGeometry(null, curvePen, geometry);
// Cache and draw handles at the knot positions.
if (knots.Length <= handleCache.Length)
{
for (var k = 0; k < knots.Length; k++)
{
var t = knots.Length == 1 ? 0.0 : (double)k / (knots.Length - 1);
var px = plot.Left + t * plot.Width;
var py = plot.Bottom - Math.Clamp(knots[k] / displayMax, 0, 1) * plot.Height;
handleCache[k] = new Point(px, py);
if (drawHandles)
{
ctx.DrawEllipse(handleFill, handleStroke, handleCache[k], HandleRadius, HandleRadius);
}
}
}
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var props = e.GetCurrentPoint(this).Properties;
if (!props.IsLeftButtonPressed) return;
var pos = e.GetPosition(this);
// Cell handles take priority over average handles when both overlap.
var hit = HitTest(pos, _cellHandlePixels, requireCellSpline: true);
if (hit >= 0)
{
_draggingKind = SplineKind.Cell;
_draggingIndex = hit;
}
else
{
hit = HitTest(pos, _avgHandlePixels, requireCellSpline: false);
if (hit < 0) return;
_draggingKind = SplineKind.Average;
_draggingIndex = hit;
}
e.Pointer.Capture(this);
ApplyDrag(pos);
e.Handled = true;
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
if (_draggingKind == SplineKind.None) return;
var pos = e.GetPosition(this);
ApplyDrag(pos);
e.Handled = true;
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_draggingKind == SplineKind.None) return;
_draggingKind = SplineKind.None;
_draggingIndex = -1;
if (e.Pointer.Captured == this)
e.Pointer.Capture(null);
e.Handled = true;
SplineDragCompleted?.Invoke(this, EventArgs.Empty);
}
private int HitTest(Point pos, Point[] handles, bool requireCellSpline)
{
if (requireCellSpline && CellSpline is not { Length: SplineCurve.KnotCount }) return -1;
for (var i = 0; i < handles.Length; i++)
{
var h = handles[i];
if (double.IsNaN(h.X)) continue;
var dx = pos.X - h.X;
var dy = pos.Y - h.Y;
if (dx * dx + dy * dy <= HitRadius * HitRadius)
return i;
}
return -1;
}
private void ApplyDrag(Point pos)
{
if (_draggingIndex < 0) return;
var plotRect = new Rect(
LeftPad, TopPad,
Math.Max(1, Bounds.Width - LeftPad - RightPad),
Math.Max(1, Bounds.Height - TopPad - BottomPad));
var rel = (plotRect.Bottom - pos.Y) / plotRect.Height;
var newY = (float)Math.Clamp(rel, 0.0, 1.0);
if (_draggingKind == SplineKind.Average)
{
var current = AverageSpline;
if (current is not { Length: SplineCurve.KnotCount }) return;
var copy = (float[])current.Clone();
copy[_draggingIndex] = newY;
SetCurrentValue(AverageSplineProperty, copy);
}
else if (_draggingKind == SplineKind.Cell)
{
var current = CellSpline;
if (current is not { Length: SplineCurve.KnotCount }) return;
var copy = (float[])current.Clone();
copy[_draggingIndex] = newY;
SetCurrentValue(CellSplineProperty, copy);
}
}
}