From 1e6d46c91c129fc48ef308f104f99831e098a978 Mon Sep 17 00:00:00 2001 From: lainiao Date: Sat, 16 May 2026 16:39:02 +0800 Subject: [PATCH] Add oval ROI tool --- src/ImageJCsharp.App/Form1.cs | 45 +++++++++++++--- src/ImageJCsharp.App/RoiShape.cs | 7 +++ src/ImageJCsharp.Core/Measurements.cs | 54 +++++++++++++++++-- src/ImageJCsharp.Core/OvalRoi.cs | 47 ++++++++++++++++ .../FormStartupTests.cs | 17 ++++++ .../ImageJCsharp.Core.Tests/CoreImageTests.cs | 19 +++++++ 6 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 src/ImageJCsharp.App/RoiShape.cs create mode 100644 src/ImageJCsharp.Core/OvalRoi.cs diff --git a/src/ImageJCsharp.App/Form1.cs b/src/ImageJCsharp.App/Form1.cs index 96fab6f..8994b1a 100644 --- a/src/ImageJCsharp.App/Form1.cs +++ b/src/ImageJCsharp.App/Form1.cs @@ -23,6 +23,8 @@ public partial class Form1 : Form private ImageDocument? _document; private Bitmap? _displayBitmap; private RectRoi? _roi; + private RoiShape _roiShape = RoiShape.Rectangle; + private RoiShape _selectionTool = RoiShape.Rectangle; private Point? _dragStartImagePoint; private RectRoi? _resizeStartRoi; private RoiResizeHandle _activeResizeHandle = RoiResizeHandle.None; @@ -54,7 +56,8 @@ private void BuildUi() AddItem(file, "E&xit", () => Close(), Keys.Alt | Keys.F4); var edit = AddMenu(menu, "&Edit"); - AddDisabledItem(edit, "No Edit commands yet"); + AddItem(edit, "&Rectangle Selection", () => _selectionTool = RoiShape.Rectangle); + AddItem(edit, "&Oval Selection", () => _selectionTool = RoiShape.Oval); var image = AddMenu(menu, "&Image"); AddActiveImageItem(image, "&Brightness/Contrast...", AdjustBrightnessContrast); @@ -198,6 +201,7 @@ private void OpenImage() using var bitmap = new Bitmap(dialog.FileName); _document = new ImageDocument(dialog.FileName, BitmapConversion.ToGrayImage(bitmap)); _roi = null; + _roiShape = RoiShape.Rectangle; _displayAdjustment = null; _zoom = 1d; RefreshDisplay(); @@ -232,6 +236,7 @@ private void CloseImage() { _document = null; _roi = null; + _roiShape = RoiShape.Rectangle; _displayAdjustment = null; _displayBitmap?.Dispose(); _displayBitmap = null; @@ -291,8 +296,10 @@ private void MeasureCurrentRoi() return; } - var roi = _roi ?? new RectRoi(0, 0, _document.Image.Width, _document.Image.Height); - var result = Measurements.Measure(_document.Image, roi, _document.Calibration); + var fullImageRoi = new RectRoi(0, 0, _document.Image.Width, _document.Image.Height); + var result = _roi is null || _roiShape == RoiShape.Rectangle + ? Measurements.Measure(_document.Image, _roi ?? fullImageRoi, _document.Calibration) + : Measurements.Measure(_document.Image, new OvalRoi(_roi.Value.X, _roi.Value.Y, _roi.Value.Width, _roi.Value.Height), _document.Calibration); _resultsGrid.Rows.Add(CreateMeasurementRow(result)); UpdateCommandStates(); } @@ -440,7 +447,15 @@ private void ShowHistogram() var histogram = _roi is null ? Histogram.Calculate(_document.Image) - : Histogram.Calculate(_document.Image, _roi.Value); + : _roiShape == RoiShape.Rectangle + ? Histogram.Calculate(_document.Image, _roi.Value) + : null; + + if (histogram is null) + { + MessageBox.Show(this, "Histogram currently supports full image or rectangle ROI selections.", "Unsupported ROI", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } var form = new HistogramForm(_document.DisplayName, histogram); form.Show(this); @@ -455,7 +470,15 @@ private void ShowProfile() var profile = _roi is null ? Profile.HorizontalCenterLine(_document.Image) - : Profile.HorizontalCenterLine(_document.Image, _roi.Value); + : _roiShape == RoiShape.Rectangle + ? Profile.HorizontalCenterLine(_document.Image, _roi.Value) + : null; + + if (profile is null) + { + MessageBox.Show(this, "Plot Profile currently supports full image or rectangle ROI selections.", "Unsupported ROI", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } var form = new ProfileForm(_document.DisplayName, profile); form.Show(this); @@ -604,6 +627,7 @@ private void ImageBoxMouseDown(object? sender, MouseEventArgs e) if (_activeResizeHandle == RoiResizeHandle.None) { _resizeStartRoi = null; + _roiShape = _selectionTool; _roi = new RectRoi(_dragStartImagePoint.Value.X, _dragStartImagePoint.Value.Y, 1, 1); } else @@ -661,12 +685,19 @@ private void ImageBoxPaint(object? sender, PaintEventArgs e) var roi = _roi.Value; using var pen = new Pen(Color.Yellow, 1); - e.Graphics.DrawRectangle( - pen, + var bounds = new RectangleF( (float)(roi.X * _zoom), (float)(roi.Y * _zoom), (float)(roi.Width * _zoom), (float)(roi.Height * _zoom)); + if (_roiShape == RoiShape.Oval) + { + e.Graphics.DrawEllipse(pen, bounds); + } + else + { + e.Graphics.DrawRectangle(pen, bounds.X, bounds.Y, bounds.Width, bounds.Height); + } DrawRoiHandles(e.Graphics, roi); } diff --git a/src/ImageJCsharp.App/RoiShape.cs b/src/ImageJCsharp.App/RoiShape.cs new file mode 100644 index 0000000..6e14159 --- /dev/null +++ b/src/ImageJCsharp.App/RoiShape.cs @@ -0,0 +1,7 @@ +namespace ImageJCsharp.App; + +public enum RoiShape +{ + Rectangle, + Oval +} diff --git a/src/ImageJCsharp.Core/Measurements.cs b/src/ImageJCsharp.Core/Measurements.cs index c115f5e..deed85a 100644 --- a/src/ImageJCsharp.Core/Measurements.cs +++ b/src/ImageJCsharp.Core/Measurements.cs @@ -58,20 +58,54 @@ public MeasurementResult(int pixelCount, double area, double mean, double min, d public static class Measurements { public static MeasurementResult Measure(GrayImage image, RectRoi roi, PixelCalibration calibration) + { + return Measure( + image, + roi.X, + roi.Y, + roi.Right, + roi.Bottom, + calibration, + (_, _) => true, + nameof(roi)); + } + + public static MeasurementResult Measure(GrayImage image, OvalRoi roi, PixelCalibration calibration) + { + return Measure( + image, + roi.X, + roi.Y, + roi.Right, + roi.Bottom, + calibration, + roi.ContainsPixel, + nameof(roi)); + } + + private static MeasurementResult Measure( + GrayImage image, + int roiX, + int roiY, + int roiRight, + int roiBottom, + PixelCalibration calibration, + Func containsPixel, + string roiParameterName) { if (image is null) { throw new ArgumentNullException(nameof(image)); } - var startX = Math.Max(0, roi.X); - var startY = Math.Max(0, roi.Y); - var endX = Math.Min(image.Width, roi.Right); - var endY = Math.Min(image.Height, roi.Bottom); + var startX = Math.Max(0, roiX); + var startY = Math.Max(0, roiY); + var endX = Math.Min(image.Width, roiRight); + var endY = Math.Min(image.Height, roiBottom); if (startX >= endX || startY >= endY) { - throw new ArgumentException("ROI does not intersect the image.", nameof(roi)); + throw new ArgumentException("ROI does not intersect the image.", roiParameterName); } var count = 0; @@ -84,6 +118,11 @@ public static MeasurementResult Measure(GrayImage image, RectRoi roi, PixelCalib { for (var x = startX; x < endX; x++) { + if (!containsPixel(x, y)) + { + continue; + } + var value = image[x, y]; count++; sum += value; @@ -93,6 +132,11 @@ public static MeasurementResult Measure(GrayImage image, RectRoi roi, PixelCalib } } + if (count == 0) + { + throw new ArgumentException("ROI does not contain any image pixels.", roiParameterName); + } + var mean = sum / count; var variance = Math.Max(0, (sumSquares / count) - (mean * mean)); var area = count * calibration.PixelWidth * calibration.PixelHeight; diff --git a/src/ImageJCsharp.Core/OvalRoi.cs b/src/ImageJCsharp.Core/OvalRoi.cs new file mode 100644 index 0000000..3856cda --- /dev/null +++ b/src/ImageJCsharp.Core/OvalRoi.cs @@ -0,0 +1,47 @@ +using System; + +namespace ImageJCsharp.Core; + +public readonly struct OvalRoi +{ + public OvalRoi(int x, int y, int width, int height) + { + if (width <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width), "Width must be positive."); + } + + if (height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(height), "Height must be positive."); + } + + X = x; + Y = y; + Width = width; + Height = height; + } + + public int X { get; } + + public int Y { get; } + + public int Width { get; } + + public int Height { get; } + + public int Right => X + Width; + + public int Bottom => Y + Height; + + public bool ContainsPixel(int x, int y) + { + var radiusX = (Width - 1) / 2d; + var radiusY = (Height - 1) / 2d; + var centerX = X + radiusX; + var centerY = Y + radiusY; + var normalizedX = radiusX == 0 ? 0 : (x - centerX) / radiusX; + var normalizedY = radiusY == 0 ? 0 : (y - centerY) / radiusY; + return (normalizedX * normalizedX) + (normalizedY * normalizedY) <= 1d; + } +} diff --git a/tests/ImageJCsharp.App.Tests/FormStartupTests.cs b/tests/ImageJCsharp.App.Tests/FormStartupTests.cs index 40ef0a6..1f85e61 100644 --- a/tests/ImageJCsharp.App.Tests/FormStartupTests.cs +++ b/tests/ImageJCsharp.App.Tests/FormStartupTests.cs @@ -74,6 +74,23 @@ public void Form1DisablesImageCommandsWhenNoImageIsActive() } } + [Fact] + public void EditMenuCanSelectOvalRoiTool() + { + RoiShape? selectedTool = null; + var capturedException = RunOnStaThread(() => + { + using var form = new Form1(); + + FindMenuItem(form, "Edit", "Oval Selection").PerformClick(); + + selectedTool = GetPrivateField(form, "_selectionTool"); + }); + + Assert.Null(capturedException); + Assert.Equal(RoiShape.Oval, selectedTool); + } + [Fact] public void FileCloseClearsActiveImageState() { diff --git a/tests/ImageJCsharp.Core.Tests/CoreImageTests.cs b/tests/ImageJCsharp.Core.Tests/CoreImageTests.cs index bad8849..b8a8524 100644 --- a/tests/ImageJCsharp.Core.Tests/CoreImageTests.cs +++ b/tests/ImageJCsharp.Core.Tests/CoreImageTests.cs @@ -120,6 +120,25 @@ public void MeasureUsesPixelCalibrationForArea() Assert.Equal(4, result.Area); } + [Fact] + public void MeasureUsesOnlyPixelsInsideOvalRoi() + { + var image = GrayImage.FromPixels(3, 3, new ushort[] + { + 1, 2, 3, + 4, 5, 6, + 7, 8, 9 + }); + + var result = Measurements.Measure(image, new OvalRoi(0, 0, 3, 3), PixelCalibration.Identity); + + Assert.Equal(5, result.PixelCount); + Assert.Equal(5, result.Area); + Assert.Equal(5, result.Mean); + Assert.Equal(2, result.Min); + Assert.Equal(8, result.Max); + } + [Fact] public void ThresholdCreatesBinaryMask() {