histogram implemented

This commit is contained in:
EugeneTes
2026-04-08 13:10:34 +02:00
parent 931de47164
commit 2be13501fa
11 changed files with 805 additions and 125 deletions

View File

@@ -6,12 +6,32 @@
Title="Leerform Calibration"
Width="1024" Height="800">
<Window.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="White" />
<Setter Property="Foreground" Value="Red" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#FFEBEE" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="TextBlock.Foreground" Value="Red" />
</Style>
<Style Selector="Button:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#FFCDD2" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="TextBlock.Foreground" Value="Red" />
</Style>
</Window.Styles>
<DockPanel>
<!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="8">
<Button Content="Start Preview" Command="{Binding StartPreviewCommand}" IsEnabled="{Binding !IsPreviewRunning}" />
<Button Content="Stop Preview" Command="{Binding StopPreviewCommand}" IsEnabled="{Binding IsPreviewRunning}" />
<Button Content="Capture Image" Command="{Binding CaptureImageCommand}" />
<Button Content="START PREVIEW" Command="{Binding StartPreviewCommand}" IsEnabled="{Binding !IsPreviewRunning}" />
<Button Content="STOP PREVIEW" Command="{Binding StopPreviewCommand}" IsEnabled="{Binding IsPreviewRunning}" />
<Button Content="CAPTURE IMAGE" Command="{Binding CaptureImageCommand}" />
</StackPanel>
<!-- Status Bar -->
@@ -29,17 +49,14 @@
</Border>
<!-- Settings Panel -->
<Border DockPanel.Dock="Right" Width="260" Padding="12" Background="White">
<ScrollViewer>
<StackPanel Spacing="8">
<Border DockPanel.Dock="Right" Width="290" Padding="12" Background="White">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="8" Margin="0,0,20,0">
<TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="Black" />
<Button Content="Calibrate" Command="{Binding RunCalibrationCommand}" Margin="0,4"
Background="Red" Foreground="White" FontWeight="Bold" />
<Button Content="Clear Images" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4"
Background="White" Foreground="Red" FontWeight="Bold"
BorderBrush="Red" BorderThickness="1" />
<Button Content="CALIBRATE" Command="{Binding RunCalibrationCommand}" Margin="0,4" />
<Button Content="CLEAR IMAGES" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4" />
<Separator Margin="0,8" />
@@ -85,15 +102,12 @@
<Separator Margin="0,8" />
<Button Content="Set Reference" Command="{Binding SetReferenceHistogramCommand}" Margin="0,4"
Background="#2E7D32" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" />
<TextBlock Text="✓ Reference set" Foreground="LimeGreen" IsVisible="{Binding HasReferenceHistogram}" />
<Button Content="SET REFERENCE (RESETS SPLINES)" Command="{Binding SetReferenceHistogramCommand}" Margin="0,4"
HorizontalAlignment="Stretch" />
<TextBlock Text="✓ Average spline set" Foreground="LimeGreen" IsVisible="{Binding HasAverageSpline}" />
<TextBlock Text="Tolerance" Foreground="Black" Margin="0,4,0,0" />
<NumericUpDown Value="{Binding Tolerance}" Minimum="0" Maximum="1" Increment="0.05" FormatString="0.00" />
<Button Content="Test Pattern" Command="{Binding TestPatternCommand}" Margin="0,4"
Background="#1565C0" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" />
<Button Content="TEST PATTERN" Command="{Binding TestPatternCommand}" Margin="0,4"
HorizontalAlignment="Stretch" />
<Separator Margin="0,8" />
@@ -105,8 +119,8 @@
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<Button Content="Save Recipe" Command="{Binding SaveRecipeCommand}" Margin="0,4"
Background="#6A1B9A" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" />
<Button Content="SAVE RECIPE" Command="{Binding SaveRecipeCommand}" Margin="0,4"
HorizontalAlignment="Stretch" />
</StackPanel>
</ScrollViewer>
</Border>

View File

@@ -1,26 +1,43 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LindtLeerformPlugin.ViewModels"
xmlns:v="using:LindtLeerformPlugin.Views"
x:Class="LindtLeerformPlugin.Views.CellHistogramWindow"
x:DataType="vm:CellHistogramViewModel"
Title="{Binding Title}"
Width="720" Height="600"
Width="820" Height="560"
Background="#1A1A1A"
WindowStartupLocation="CenterOwner">
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
CanResize="True">
<ScrollViewer>
<StackPanel Spacing="12" Margin="16">
<TextBlock Text="Reference (learned)" FontWeight="Bold" Foreground="White" FontSize="14" />
<Border Background="#0A0A0A" Padding="4">
<Image Source="{Binding ReferenceImage}" Stretch="Uniform" Height="220" />
</Border>
<TextBlock Text="Selected cell" FontWeight="Bold" Foreground="White" FontSize="14" Margin="0,8,0,0" />
<Border Background="#0A0A0A" Padding="4">
<Image Source="{Binding CellImage}" Stretch="Uniform" Height="220" />
</Border>
<TextBlock Text="{Binding DistanceText}" Foreground="LightGray" FontSize="13" Margin="0,8,0,0" />
<DockPanel Margin="12">
<StackPanel DockPanel.Dock="Top" Spacing="4" Margin="0,0,0,8">
<TextBlock Text="Drag the gray dashed handles to edit the AVERAGE spline. Use 'Override Cell' to give this cell its own spline."
Foreground="LightGray" FontSize="12" TextWrapping="Wrap" />
<TextBlock Text="A cell is BAD when any histogram bar rises above its active spline."
Foreground="#888888" FontSize="11" TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8" Margin="0,12,0,0">
<Button Content="Override Cell"
Command="{Binding CreateOverrideCommand}"
IsVisible="{Binding !HasOverride}"
Background="#FF8C00" Foreground="Black" FontWeight="Bold" />
<Button Content="Remove Override"
Command="{Binding RemoveOverrideCommand}"
IsVisible="{Binding HasOverride}"
Background="#8B0000" Foreground="White" FontWeight="Bold" />
<TextBlock Text="{Binding VerdictText}" Foreground="LightGray"
VerticalAlignment="Center" FontSize="13" />
</StackPanel>
<Border Background="#0A0A0A" Padding="2">
<v:SplineHistogramEditor x:Name="Editor"
Histogram="{Binding CellHistogram}"
CellSpline="{Binding CellSpline, Mode=TwoWay}"
AverageSpline="{Binding AverageSpline, Mode=TwoWay}"
Bins="{Binding Bins}" />
</Border>
</DockPanel>
</Window>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using LindtLeerformPlugin.ViewModels;
namespace LindtLeerformPlugin.Views;
@@ -7,5 +8,7 @@ public partial class CellHistogramWindow : Window
public CellHistogramWindow()
{
InitializeComponent();
Editor.SplineDragCompleted += (_, _) =>
(DataContext as CellHistogramViewModel)?.RaiseDragCompleted();
}
}

View File

@@ -0,0 +1,305 @@
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);
}
}
}