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 @@
-
+
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