diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index fd00e9b1..79c86b86 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -tests: 369 passedtests369 passed +tests: 381 passedtests381 passed diff --git a/code/apps/+imviewer/+plugin/RegionSelector.m b/code/apps/+imviewer/+plugin/RegionSelector.m new file mode 100644 index 00000000..a3831179 --- /dev/null +++ b/code/apps/+imviewer/+plugin/RegionSelector.m @@ -0,0 +1,170 @@ +classdef RegionSelector < imviewer.ImviewerPlugin +%RegionSelector Plugin for placing rectangular regions on an image +% +% This plugin lets the user place rectangular regions on the image of an +% imviewer app, and returns them as rectangles, for example to pick the +% area to crop a stack to or the parts of the field of view that a +% method should work on. +% +% h = imviewer.plugin.RegionSelector(hImviewer) adds the plugin to the +% imviewer app hImviewer. +% +% h = imviewer.plugin.RegionSelector(hImviewer, Name, Value) also +% specifies the RegionSize and the Regions to start with. +% +% The regions are represented as rectangular polygon rois in a roi map, +% which gives the same display and editing as roimanager provides for +% rois. Regions are placed with the rectangleSelect pointer tool and +% edited with the selectObject pointer tool. +% +% See also nansen.stack.selectRectangularRegions, +% roimanager.pointerTool.rectangleSelect + + properties (Constant) % Inherited from applify.mixin.AppPlugin + Name = 'Region Selector' + end + + properties + RegionSize (1,2) double {mustBePositive} = [64, 64] % Size of the region to place, as [width, height] + Regions (:,4) double = zeros(0, 4) % Regions to start with, as rectangles [xmin, ymin, width, height] + end + + properties (Dependent, SetAccess = private) + RegionCount % Number of regions that are currently placed + end + + properties (Access = private) + RoiGroup roimanager.roiGroup + RoiMap roimanager.roiMap + PointerManager uim.interface.pointerManager + end + + methods % Constructor + + function obj = RegionSelector(varargin) + obj@imviewer.ImviewerPlugin(varargin{:}) + + if ~nargout; clear obj; end + end + + function delete(obj) + if ~isempty(obj.RoiMap) && isvalid(obj.RoiMap) + delete(obj.RoiMap) + end + if ~isempty(obj.RoiGroup) && isvalid(obj.RoiGroup) + delete(obj.RoiGroup) + end + end + end + + methods % Set/get + + function regionCount = get.RegionCount(obj) + if isempty(obj.RoiGroup) || ~isvalid(obj.RoiGroup) + regionCount = 0; + else + regionCount = obj.RoiGroup.roiCount; + end + end + end + + methods + + function regions = getRegions(obj) + %getRegions Get the regions that are currently placed + % + % regions = getRegions(obj) returns an N-by-4 array of rectangles + % [xmin, ymin, width, height] of pixel indices, in the order the + % regions were placed. + + regions = rois2rectangles(obj.RoiGroup.roiArray); + end + end + + methods (Access = protected) + + function onPluginActivated(obj) + onPluginActivated@imviewer.ImviewerPlugin(obj) + + obj.initializeRoiMap() + obj.initializePointerTools() + end + end + + methods (Access = private) + + function initializeRoiMap(obj) + %initializeRoiMap Create a roi map to hold and display the regions + + imageSize = [obj.ImviewerObj.imHeight, obj.ImviewerObj.imWidth]; + + obj.RoiGroup = roimanager.roiGroup(); + if ~isempty(obj.Regions) + obj.RoiGroup.addRois( rectangles2rois(obj.Regions, imageSize) ) + end + + obj.RoiMap = roimanager.roiMap(obj.ImviewerObj, ... + obj.ImviewerObj.Axes, obj.RoiGroup); + end + + function initializePointerTools(obj) + %initializePointerTools Add the roi pointer tools to the image viewer + + obj.PointerManager = obj.PrimaryApp.PointerManager; + hAxes = obj.PrimaryApp.Axes; + + obj.PointerManager.initializePointers(hAxes, ... + @roimanager.pointerTool.rectangleSelect) + obj.PointerManager.initializePointers(hAxes, ... + @roimanager.pointerTool.selectObject) + + obj.PointerManager.pointers.rectangleSelect.RoiDisplay = obj.RoiMap; + obj.PointerManager.pointers.selectObject.RoiDisplay = obj.RoiMap; + obj.PointerManager.pointers.rectangleSelect.setRectangleSize(obj.RegionSize) + + % Placing regions is the default mode, so that toggling the + % select tool off with the s key returns to placing regions. + obj.PointerManager.defaultPointerTool = ... + obj.PointerManager.pointers.rectangleSelect; + obj.PointerManager.togglePointerMode('rectangleSelect') + end + end +end + +function rectangles = rois2rectangles(roiArray) +%rois2rectangles Get the bounding rectangle of each roi + + numRois = numel(roiArray); + rectangles = zeros(numRois, 4); + + for i = 1:numRois + xCoordinates = roiArray(i).coordinates(:, 1); + yCoordinates = roiArray(i).coordinates(:, 2); + + xMin = round( min(xCoordinates) ); + yMin = round( min(yCoordinates) ); + width = round( max(xCoordinates) ) - xMin + 1; + height = round( max(yCoordinates) ) - yMin + 1; + + rectangles(i, :) = [xMin, yMin, width, height]; + end +end + +function roiArray = rectangles2rois(rectangles, imageSize) +%rectangles2rois Create one rectangular polygon roi per rectangle + + numRectangles = size(rectangles, 1); + roiArray = RoI.empty; + + for i = 1:numRectangles + xMin = rectangles(i, 1); + yMin = rectangles(i, 2); + xMax = xMin + rectangles(i, 3) - 1; + yMax = yMin + rectangles(i, 4) - 1; + + xCorners = [xMin, xMax, xMax, xMin]; + yCorners = [yMin, yMin, yMax, yMax]; + + roiArray(i) = RoI('Polygon', [xCorners; yCorners], imageSize); + end +end diff --git a/code/datatypes/+nansen/+stack/selectRectangularRegions.m b/code/datatypes/+nansen/+stack/selectRectangularRegions.m new file mode 100644 index 00000000..6a61be71 --- /dev/null +++ b/code/datatypes/+nansen/+stack/selectRectangularRegions.m @@ -0,0 +1,124 @@ +function [regions, wasAborted] = selectRectangularRegions(imageStack, options) +%selectRectangularRegions - Select rectangular image regions interactively +% +% regions = nansen.stack.selectRectangularRegions(imageStack) opens the +% nansen.stack.ImageStack object imageStack in imviewer and lets the user +% place rectangular regions on the image by clicking. Pressing enter +% finishes the selection and returns the regions as an N-by-4 array of +% rectangles [xmin, ymin, width, height] of pixel indices, in the order +% the regions were created. +% +% Interaction: +% click : Add a region centered on the point that is clicked +% g / h : Grow / shrink the region in both directions +% shift + g / h : Grow / shrink the region in the y-direction only +% s : Switch between placing and selecting regions. +% A selected region is moved with the arrow keys and +% deleted with the backspace key +% enter : Finish and return the regions +% escape : Abort and return no regions +% +% regions = selectRectangularRegions(imageStack,RegionSize=SIZE) sets the +% initial size of the region that is placed. SIZE is a scalar for a +% square region or a two-element vector [WIDTH, HEIGHT]. By default, the +% region is one sixth of the image size. +% +% regions = selectRectangularRegions(imageStack,Regions=REGIONS) starts +% the selection with the regions in REGIONS already placed, so that an +% earlier selection can be adjusted. +% +% [regions, wasAborted] = selectRectangularRegions(...) also returns +% whether the user aborted the selection, either by pressing escape or by +% closing the window. regions is empty when the selection is aborted. +% +% See also nansen.stack.crop, imviewer.plugin.RegionSelector, imviewer + + arguments + imageStack {mustBeA(imageStack, "nansen.stack.ImageStack"), ... + mustBeScalarOrEmpty, mustBeNonempty} + options.RegionSize (1,:) {mustBeNumeric, mustBeReal, mustBePositive} = [] + options.Regions (:,4) {mustBeNumeric, mustBeReal} = zeros(0, 4) + end + + regionSize = resolveRegionSize(options.RegionSize, imageStack); + + % Caching makes browsing the stack in imviewer responsive while the + % regions are placed. Only a virtual stack has a dynamic cache. + useDynamicCache = imageStack.IsVirtual; + if useDynamicCache + initialCacheState = imageStack.DynamicCacheEnabled; + imageStack.DynamicCacheEnabled = 'on'; + end + + hImviewer = imviewer(imageStack); + hImviewer.ImageDragAndDropEnabled = false; + + hRegionSelector = hImviewer.openPlugin(@imviewer.plugin.RegionSelector, ... + [], 'RegionSize', regionSize, 'Regions', options.Regions); + + message = ['Click to add a region. Press g/h to resize, s to select ', ... + 'and move or delete a region, enter to finish and escape to abort.']; + hImviewer.displayMessage(message, [], 8) + + wasAborted = waitForUser(hImviewer, hRegionSelector); + + if wasAborted + regions = zeros(0, 4); + else + regions = hRegionSelector.getRegions(); + end + + delete(hRegionSelector) + if isvalid(hImviewer) + hImviewer.quit() + end + if useDynamicCache && isvalid(imageStack) + imageStack.DynamicCacheEnabled = initialCacheState; + end +end + +function regionSize = resolveRegionSize(regionSize, imageStack) +%resolveRegionSize Get the initial region size as [width, height] + + if isempty(regionSize) + % One sixth of the image in each direction is large enough to hold + % structures for registration and small enough that several regions + % fit in the field of view. + regionSize = round( [imageStack.ImageWidth, imageStack.ImageHeight] ./ 6 ); + elseif isscalar(regionSize) + regionSize = [regionSize, regionSize]; + elseif numel(regionSize) > 2 + error('NANSEN:Stack:SelectRegions:InvalidRegionSize', ... + ['RegionSize must be a scalar or a two-element vector ', ... + '[width, height].']) + end + + regionSize = min(regionSize, [imageStack.ImageWidth, imageStack.ImageHeight]); +end + +function wasAborted = waitForUser(hImviewer, hRegionSelector) +%waitForUser Block until the user finishes or aborts the selection + + hFigure = hImviewer.Figure; + hFigure.UserData.lastKey = ''; + + while true + uiwait(hFigure) % Resumes on enter or escape + + if ~ishghandle(hFigure) + wasAborted = true; + break + elseif strcmp(hFigure.UserData.lastKey, 'escape') + wasAborted = true; + break + elseif strcmp(hFigure.UserData.lastKey, 'return') + if hRegionSelector.RegionCount == 0 + hImviewer.displayMessage( ... + 'Add at least one region before pressing enter.', [], 4) + else + wasAborted = false; + break + end + end + end +end diff --git a/code/tools/+roimanager/+pointerTool/rectangleSelect.m b/code/tools/+roimanager/+pointerTool/rectangleSelect.m new file mode 100644 index 00000000..07903ea4 --- /dev/null +++ b/code/tools/+roimanager/+pointerTool/rectangleSelect.m @@ -0,0 +1,225 @@ +classdef rectangleSelect < uim.interface.abstractPointer & ... + roimanager.pointerTool.RoiDisplayInputHandler +%rectangleSelect Pointer tool for creating rectangular rois +% +% This tool shows a rectangle of a fixed size at the pointer location and +% creates a rectangular roi centered on the point that is clicked. The +% size of the rectangle is adjusted with the keyboard, or by the +% changeRectangleSize method. +% +% Keyboard shortcuts: +% g / h : Grow / shrink the rectangle in both directions +% shift + g / h : Grow / shrink the rectangle in the y-direction only +% +% See also roimanager.pointerTool.circleSelect + + properties (Constant) + exitMode = 'default'; + end + + properties % Properties related to displaying the rectangle during creation + rectangleToolCoords % Current rectangle as [xCenter, yCenter, width, height] + hRectangle % Patch handle for the temporary rectangle + defaultSize = [64, 64] % Default rectangle size as [width, height] + end + + properties (Constant, Access = private) + MIN_SIZE = 4 % Smallest rectangle size (pixels) in each direction + SIZE_INCREMENT = 4 % Change in size (pixels) per keypress + end + + methods + + function obj = rectangleSelect(hAxes) + obj.hFigure = ancestor(hAxes, 'figure'); + obj.hAxes = hAxes; + end + + function activate(obj) + activate@uim.interface.abstractPointer(obj) + showRectangle(obj) + end + + function deactivate(obj) + deactivate@uim.interface.abstractPointer(obj) + hideRectangle(obj) + end + + function suspend(obj) + suspend@uim.interface.abstractPointer(obj) + hideRectangle(obj) + end + + function setPointerSymbol(obj) + obj.hFigure.Pointer = 'crosshair'; + end + + function onButtonDown(obj, ~, ~) + + if strcmp(obj.hFigure.SelectionType, 'alt') + return + end + + obj.isActive = true; + + currentPoint = obj.hAxes.CurrentPoint(1, 1:2); + x = currentPoint(1); + y = currentPoint(2); + w = obj.rectangleToolCoords(3); + h = obj.rectangleToolCoords(4); + + obj.RoiDisplay.createRectangularRoi(x, y, w, h); + end + + function onButtonMotion(obj, ~, ~) + + persistent prevValue + if isempty(prevValue); prevValue = 0; end + + currentPoint = obj.hAxes.CurrentPoint(1, 1:2); + + % Hide the rectangle tool if the pointer is not in a "valid" + % position. + x = round(currentPoint(1)); y = round(currentPoint(2)); + tf = obj.RoiDisplay.isPointValid(x, y); + + if tf == 0 && prevValue ~= 0 + obj.hideRectangle() + elseif tf ~= 0 && prevValue == 0 + obj.showRectangle() + end + prevValue = tf; + + tmpCoords = [currentPoint, obj.rectangleToolCoords(3:4)]; + obj.plotRectangleTool(tmpCoords); + end + + function onButtonUp(obj, ~, ~) + obj.isActive = false; + end + + function wasCaptured = onKeyPress(obj, src, event) + wasCaptured = true; + + switch event.Key + case {'g', 'h'} + deltaSize = obj.SIZE_INCREMENT .* [1, 1]; + if contains('shift', event.Modifier) + % Change the height only, to make a rectangle that + % is not square. + deltaSize(1) = 0; + end + + if isequal(event.Key, 'h') + deltaSize = -1*deltaSize; + end + + changeRectangleSize(obj, deltaSize) + otherwise + wasCaptured = false; + end + + if wasCaptured + return + else % Pass on to roi keypress handler + wasCaptured = obj.roiKeypressHandler(src, event); + end + end + end + + methods + + function showRectangle(obj) + if isempty(obj.hRectangle) + obj.plotRectangleTool() + end + + obj.hRectangle.Visible = 'on'; + end + + function hideRectangle(obj) + obj.hRectangle.Visible = 'off'; + end + + function changeRectangleSize(obj, deltaSize) + %changeRectangleSize Change the size of the rectangle tool + % + % changeRectangleSize(obj, deltaSize) changes the size of the + % rectangle by deltaSize, given as [deltaWidth, deltaHeight]. + + tmpCoords = obj.rectangleToolCoords; + tmpCoords(3:4) = tmpCoords(3:4) + deltaSize; + obj.plotRectangleTool(tmpCoords) + end + + function setRectangleSize(obj, newSize) + %setRectangleSize Set the size of the rectangle tool + % + % setRectangleSize(obj, newSize) sets the size of the rectangle + % to newSize, given as [width, height]. + + obj.defaultSize = newSize; + + if isempty(obj.rectangleToolCoords) + obj.plotRectangleTool() + else + tmpCoords = obj.rectangleToolCoords; + tmpCoords(3:4) = newSize; + obj.plotRectangleTool(tmpCoords) + end + end + end + + methods (Access = protected) + + function plotRectangleTool(obj, coords) + + if nargin < 2 && ~obj.isPointerInsideAxes() + if isempty(obj.rectangleToolCoords) + x = obj.hAxes.XLim(1) + nansen.util.range(obj.hAxes.XLim)/2; + y = obj.hAxes.YLim(1) + nansen.util.range(obj.hAxes.YLim)/2; + rectangleSize = obj.defaultSize; + obj.rectangleToolCoords = [x, y, rectangleSize]; + else + x = obj.rectangleToolCoords(1); + y = obj.rectangleToolCoords(2); + rectangleSize = obj.rectangleToolCoords(3:4); + end + + elseif nargin < 2 && obj.isPointerInsideAxes() + point = obj.hAxes.CurrentPoint; + x = point(1,1); + y = point(1,2); + if isempty(obj.rectangleToolCoords) + rectangleSize = obj.defaultSize; + else + rectangleSize = obj.rectangleToolCoords(3:4); + end + else + x = coords(1); y = coords(2); rectangleSize = coords(3:4); + end + + if any(rectangleSize < obj.MIN_SIZE) + return + else + obj.rectangleToolCoords = [x, y, rectangleSize]; + end + + % Create the corners of the rectangle, centered on x and y. + w = rectangleSize(1); h = rectangleSize(2); + xData = x + [-1, 1, 1, -1] * w/2; + yData = y + [-1, -1, 1, 1] * h/2; + + if isempty(obj.hRectangle) + edgeColor = ones(1,3)*0.5; + obj.hRectangle = patch(obj.hAxes, xData, yData, 'w', ... + 'EdgeColor', edgeColor); + obj.hRectangle.FaceAlpha = 0.15; + obj.hRectangle.PickableParts = 'none'; + obj.hRectangle.HitTest = 'off'; + else + set(obj.hRectangle, 'XData', xData, 'YData', yData) + end + end + end +end diff --git a/code/tools/+roimanager/roiMap.m b/code/tools/+roimanager/roiMap.m index 24a569cc..50191f89 100755 --- a/code/tools/+roimanager/roiMap.m +++ b/code/tools/+roimanager/roiMap.m @@ -1042,6 +1042,38 @@ function createCircularRoi(obj, x, y, r) obj.addRois(newRoi) end + % Todo: move to roi editor + function createRectangularRoi(obj, x, y, width, height) + %createRectangularRoi Create a rectangular roi centered on a point + % + % createRectangularRoi(obj, x, y, width, height) creates an + % axis-aligned rectangular roi of the given width and height, + % centered on the point (x, y). The rectangle is created as a + % polygon roi with integer pixel corners. A rectangle that + % extends outside the image is moved inside it, and keeps the + % requested size unless the size exceeds the image. + + imageHeight = obj.displayApp.imHeight; + imageWidth = obj.displayApp.imWidth; + + width = min(round(width), imageWidth); + height = min(round(height), imageHeight); + + xMin = min( max(round(x - width/2), 1), imageWidth - width + 1 ); + yMin = min( max(round(y - height/2), 1), imageHeight - height + 1 ); + xMax = xMin + width - 1; + yMax = yMin + height - 1; + + % Polygon corners are ordered counterclockwise, starting at the + % upper left corner of the rectangle. + xCorners = [xMin, xMax, xMax, xMin]; + yCorners = [yMin, yMin, yMax, yMax]; + + newRoi = RoI('Polygon', [xCorners; yCorners], ... + [imageHeight, imageWidth]); + obj.addRois(newRoi) + end + function createFreehandRoi(obj, x, y, thickness) if nargin < 4 diff --git a/tests/+nansen/+unittest/+roimanager/RectangularRoiTest.m b/tests/+nansen/+unittest/+roimanager/RectangularRoiTest.m new file mode 100644 index 00000000..a7a13298 --- /dev/null +++ b/tests/+nansen/+unittest/+roimanager/RectangularRoiTest.m @@ -0,0 +1,204 @@ +classdef RectangularRoiTest < matlab.unittest.TestCase + %RectangularRoiTest Unit tests for creating rectangular rois. + % + % Covers roimanager.roiMap/createRectangularRoi and the size that the + % rectangleSelect pointer tool creates rois with: where a rectangle + % ends up for a given point, what happens at the edge of the image, + % and how the size of the tool is changed. + % + % The roi map is given a struct in place of the image viewer, because + % the only thing it needs from the viewer here is the image size. + % + % Run tests: + % runtests('nansen.unittest.roimanager.RectangularRoiTest') + + properties (Constant) + ImageHeight = 200 + ImageWidth = 300 + RegionSize = [64, 48] % [width, height] + end + + methods (Test) + + % ------------------------------------------------------------ + % Where the rectangle ends up + % ------------------------------------------------------------ + + function testRectangleIsCenteredOnThePoint(testCase) + [roiMap, roiGroup] = testCase.createRoiMap(); + + roiMap.createRectangularRoi(100, 120, 64, 48); + + testCase.verifyEqual(getRectangle(roiGroup, 1), [68, 96, 64, 48]); + end + + function testRectangleIsCreatedAsAPolygonWithFourCorners(testCase) + [roiMap, roiGroup] = testCase.createRoiMap(); + + roiMap.createRectangularRoi(100, 120, 64, 48); + + testCase.verifyEqual(roiGroup.roiArray(1).shape, 'Polygon'); + testCase.verifyEqual(size(roiGroup.roiArray(1).coordinates), [4, 2]); + end + + function testRectangleAtTheUpperLeftIsMovedInsideTheImage(testCase) + % The rectangle keeps its size and is moved, rather than being + % cut down to what fits where it was clicked. + [roiMap, roiGroup] = testCase.createRoiMap(); + + roiMap.createRectangularRoi(5, 5, 64, 48); + + testCase.verifyEqual(getRectangle(roiGroup, 1), [1, 1, 64, 48]); + end + + function testRectangleAtTheLowerRightIsMovedInsideTheImage(testCase) + [roiMap, roiGroup] = testCase.createRoiMap(); + + roiMap.createRectangularRoi(testCase.ImageWidth - 1, ... + testCase.ImageHeight - 1, 64, 48); + + expectedRectangle = [testCase.ImageWidth - 64 + 1, ... + testCase.ImageHeight - 48 + 1, 64, 48]; + testCase.verifyEqual(getRectangle(roiGroup, 1), expectedRectangle); + end + + function testRectangleLargerThanTheImageIsCutDownToIt(testCase) + [roiMap, roiGroup] = testCase.createRoiMap(); + + roiMap.createRectangularRoi(150, 100, 400, 300); + + testCase.verifyEqual(getRectangle(roiGroup, 1), ... + [1, 1, testCase.ImageWidth, testCase.ImageHeight]); + end + + function testCornersAreWholePixelsForAPointBetweenThem(testCase) + [roiMap, roiGroup] = testCase.createRoiMap(); + + roiMap.createRectangularRoi(100.4, 120.6, 65, 49); + + rectangle = getRectangle(roiGroup, 1); + testCase.verifyEqual(rectangle, round(rectangle)); + end + + function testEveryRectangleIsKeptInTheOrderItWasCreated(testCase) + [roiMap, roiGroup] = testCase.createRoiMap(); + + roiMap.createRectangularRoi(100, 120, 64, 48); + roiMap.createRectangularRoi(200, 60, 32, 32); + + testCase.verifyEqual(roiGroup.roiCount, 2); + testCase.verifyEqual(getRectangle(roiGroup, 1), [68, 96, 64, 48]); + testCase.verifyEqual(getRectangle(roiGroup, 2), [184, 44, 32, 32]); + end + + % ------------------------------------------------------------ + % The size that the pointer tool creates rois with + % ------------------------------------------------------------ + + function testToolKeepsTheSizeItIsSetTo(testCase) + hTool = testCase.createPointerTool(); + + hTool.setRectangleSize(testCase.RegionSize) + + testCase.verifyEqual(hTool.rectangleToolCoords(3:4), testCase.RegionSize); + end + + function testToolGrowsAndShrinksInBothDirections(testCase) + hTool = testCase.createPointerTool(); + hTool.setRectangleSize(testCase.RegionSize) + + hTool.changeRectangleSize([8, 8]) + testCase.verifyEqual(hTool.rectangleToolCoords(3:4), ... + testCase.RegionSize + 8); + + hTool.changeRectangleSize([-8, -8]) + testCase.verifyEqual(hTool.rectangleToolCoords(3:4), testCase.RegionSize); + end + + function testToolGrowsInOneDirectionOnly(testCase) + hTool = testCase.createPointerTool(); + hTool.setRectangleSize(testCase.RegionSize) + + hTool.changeRectangleSize([0, 8]) + + testCase.verifyEqual(hTool.rectangleToolCoords(3:4), ... + testCase.RegionSize + [0, 8]); + end + + function testToolDoesNotShrinkBelowTheMinimumSize(testCase) + hTool = testCase.createPointerTool(); + hTool.setRectangleSize(testCase.RegionSize) + + hTool.changeRectangleSize([-1000, -1000]) + + testCase.verifyEqual(hTool.rectangleToolCoords(3:4), testCase.RegionSize); + end + + function testRoiIsCreatedWithTheSizeOfTheTool(testCase) + % What the tool does on a click, without moving the mouse + [roiMap, roiGroup] = testCase.createRoiMap(); + hTool = testCase.createPointerTool(roiMap); + hTool.setRectangleSize(testCase.RegionSize) + hTool.changeRectangleSize([8, 8]) + + toolSize = hTool.rectangleToolCoords(3:4); + hTool.RoiDisplay.createRectangularRoi(150, 100, toolSize(1), toolSize(2)); + + rectangle = getRectangle(roiGroup, 1); + testCase.verifyEqual(rectangle(3:4), testCase.RegionSize + 8); + end + end + + methods (Access = private) + + function [roiMap, roiGroup] = createRoiMap(testCase) + %createRoiMap Create a roi map on an invisible figure + hAxes = testCase.createAxes(); + roiGroup = roimanager.roiGroup(); + + % The roi map only needs the image size from the viewer + displayAppStub = struct('imHeight', testCase.ImageHeight, ... + 'imWidth', testCase.ImageWidth); + + roiMap = roimanager.roiMap(displayAppStub, hAxes, roiGroup); + testCase.addTeardown(@delete, roiMap) + end + + function hTool = createPointerTool(testCase, roiMap) + %createPointerTool Create the rectangleSelect tool for an axes + if nargin < 2 + hAxes = testCase.createAxes(); + else + hAxes = roiMap.hAxes; + end + + hTool = roimanager.pointerTool.rectangleSelect(hAxes); + if nargin >= 2 + hTool.RoiDisplay = roiMap; + end + end + + function hAxes = createAxes(testCase) + %createAxes Create axes on a figure that never opens on screen + hFigure = figure('Visible', 'off'); + testCase.addTeardown(@close, hFigure) + + hAxes = axes(hFigure); + hAxes.XLim = [1, testCase.ImageWidth]; + hAxes.YLim = [1, testCase.ImageHeight]; + end + end +end + +function rectangle = getRectangle(roiGroup, roiNumber) +%getRectangle Get the bounding rectangle of a roi as [x, y, width, height] + + coordinates = roiGroup.roiArray(roiNumber).coordinates; + + xMin = min(coordinates(:, 1)); + yMin = min(coordinates(:, 2)); + width = max(coordinates(:, 1)) - xMin + 1; + height = max(coordinates(:, 2)) - yMin + 1; + + rectangle = [xMin, yMin, width, height]; +end