diff --git a/tests/measure/test_feret.py b/tests/measure/test_feret.py new file mode 100644 index 0000000000..aace4c8dfe --- /dev/null +++ b/tests/measure/test_feret.py @@ -0,0 +1,1360 @@ +"""Tests for feret functions.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt +import pytest +from skimage import draw + +from topostats.measure import feret + +# pylint: disable=protected-access +# pylint: disable=too-many-lines +# pylint: disable=fixme + +POINT1 = (0, 0) +POINT2 = (1, 0) +POINT3 = (1, 1) +POINT4 = (2, 0) +POINT5 = (0, 1) +POINT6 = (0, 2) + +tiny_circle = np.zeros((3, 3), dtype=np.uint8) +rr, cc = draw.circle_perimeter(1, 1, 1) +tiny_circle[rr, cc] = 1 + +small_circle = np.zeros((5, 5), dtype=np.uint8) +rr, cc = draw.circle_perimeter(2, 2, 2) +small_circle[rr, cc] = 1 + +tiny_quadrilateral = np.asarray( + [ + [0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0], + [0, 1, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + ], + dtype=np.uint8, +) + +tiny_square = np.asarray([[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]], dtype=np.uint8) + +tiny_triangle = np.asarray([[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 0, 0], [0, 0, 0, 0]], dtype=np.uint8) + +arbitrary_triangle = np.array([[1.18727719, 2.96140198], [4.14262995, 3.17983179], [6.92472119, 0.64147496]]) + +tiny_rectangle = np.asarray([[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]], dtype=np.uint8) + +tiny_ellipse = np.asarray( + [ + [0, 0, 0, 0, 0], + [0, 0, 1, 0, 0], + [0, 1, 0, 1, 0], + [0, 1, 0, 1, 0], + [0, 1, 0, 1, 0], + [0, 0, 1, 0, 0], + [0, 0, 0, 0, 0], + ], + dtype=np.uint8, +) + +holo_circle = np.zeros((7, 7), dtype=np.uint8) +rr, cc = draw.circle_perimeter(3, 3, 2) +holo_circle[rr, cc] = 1 + +holo_ellipse_vertical = np.zeros((11, 9), dtype=np.uint8) +rr, cc = draw.ellipse_perimeter(5, 4, 4, 3) +holo_ellipse_vertical[rr, cc] = 1 + +holo_ellipse_horizontal = np.zeros((9, 11), dtype=np.uint8) +rr, cc = draw.ellipse_perimeter(4, 5, 3, 4) +holo_ellipse_horizontal[rr, cc] = 1 + +holo_ellipse_angled = np.zeros((8, 10), dtype=np.uint8) +rr, cc = draw.ellipse_perimeter(4, 5, 1, 3, orientation=np.deg2rad(30)) +holo_ellipse_angled[rr, cc] = 1 + +curved_line = np.zeros((10, 10), dtype=np.uint8) +rr, cc = draw.bezier_curve(1, 5, 5, -2, 8, 8, 2) +curved_line[rr, cc] = 1 + +filled_circle = np.zeros((9, 9), dtype=np.uint8) +rr, cc = draw.disk((4, 4), 4) +filled_circle[rr, cc] = 1 + +filled_ellipse_vertical = np.zeros((9, 7), dtype=np.uint8) +rr, cc = draw.ellipse(4, 3, 4, 3) +filled_ellipse_vertical[rr, cc] = 1 + +filled_ellipse_horizontal = np.zeros((7, 9), dtype=np.uint8) +rr, cc = draw.ellipse(3, 4, 3, 4) +filled_ellipse_horizontal[rr, cc] = 1 + +filled_ellipse_angled = np.zeros((9, 11), dtype=np.uint8) +rr, cc = draw.ellipse(4, 5, 3, 5, rotation=np.deg2rad(30)) +filled_ellipse_angled[rr, cc] = 1 + + +@pytest.mark.parametrize( + ("point1", "point2", "point3", "target"), + [ + pytest.param(POINT3, POINT2, POINT1, 1, id="clockwise"), + pytest.param(POINT1, POINT2, POINT3, -1, id="anti-clockwise"), + pytest.param(POINT1, POINT2, POINT4, 0, id="vertical-line"), + pytest.param(POINT1, POINT5, POINT6, 0, id="horizontal-line"), + ], +) +def test_orientation(point1: tuple, point2: tuple, point3: tuple, target: int) -> None: + """Test calculation of orientation of three points.""" + assert feret.orientation(point1, point2, point3) == target + + +@pytest.mark.parametrize( + ("shape", "axis", "target"), + [ + pytest.param(tiny_circle, 1, [[1, 0], [0, 1], [2, 1], [1, 2]], id="tiny circle sorted on axis 1"), + pytest.param(tiny_circle, 0, [[0, 1], [1, 0], [1, 2], [2, 1]], id="tiny circle sorted on axis 0"), + pytest.param(tiny_square, 1, [[1, 1], [2, 1], [1, 2], [2, 2]], id="tiny square sorted on axis 1"), + pytest.param(tiny_square, 0, [[1, 1], [1, 2], [2, 1], [2, 2]], id="tiny square sorted on axis 0"), + pytest.param(tiny_quadrilateral, 1, [[2, 1], [1, 2], [5, 2], [2, 4]], id="tiny quadrilateral sorted on axis 1"), + pytest.param(tiny_quadrilateral, 0, [[1, 2], [2, 1], [2, 4], [5, 2]], id="tiny quadrilateral sorted on axis 0"), + ], +) +def test_sort_coords(shape: npt.NDArray, axis: int, target: npt.NDArray) -> None: + """Test sorting of coordinates.""" + sorted_coords = feret.sort_coords(np.argwhere(shape == 1), axis) + np.testing.assert_array_equal(sorted_coords, target) + + +@pytest.mark.parametrize( + ("coords", "axis", "target"), + [ + pytest.param( + arbitrary_triangle, + 0, + [[1.187277, 2.961402], [4.14263, 3.179832], [6.924721, 0.641475]], + id="arbitrary triangle sorted on axis 0", + ), + pytest.param( + arbitrary_triangle, + 1, + [[6.924721, 0.641475], [1.187277, 2.961402], [4.14263, 3.179832]], + id="arbitrary triangle sorted on axis 1", + ), + ], +) +def test_sort_coords_arbitrary(coords: npt.NDArray, axis: int, target: npt.NDArray) -> None: + """Test sorting of coordinates.""" + sorted_coords = feret.sort_coords(coords, axis) + np.testing.assert_array_almost_equal(sorted_coords, target) + + +@pytest.mark.parametrize( + ("shape", "axis"), + [ + pytest.param(tiny_triangle, 2, id="integer not 0 or 1"), + pytest.param(tiny_triangle, "row", id="string"), + ], +) +def test_sort_coords_invalid_axis(shape: npt.NDArray, axis: int | str) -> None: + """Test ValueError raised when axis is not 0 or 1.""" + with pytest.raises(ValueError): # noqa: PT011 + feret.sort_coords(np.argwhere(shape == 1), axis) + + +@pytest.mark.parametrize( + ("shape", "axis", "upper_target", "lower_target"), + [ + pytest.param( + tiny_circle, 0, [[0, 1], [1, 2], [2, 1]], [[0, 1], [1, 0], [2, 1]], id="tiny circle sorted on axis 0" + ), + pytest.param(tiny_circle, 1, [[1, 0], [0, 1], [1, 2]], [[1, 0], [2, 1], [1, 2]], id="tiny circle on axis 1"), + pytest.param( + tiny_square, 0, [[1, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [2, 2]], id="tiny square sorted on axis 0" + ), + pytest.param( + tiny_square, 1, [[1, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [2, 2]], id="tiny square sorted on axis 1" + ), + pytest.param(tiny_triangle, 0, [[1, 1], [1, 2], [2, 1]], [[1, 1], [2, 1]], id="tiny triangle sorted on axis 0"), + pytest.param(tiny_triangle, 1, [[1, 1], [1, 2]], [[1, 1], [2, 1], [1, 2]], id="tiny triangle sorted on axis 1"), + pytest.param( + tiny_rectangle, 0, [[1, 1], [1, 2], [3, 2]], [[1, 1], [3, 1], [3, 2]], id="tiny rectangle sorted on axis 0" + ), + pytest.param( + tiny_rectangle, 1, [[1, 1], [1, 2], [3, 2]], [[1, 1], [3, 1], [3, 2]], id="tiny rectangle sorted on axis 1" + ), + pytest.param( + tiny_ellipse, + 0, + [[1, 2], [2, 3], [4, 3], [5, 2]], + [[1, 2], [2, 1], [4, 1], [5, 2]], + id="tiny ellipse sorted on axis 0", + ), + pytest.param( + tiny_ellipse, + 1, + [[2, 1], [1, 2], [2, 3], [4, 3]], + [[2, 1], [4, 1], [5, 2], [4, 3]], + id="tiny ellipse sorted on axis 1", + ), + pytest.param( + tiny_quadrilateral, + 0, + [[1, 2], [2, 4], [5, 2]], + [[1, 2], [2, 1], [5, 2]], + id="tiny quadrialteral sorted on axis 0", + ), + pytest.param( + tiny_quadrilateral, + 1, + [[2, 1], [1, 2], [2, 4]], + [[2, 1], [5, 2], [2, 4]], + id="tiny quadrialteral sorted on axis 1", + ), + pytest.param( + small_circle, + 0, + [[0, 1], [0, 3], [1, 4], [3, 4], [4, 3]], + [[0, 1], [1, 0], [3, 0], [4, 1], [4, 3]], + id="small circle sorted on axis 0", + ), + pytest.param( + small_circle, + 1, + [[1, 0], [0, 1], [0, 3], [1, 4], [3, 4]], + [[1, 0], [3, 0], [4, 1], [4, 3], [3, 4]], + id="small circle sorted on axis 1", + ), + pytest.param( + holo_circle, + 0, + [[1, 2], [1, 4], [2, 5], [4, 5], [5, 4]], + [[1, 2], [2, 1], [4, 1], [5, 2], [5, 4]], + id="holo circle sorted on axis 0", + ), + pytest.param( + holo_circle, + 1, + [[2, 1], [1, 2], [1, 4], [2, 5], [4, 5]], + [[2, 1], [4, 1], [5, 2], [5, 4], [4, 5]], + id="holo circle sorted on axis 1", + ), + pytest.param( + holo_ellipse_horizontal, + 0, + [[1, 3], [1, 7], [3, 9], [5, 9], [7, 7]], + [[1, 3], [3, 1], [5, 1], [7, 3], [7, 7]], + id="holo ellipse horizontal sorted on axis 0", + ), + pytest.param( + holo_ellipse_horizontal, + 1, + [[3, 1], [1, 3], [1, 7], [3, 9], [5, 9]], + [[3, 1], [5, 1], [7, 3], [7, 7], [5, 9]], + id="holo ellipse horizontal sorted on axis 1", + ), + pytest.param( + holo_ellipse_vertical, + 0, + [[1, 3], [1, 5], [3, 7], [7, 7], [9, 5]], + [[1, 3], [3, 1], [7, 1], [9, 3], [9, 5]], + id="holo ellipse vertical sorted on axis 0", + ), + pytest.param( + holo_ellipse_vertical, + 1, + [[3, 1], [1, 3], [1, 5], [3, 7], [7, 7]], + [[3, 1], [7, 1], [9, 3], [9, 5], [7, 7]], + id="holo ellipse vertical sorted on axis 1", + ), + pytest.param( + holo_ellipse_angled, + 0, + [[1, 2], [1, 4], [5, 8], [6, 7]], + [[1, 2], [2, 1], [6, 5], [6, 7]], + id="holo ellipse angled sorted on axis 0", + ), + pytest.param( + holo_ellipse_angled, + 1, + [[2, 1], [1, 2], [1, 4], [5, 8]], + [[2, 1], [6, 5], [6, 7], [5, 8]], + id="holo ellipse angled sorted on axis 1", + ), + pytest.param( + curved_line, + 0, + [[1, 5], [8, 8]], + [[1, 5], [2, 3], [4, 1], [5, 1], [6, 2], [7, 4], [8, 7], [8, 8]], + id="curved line sorted on axis 0", + ), + pytest.param( + curved_line, + 1, + [[4, 1], [2, 3], [1, 5], [8, 8]], + [[4, 1], [5, 1], [6, 2], [7, 4], [8, 7], [8, 8]], + id="curved line sorted on axis 1", + ), + ], +) +def test_hulls(shape: npt.NDArray, axis: bool, upper_target: list, lower_target: list) -> None: + """Test construction of upper and lower hulls.""" + upper, lower = feret.hulls(np.argwhere(shape == 1), axis) + np.testing.assert_array_equal(upper, upper_target) + np.testing.assert_array_equal(lower, lower_target) + + +@pytest.mark.parametrize( + ("coords", "axis", "upper_target", "lower_target"), + [ + pytest.param( + arbitrary_triangle, + 0, + [[1.187277, 2.961402], [4.14263, 3.179832], [6.924721, 0.641475]], + [[1.187277, 2.961402], [6.924721, 0.641475]], + id="arbitrary triangle sorted on axis 0", + ), + ], +) +def test_hulls_arbitrary(coords: npt.NDArray, axis: bool, upper_target: list, lower_target: list) -> None: + """Test construction of upper and lower hulls.""" + upper, lower = feret.hulls(coords, axis) + np.testing.assert_array_almost_equal(upper, upper_target) + np.testing.assert_array_almost_equal(lower, lower_target) + + +@pytest.mark.parametrize( + ("shape", "points_target"), + [ + pytest.param( + tiny_circle, + [ + ([1, 0], [2, 1]), + ([1, 0], [1, 2]), + ([0, 1], [1, 0]), + ([0, 1], [2, 1]), + ([0, 1], [1, 2]), + ([1, 2], [2, 1]), + ], + id="tiny circle", + ), + pytest.param( + tiny_square, + [ + ([1, 1], [2, 1]), + ([1, 1], [2, 2]), + ([1, 1], [1, 2]), + ([1, 2], [2, 1]), + ([1, 2], [2, 2]), + ([2, 1], [2, 2]), + ], + id="tiny square", + ), + pytest.param( + tiny_triangle, + [ + ([1, 1], [2, 1]), + ([1, 1], [1, 2]), + ([1, 2], [2, 1]), + ], + id="tiny triangle", + ), + pytest.param( + tiny_rectangle, + [ + ([1, 1], [3, 1]), + ([1, 1], [3, 2]), + ([1, 1], [1, 2]), + ([1, 2], [3, 1]), + ([1, 2], [3, 2]), + ([3, 1], [3, 2]), + ], + id="tiny rectangle", + ), + pytest.param( + tiny_ellipse, + [ + ([2, 1], [4, 1]), + ([2, 1], [5, 2]), + ([2, 1], [4, 3]), + ([1, 2], [2, 1]), + ([1, 2], [4, 1]), + ([1, 2], [5, 2]), + ([1, 2], [4, 3]), + ([2, 1], [2, 3]), + ([2, 3], [4, 1]), + ([2, 3], [5, 2]), + ([2, 3], [4, 3]), + ([4, 1], [4, 3]), + ([4, 3], [5, 2]), + ], + id="tiny ellipse", + ), + pytest.param( + small_circle, + [ + ([1, 0], [3, 0]), + ([1, 0], [4, 1]), + ([1, 0], [4, 3]), + ([1, 0], [3, 4]), + ([0, 1], [1, 0]), + ([0, 1], [3, 0]), + ([0, 1], [4, 1]), + ([0, 1], [4, 3]), + ([0, 1], [3, 4]), + ([0, 3], [1, 0]), + ([0, 3], [3, 0]), + ([0, 3], [4, 1]), + ([0, 3], [4, 3]), + ([0, 3], [3, 4]), + ([1, 0], [1, 4]), + ([1, 4], [3, 0]), + ([1, 4], [4, 1]), + ([1, 4], [4, 3]), + ([1, 4], [3, 4]), + ([3, 0], [3, 4]), + ([3, 4], [4, 1]), + ([3, 4], [4, 3]), + ], + id="small circle", + ), + pytest.param( + holo_circle, + [ + ([2, 1], [4, 1]), + ([2, 1], [5, 2]), + ([2, 1], [5, 4]), + ([2, 1], [4, 5]), + ([1, 2], [2, 1]), + ([1, 2], [4, 1]), + ([1, 2], [5, 2]), + ([1, 2], [5, 4]), + ([1, 2], [4, 5]), + ([1, 4], [2, 1]), + ([1, 4], [4, 1]), + ([1, 4], [5, 2]), + ([1, 4], [5, 4]), + ([1, 4], [4, 5]), + ([2, 1], [2, 5]), + ([2, 5], [4, 1]), + ([2, 5], [5, 2]), + ([2, 5], [5, 4]), + ([2, 5], [4, 5]), + ([4, 1], [4, 5]), + ([4, 5], [5, 2]), + ([4, 5], [5, 4]), + ], + id="holo circle", + ), + pytest.param( + holo_ellipse_horizontal, + [ + ([3, 1], [5, 1]), + ([3, 1], [7, 3]), + ([3, 1], [7, 7]), + ([3, 1], [5, 9]), + ([1, 3], [3, 1]), + ([1, 3], [5, 1]), + ([1, 3], [7, 3]), + ([1, 3], [7, 7]), + ([1, 3], [5, 9]), + ([1, 7], [3, 1]), + ([1, 7], [5, 1]), + ([1, 7], [7, 3]), + ([1, 7], [7, 7]), + ([1, 7], [5, 9]), + ([3, 1], [3, 9]), + ([3, 9], [5, 1]), + ([3, 9], [7, 3]), + ([3, 9], [7, 7]), + ([3, 9], [5, 9]), + ([5, 1], [5, 9]), + ([5, 9], [7, 3]), + ([5, 9], [7, 7]), + ], + id="holo ellipse horizontal", + ), + pytest.param( + holo_ellipse_vertical, + [ + ([3, 1], [7, 1]), + ([3, 1], [9, 3]), + ([3, 1], [9, 5]), + ([3, 1], [7, 7]), + ([1, 3], [3, 1]), + ([1, 3], [7, 1]), + ([1, 3], [9, 3]), + ([1, 3], [9, 5]), + ([1, 3], [7, 7]), + ([1, 5], [3, 1]), + ([1, 5], [7, 1]), + ([1, 5], [9, 3]), + ([1, 5], [9, 5]), + ([1, 5], [7, 7]), + ([3, 1], [3, 7]), + ([3, 7], [7, 1]), + ([3, 7], [9, 3]), + ([3, 7], [9, 5]), + ([3, 7], [7, 7]), + ([7, 1], [7, 7]), + ([7, 7], [9, 3]), + ([7, 7], [9, 5]), + ], + id="holo ellipse vertical", + ), + pytest.param( + holo_ellipse_angled, + [ + ([2, 1], [6, 5]), + ([2, 1], [6, 7]), + ([2, 1], [5, 8]), + ([1, 2], [2, 1]), + ([1, 2], [6, 5]), + ([1, 2], [6, 7]), + ([1, 2], [5, 8]), + ([1, 4], [2, 1]), + ([1, 4], [6, 5]), + ([1, 4], [6, 7]), + ([1, 4], [5, 8]), + ([5, 8], [6, 5]), + ([5, 8], [6, 7]), + ], + id="holo ellipse angled", + ), + pytest.param( + curved_line, + [ + ([4, 1], [5, 1]), + ([4, 1], [6, 2]), + ([4, 1], [7, 4]), + ([4, 1], [8, 7]), + ([4, 1], [8, 8]), + ([2, 3], [4, 1]), + ([2, 3], [5, 1]), + ([2, 3], [6, 2]), + ([2, 3], [7, 4]), + ([2, 3], [8, 7]), + ([2, 3], [8, 8]), + ([1, 5], [4, 1]), + ([1, 5], [5, 1]), + ([1, 5], [6, 2]), + ([1, 5], [7, 4]), + ([1, 5], [8, 7]), + ([1, 5], [8, 8]), + ([5, 1], [8, 8]), + ([6, 2], [8, 8]), + ([7, 4], [8, 8]), + ([8, 7], [8, 8]), + ], + id="curved line", + ), + ], +) +def test_all_pairs(shape: npt.NDArray, points_target: list) -> None: + """Test calculation of all pairs.""" + points = feret.all_pairs(np.argwhere(shape == 1)) + np.testing.assert_array_equal(list(points), points_target) + + +@pytest.mark.parametrize( + ("base1", "base2", "apex", "target_height"), + [ + pytest.param([0, 0], [1, 0], [0, 1], 1.0, id="tiny triangle (vertical is base)"), + pytest.param([0, 0], [0, 1], [1, 0], 1.0, id="tiny triangle (horizontal is base)"), + pytest.param([0, 1], [1, 0], [0, 0], 0.7071067811865475, id="tiny triangle (hypotenuse is base)"), + pytest.param([4, 0], [4, 3], [0, 0], 4.0, id="3, 4, 5 triangle"), + pytest.param([4, 0], [4, 6], [0, 3], 4.0, id="equilateral triangle"), + pytest.param( + np.asarray([4, 0]), np.asarray([4, 6]), np.asarray([0, 3]), 4.0, id="equilateral triangle (numpy arrays)" + ), + pytest.param([4, 3], [4, 6], [0, 0], 4.0, id="offset"), + pytest.param([4, 3], [3, 6], [0, 0], 4.743416490252569, id="offset"), + pytest.param( + [1.187277, 2.961402], + [4.14263, 3.179832], + [6.924721, 0.641475], + 2.736517033606448, + id="arbitrary triangle, height 1", + ), + pytest.param( + [1.187277, 2.961402], + [6.924721, 0.641475], + [4.14263, 3.179832], + 1.3103558947980252, + id="arbitrary triangle height 2", + ), + pytest.param( + [6.924721, 0.641475], + [4.14263, 3.179832], + [1.187277, 2.961402], + 2.1532876859324226, + id="arbitrary triangle height 3", + ), + ], +) +def test_triangle_heights( + base1: npt.NDArray | list, base2: npt.NDArray | list, apex: npt.NDArray | list, target_height: float +) -> None: + """Test calculation of minimum feret (height of triangle).""" + height = feret.triangle_height(base1, base2, apex) + np.testing.assert_almost_equal(height, target_height) + + +@pytest.mark.parametrize( + ("base1", "base2", "apex", "round_coord", "opposite_target"), + [ + pytest.param([1, 0], [0, 1], [0, 0], True, np.asarray([1, 1]), id="tiny triangle (apex top left, rounding)"), + pytest.param([1, 0], [0, 1], [1, 1], True, np.asarray([0, 0]), id="tiny triangle (apex top right, rounding)"), + pytest.param([0, 0], [1, 1], [1, 0], True, np.asarray([0, 1]), id="tiny triangle (apex bottom left, rounding)"), + pytest.param( + [0, 0], [1, 1], [0, 1], True, np.asarray([1, 0]), id="tiny triangle (apex bottom right, rounding)" + ), + pytest.param([1, 2], [2, 1], [1, 1], True, np.asarray([2, 2]), id="tiny triangle (from tests, rounding)"), + pytest.param([2, 1], [8, 2], [5, 4], True, np.asarray([6, 1]), id="another triangle (rounding)"), + pytest.param( + [2, 1], + [8, 2], + [5, 4], + False, + np.asarray([5.405405405405405, 1.5675675675675649]), + id="another triangle (no rounding)", + ), + pytest.param( + [1, 0], + [1, 1], + [0, 0], + False, + np.asarray([1, 0]), + id="tiny triangle with base gradient zero, apex gradient 1 (no rounding)", + ), + pytest.param( + [1, 0], + [1, 1], + [0, 0], + True, + np.asarray([1, 1]), + id="tiny triangle with base gradient zero, apex gradient 1 (rounding)", + ), + pytest.param( + [1, 0], + [1, 2], + [0, 1], + False, + np.asarray([1, 1]), + id="tiny triangle with base gradient zero, apex gradient 0.5 (rounding)", + ), + pytest.param( + [1.187277, 2.961402], + [4.14263, 3.179832], + [6.924721, 0.641475], + False, + [6.723015, 3.370548], + id="arbitrary triangle, height 1", + ), + pytest.param( + [1.187277, 2.961402], + [6.924721, 0.641475], + [4.14263, 3.179832], + False, + [3.651425, 1.965027], + id="arbitrary triangle height 2", + ), + pytest.param( + [6.924721, 0.641475], + [4.14263, 3.179832], + [1.187277, 2.961402], + False, + [2.638607, 4.55209], + id="arbitrary triangle height 3", + ), + ], +) +def test_min_feret_coord( + base1: npt.NDArray | list, + base2: npt.NDArray | list, + apex: npt.NDArray | list, + round_coord: bool, + opposite_target: float, +) -> None: + """Test calculation of mid_point of the triangle formed by rotating caliper and next point on convex hull.""" + opposite = feret._min_feret_coord(np.asarray(base1), np.asarray(base2), np.asarray(apex), round_coord) + np.testing.assert_array_almost_equal(opposite, opposite_target) + + +@pytest.mark.parametrize( + ("shape", "axis", "calipers_target", "min_ferets_target", "min_feret_coords_target"), + [ + pytest.param( + tiny_circle, + 0, + (([2, 1], [0, 1]), ([1, 0], [0, 1]), ([1, 0], [1, 2]), ([0, 1], [1, 2])), + (1.414213562373095, 1.414213562373095, 1.414213562373095, 1.414213562373095), + ([[1.0, 0.0], [0, 1]], [[-0.0, 1.0], [1, 0]], [[0.0, 1.0], [1, 2]], [[1.0, 2.0], [0, 1]]), + id="tiny circle sorted by axis 0", + ), + pytest.param( + tiny_quadrilateral, + 0, + (([5, 2], [1, 2]), ([5, 2], [2, 4]), ([2, 1], [2, 4]), ([2, 1], [5, 2])), + (3.5777087639996634, 2.846049894151541, 2.4961508830135313, 2.82842712474619), + ( + [[1.8, 3.6], [5, 2]], + [[2.9, 1.3000000000000007], [2, 4]], + [[3.3846153846153846, 3.0769230769230766], [2, 1]], + [[3.0, 0.0], [5, 2]], + ), + id="tiny quadrilateral sorted by axis 0", + ), + pytest.param( + tiny_square, + 0, + (([2, 2], [1, 1]), ([2, 1], [1, 1]), ([2, 1], [1, 2]), ([1, 1], [1, 2])), + (1.0, 1.0, 1.0, 1.0), + ( + [[2.0, 1.0], [1, 1]], + [[1.0, 1.0], [2, 1]], + [[1.0, 1.0], [1, 2]], + [[1.0, 2.0], [1, 1]], + ), + id="tiny square sorted by axis 0", + ), + pytest.param( + tiny_triangle, + 0, + (([2, 1], [1, 1]), ([2, 1], [1, 2]), ([1, 1], [1, 2])), + (1.0, 1.0, 0.7071067811865475), + ([[1.0, 1.0], [2, 1]], [[1.0, 1.0], [1, 2]], [[1.5, 1.5], [1, 1]]), + id="tiny triangle sorted by axis 0", + ), + pytest.param( + tiny_rectangle, + 0, + (([3, 2], [1, 1]), ([3, 1], [1, 1]), ([3, 1], [1, 2]), ([1, 1], [1, 2])), + (2.0, 2.0, 1.0, 1.0), + ( + [[3.0, 1.0], [1, 1]], + [[1.0, 1.0], [3, 1]], + [[1.0, 1.0], [1, 2]], + [[1.0, 2.0], [1, 1]], + ), + id="tiny rectangle sorted by axis 0", + ), + pytest.param( + tiny_ellipse, + 0, + ( + ([5, 2], [1, 2]), + ([4, 1], [1, 2]), + ([4, 1], [2, 3]), + ([2, 1], [2, 3]), + ([2, 1], [4, 3]), + ([1, 2], [4, 3]), + ), + (2.82842712474619, 2.82842712474619, 2.0, 2.0, 2.82842712474619, 2.82842712474619), + ( + [[3.0, 0.0], [1, 2]], + [[2.0, 3.0], [4, 1]], + [[2.0, 1.0], [2, 3]], + [[2.0, 3.0], [2, 1]], + [[2.0, 1.0], [4, 3]], + [[3.0, 4.0], [1, 2]], + ), + id="tiny ellipse sorted by axis 0", + ), + pytest.param( + small_circle, + 0, + ( + ([4, 3], [0, 1]), + ([4, 1], [0, 1]), + ([4, 1], [0, 3]), + ([3, 0], [0, 3]), + ([3, 0], [1, 4]), + ([1, 0], [1, 4]), + ([1, 0], [3, 4]), + ([0, 1], [3, 4]), + ), + (4.0, 4.0, 4.242640687119285, 4.242640687119285, 4.0, 4.0, 4.242640687119285, 4.242640687119285), + ( + [[4.0, 1.0], [0, 1]], + [[0.0, 1.0], [4, 1]], + [[3.0, 0.0], [0, 3]], + [[0.0, 3.0], [3, 0]], + [[1.0, 0.0], [1, 4]], + [[1.0, 4.0], [1, 0]], + [[0.0, 1.0], [3, 4]], + [[3.0, 4.0], [0, 1]], + ), + id="small circle sorted by axis 0", + ), + pytest.param( + holo_circle, + 0, + ( + ([5, 4], [1, 2]), + ([5, 2], [1, 2]), + ([5, 2], [1, 4]), + ([4, 1], [1, 4]), + ([4, 1], [2, 5]), + ([2, 1], [2, 5]), + ([2, 1], [4, 5]), + ([1, 2], [4, 5]), + ), + (4.0, 4.0, 4.242640687119285, 4.242640687119285, 4.0, 4.0, 4.242640687119285, 4.242640687119285), + ( + [[5.0, 2.0], [1, 2]], + [[1.0, 2.0], [5, 2]], + [[4.0, 1.0], [1, 4]], + [[1.0, 4.0], [4, 1]], + [[2.0, 1.0], [2, 5]], + [[2.0, 5.0], [2, 1]], + [[1.0, 2.0], [4, 5]], + [[4.0, 5.0], [1, 2]], + ), + id="holo circle sorted by axis 0", + ), + pytest.param( + holo_ellipse_horizontal, + 0, + ( + ([7, 7], [1, 3]), + ([7, 3], [1, 3]), + ([7, 3], [1, 7]), + ([5, 1], [1, 7]), + ([5, 1], [3, 9]), + ([3, 1], [3, 9]), + ([3, 1], [5, 9]), + ([1, 3], [5, 9]), + ), + (6.0, 6.0, 7.071067811865475, 7.071067811865475, 8.0, 8.0, 7.071067811865475, 7.071067811865475), + ( + [[7.0, 3.0], [1, 3]], + [[1.0, 3.0], [7, 3]], + [[6.0, 2.0], [1, 7]], + [[0.0, 6.0], [5, 1]], + [[3.0, 1.0], [3, 9]], + [[3.0, 9.0], [3, 1]], + [[0.0, 4.0], [5, 9]], + [[6.0, 8.0], [1, 3]], + ), + id="holo ellipse horizontal sorted by axis 0", + ), + pytest.param( + holo_ellipse_vertical, + 0, + ( + ([9, 5], [1, 3]), + ([9, 3], [1, 3]), + ([9, 3], [1, 5]), + ([7, 1], [1, 5]), + ([7, 1], [3, 7]), + ([3, 1], [3, 7]), + ([3, 1], [7, 7]), + ([1, 3], [7, 7]), + ), + (8.0, 8.0, 7.071067811865475, 7.071067811865475, 6.0, 6.0, 7.071067811865475, 7.071067811865475), + ( + [[9.0, 3.0], [1, 3]], + [[1.0, 3.0], [9, 3]], + [[6.0, 0.0], [1, 5]], + [[2.0, 6.0], [7, 1]], + [[3.0, 1.0], [3, 7]], + [[3.0, 7.0], [3, 1]], + [[2.0, 2.0], [7, 7]], + [[6.0, 8.0], [1, 3]], + ), + id="holo ellipse vertical sorted by axis 0", + ), + pytest.param( + holo_ellipse_angled, + 0, + ( + ([6, 7], [1, 2]), + ([6, 5], [1, 2]), + ([6, 5], [1, 4]), + ([2, 1], [1, 4]), + ([2, 1], [5, 8]), + ([1, 2], [5, 8]), + ), + (5.0, 5.0, 2.82842712474619, 2.82842712474619, 7.071067811865475, 7.071067811865475), + ( + [[6.0, 2.0], [1, 2]], + [[1.0, 5.0], [6, 5]], + [[3.0, 2.0], [1, 4]], + [[0.0, 3.0], [2, 1]], + [[0.0, 3.0], [5, 8]], + [[6.0, 7.0], [1, 2]], + ), + id="holo ellipse angled sorted by axis 0", + ), + pytest.param( + curved_line, + 0, + ( + ([8, 8], [1, 5]), + ([8, 7], [1, 5]), + ([7, 4], [1, 5]), + ([6, 2], [1, 5]), + ([5, 1], [1, 5]), + ([5, 1], [8, 8]), + ([4, 1], [8, 8]), + ([2, 3], [8, 8]), + ), + ( + 7.0, + 6.00832755431992, + 5.813776741499453, + 5.65685424949238, + 5.252257314388902, + 7.0, + 7.7781745930520225, + 7.602631123499284, + ), + ( + [[8.0, 5.0], [1, 5]], + [[6.7, 3.1], [1, 5]], + [[6.2, 2.4], [1, 5]], + [[5.0, 1.0], [1, 5]], + [[2.9310344827586214, 5.827586206896551], [5, 1]], + [[8.0, 1.0], [8, 8]], + [[2.5, 2.5], [8, 8]], + [[1.2, 4.6], [8, 8]], + ), + id="curved line sorted by axis 0", + ), + ], +) +def test_rotating_calipers( + shape: npt.NDArray, axis: int, calipers_target: tuple, min_ferets_target: tuple, min_feret_coords_target: tuple +) -> None: + """Test calculation of rotating caliper pairs.""" + caliper_min_feret = feret.rotating_calipers(np.argwhere(shape == 1), axis) + min_ferets, calipers, min_feret_coords = zip(*caliper_min_feret) + np.testing.assert_array_almost_equal(calipers, calipers_target) + np.testing.assert_array_almost_equal(min_ferets, min_ferets_target) + np.testing.assert_array_almost_equal(min_feret_coords, min_feret_coords_target) + + +@pytest.mark.parametrize( + ("coords", "axis", "calipers_target", "min_ferets_target", "min_feret_coords_target"), + [ + pytest.param( + arbitrary_triangle, + 0, + ( + ([6.92472119, 0.64147496], [1.18727719, 2.96140198]), + ([6.92472119, 0.64147496], [4.14262995, 3.17983179]), + ([1.18727719, 2.96140198], [4.14262995, 3.17983179]), + ), + (2.7365167317626233, 1.3103556366489382, 2.153287228471869), + ( + [[6.7230157, 3.37054783], [6.92472119, 0.64147496]], + [[3.65142552, 1.96502724], [4.14262995, 3.17983179]], + [[2.63860725, 4.55208955], [1.18727719, 2.96140198]], + ), + id="arbitrary triangle sorted by axis 0", + ), + ], +) +def test_rotating_calipers_arbitrary( + coords: npt.NDArray, axis: int, calipers_target: tuple, min_ferets_target: tuple, min_feret_coords_target: tuple +) -> None: + """Test calculation of rotating caliper pairs.""" + caliper_min_feret = feret.rotating_calipers(coords, axis) + min_ferets, calipers, min_feret_coords = zip(*caliper_min_feret) + np.testing.assert_array_almost_equal(calipers, calipers_target) + np.testing.assert_array_almost_equal(min_ferets, min_ferets_target) + np.testing.assert_array_almost_equal(min_feret_coords, min_feret_coords_target) + + +@pytest.mark.parametrize( + ("base1", "base2", "apex", "target_angle"), + [ + pytest.param(np.asarray([1, 1]), np.asarray([1, 0]), np.asarray([0, 0]), 45.0, id="45-degree"), + pytest.param(np.asarray([1, 3]), np.asarray([1, 0]), np.asarray([0, 0]), 18.434948822922017, id="18.43-degree"), + pytest.param(np.asarray([1, 4]), np.asarray([1, 0]), np.asarray([0, 0]), 14.036243467926484, id="-degree"), + ], +) +def test_angle_between(base1: npt.NDArray, base2: npt.NDArray, apex: npt.NDArray, target_angle: float) -> None: + """Test calculation of angle between base and apex.""" + angle = feret._angle_between(apex - base1, base2 - base1) + assert np.rad2deg(angle) == pytest.approx(target_angle) + + +@pytest.mark.parametrize( + ("coordinates", "target"), + [ + pytest.param( + np.asarray([[0, 0], [0, 5], [5, 0], [5, 5]]), + ([[0, 0], [0, 5], [5, 5], [5, 0]]), + id="Simple square Top Left > Top Right > Bottom Left > Bottom Right", + ), + pytest.param( + np.asarray([[1, 1], [1, 0], [0, 0]]), + ([0, 0], [1, 1], [1, 0]), + id="Simple Triangle Bottom Right > Bottom Left > Apex", + ), + pytest.param( + np.argwhere(holo_ellipse_angled == 1), + ([2, 1], [1, 2], [1, 4], [5, 8], [6, 7], [6, 5]), + id="Angled ellipse.", + ), + pytest.param( + np.argwhere(holo_ellipse_horizontal == 1), + ([3, 1], [1, 3], [1, 7], [3, 9], [5, 9], [7, 7], [7, 3], [5, 1]), + id="Horizontal ellipse.", + ), + pytest.param( + np.argwhere(holo_ellipse_vertical == 1), + ([3, 1], [1, 3], [1, 5], [3, 7], [7, 7], [9, 5], [9, 3], [7, 1]), + id="Vertical ellipse.", + ), + pytest.param( + np.argwhere(curved_line == 1), + ([5, 1], [4, 1], [2, 3], [1, 5], [8, 8], [8, 7], [7, 4], [6, 2]), + id="Curved line.", + ), + pytest.param( + arbitrary_triangle, + ([[1.18727719, 2.96140198], [4.14262995, 3.17983179], [6.92472119, 0.64147496]]), + id="Arbitrary triangle", + ), + ], +) +def test_sort_clockwise(coordinates: npt.NDArray, target: npt.NDArray) -> None: + """Test sorting of coordinates in a clockwise direction.""" + upper_hull, lower_hull = feret.hulls(coordinates) + hull = np.unique(np.concatenate([lower_hull, upper_hull], axis=0), axis=0) + np.testing.assert_array_equal(feret.sort_clockwise(hull), target) + + +@pytest.mark.parametrize( + ( + "shape", + "axis", + "min_feret_distance_target", + "min_feret_coord_target", + "max_feret_distance_target", + "max_feret_coord_target", + ), + [ + pytest.param( + tiny_circle, + 0, + 1.4142135623730951, + ([0, 1], [1, 0]), + 2.0, + ([2, 1], [0, 1]), + id="tiny circle sorted on axis 0", + ), + pytest.param( + tiny_circle, + 1, + 1.4142135623730951, + ([0, 1], [1, 0]), + 2.0, + ([2, 1], [0, 1]), + id="tiny circle sorted on axis 1", + ), + pytest.param( + tiny_square, + 0, + 1.0, + ([1, 1], [1, 2]), + 1.4142135623730951, + ([2, 2], [1, 1]), + id="tiny square sorted on axis 0", + ), + pytest.param( + tiny_quadrilateral, + 0, + 2.4961508830135313, + ([3.384615384615385, 3.0769230769230766], [2, 1]), + 4.0, + ([5, 2], [1, 2]), + id="tiny quadrilateral sorted on axis 0", + ), + pytest.param( + tiny_quadrilateral, + 1, + 2.4961508830135313, + ([3.384615384615385, 3.0769230769230766], [2, 1]), + 4.0, + ([5, 2], [1, 2]), + id="tiny quadrilateral sorted on axis 1", + ), + pytest.param( + tiny_triangle, + 0, + 0.7071067811865475, + ([1.5, 1.5], [1, 1]), + 1.4142135623730951, + ([2, 1], [1, 2]), + id="tiny triangle sorted on axis 0", + ), + pytest.param( + tiny_rectangle, + 0, + 1.0, + ([1, 1], [1, 2]), + 2.23606797749979, + ([3, 2], [1, 1]), + id="tiny rectangle sorted on axis 0", + ), + pytest.param(tiny_ellipse, 0, 2.0, ([2, 1], [2, 3]), 4.0, ([5, 2], [1, 2]), id="tiny ellipse sorted on axis 0"), + pytest.param( + small_circle, + 1, + 4.0, + ([0, 1], [4, 1]), + 4.47213595499958, + ([4, 3], [0, 1]), + id="small circle sorted on axis 0", + ), + pytest.param( + holo_circle, + 0, + 4.0, + ([1, 2], [5, 2]), + 4.47213595499958, + ([5, 4], [1, 2]), + id="holo circle sorted on axis 0", + ), + pytest.param( + holo_ellipse_horizontal, + 0, + 6.0, + ([1, 3], [7, 3]), + 8.246211251235321, + ([5, 1], [3, 9]), + id="holo ellipse horizontal on axis 0", + ), + pytest.param( + holo_ellipse_vertical, + 0, + 6.0, + ([3, 1], [3, 7]), + 8.246211251235321, + ([9, 5], [1, 3]), + id="holo ellipse vertical on axis 0", + ), + pytest.param( + holo_ellipse_angled, + 0, + 2.82842712474619, + ([0, 3], [2, 1]), + 7.615773105863909, + ([2, 1], [5, 8]), + id="holo ellipse angled on axis 0", + ), + pytest.param( + curved_line, + 0, + 5.252257314388902, + ([2.93103448275862, 5.827586206896552], [5, 1]), + 8.06225774829855, + ([4, 1], [8, 8]), + id="curved line sorted on axis 0", + ), + pytest.param( + curved_line, + 1, + 5.252257314388902, + ([2.93103448275862, 5.827586206896552], [5, 1]), + 8.06225774829855, + ([8, 8], [4, 1]), + id="curved line sorted on axis 1", + ), + ], +) +def test_min_max_feret( + shape: npt.NDArray, + axis: int, + min_feret_distance_target: float, + min_feret_coord_target: list, + max_feret_distance_target: float, + max_feret_coord_target: list, +) -> None: + """Test calculation of min/max feret.""" + feret_statistics = feret.min_max_feret(np.argwhere(shape == 1), axis) + np.testing.assert_approx_equal(feret_statistics["min_feret"], min_feret_distance_target) + np.testing.assert_array_almost_equal(feret_statistics["min_feret_coords"], min_feret_coord_target) + np.testing.assert_approx_equal(feret_statistics["max_feret"], max_feret_distance_target) + np.testing.assert_array_almost_equal(feret_statistics["max_feret_coords"], max_feret_coord_target) + + +@pytest.mark.parametrize( + ( + "shape", + "axis", + "min_feret_distance_target", + "min_feret_coord_target", + "max_feret_distance_target", + "max_feret_coord_target", + ), + [ + pytest.param( + filled_circle, + 0, + 6.0, + ([1.0, 2.0], [7.0, 2.0]), + 7.211102550927978, + ([7, 6], [1, 2]), + id="filled circle sorted on axis 0", + ), + pytest.param( + filled_ellipse_horizontal, + 0, + 4.0, + ([1.0, 2.0], [5.0, 2.0]), + 6.324555320336759, + ([4, 1], [2, 7]), + id="filled ellipse horizontal sorted on axis 0", + ), + pytest.param( + filled_ellipse_vertical, + 0, + 4.0, + ([2.0, 1.0], [2.0, 5.0]), + 6.324555320336759, + ([7, 4], [1, 2]), + id="filled ellipse vertical sorted on axis 0", + ), + pytest.param( + filled_ellipse_angled, + 0, + 5.366563145999495, + ([1.2, 4.6], [6.0, 7.0]), + 8.94427190999916, + ([6, 1], [2, 9]), + id="filled ellipse angled sorted on axis 0", + ), + ], +) +def test_get_feret_from_mask( + shape: npt.NDArray, + axis: int, + min_feret_distance_target: float, + min_feret_coord_target: list, + max_feret_distance_target: float, + max_feret_coord_target: list, +) -> None: + """Test calculation of min/max feret for a single masked object.""" + feret_statistics = feret.get_feret_from_mask(shape, axis) + np.testing.assert_approx_equal(feret_statistics["min_feret"], min_feret_distance_target) + np.testing.assert_array_almost_equal(feret_statistics["min_feret_coords"], min_feret_coord_target) + np.testing.assert_approx_equal(feret_statistics["max_feret"], max_feret_distance_target) + np.testing.assert_array_almost_equal(feret_statistics["max_feret_coords"], max_feret_coord_target) + + +# Concatenate images to have two labeled objects within them +holo_ellipse_angled2 = holo_ellipse_angled.copy() +holo_ellipse_angled2[holo_ellipse_angled2 == 1] = 2 +# Need to pad the holo_circle +holo_image = np.concatenate((np.pad(holo_circle, pad_width=((0, 1), (0, 3))), holo_ellipse_angled2)) +filled_ellipse_angled2 = filled_ellipse_angled.copy() +filled_ellipse_angled2[filled_ellipse_angled2 == 1] = 2 +filled_image = np.concatenate((np.pad(filled_circle, pad_width=((0, 0), (0, 2))), filled_ellipse_angled2)) + + +@pytest.mark.parametrize( + ("shape", "axis", "target"), + [ + pytest.param( + holo_image, + 0, + { + 1: { + "min_feret": 4.0, + "min_feret_coords": ([1.0, 2.0], [5.0, 2.0]), + "max_feret": 4.47213595499958, + "max_feret_coords": ([5, 4], [1, 2]), + }, + 2: { + "min_feret": 2.82842712474619, + "min_feret_coords": ([8.0, 3.0], [10.0, 1.0]), + "max_feret": 7.615773105863909, + "max_feret_coords": ([10, 1], [13, 8]), + }, + }, + id="holo image", + ), + pytest.param( + filled_image, + 0, + { + 1: { + "min_feret": 6.0, + "min_feret_coords": ([1.0, 2.0], [7.0, 2.0]), + "max_feret": 7.211102550927978, + "max_feret_coords": ([7, 6], [1, 2]), + }, + 2: { + "min_feret": 5.366563145999495, + "min_feret_coords": ([10.2, 4.6], [15, 7]), + "max_feret": 8.94427190999916, + "max_feret_coords": ([15, 1], [11, 9]), + }, + }, + id="filled image", + ), + ], +) +def test_get_feret_from_labelim(shape: npt.NDArray, axis: int, target: dict) -> None: + """Test calculation of min/max feret for a labelled image with multiple objects.""" + min_max_feret_size_coord = feret.get_feret_from_labelim(shape, axis=axis) + for key, feret_statistics in min_max_feret_size_coord.items(): + np.testing.assert_equal(feret_statistics["min_feret"], target[key]["min_feret"]) + np.testing.assert_array_almost_equal(feret_statistics["min_feret_coords"], target[key]["min_feret_coords"]) + np.testing.assert_equal(feret_statistics["max_feret"], target[key]["max_feret"]) + np.testing.assert_array_almost_equal(feret_statistics["max_feret_coords"], target[key]["max_feret_coords"]) + + +@pytest.mark.parametrize( + ( + "shape", + "axis", + "plot_points", + "plot_hulls", + "plot_calipers", + "plot_triangle_heights", + "plot_min_feret", + "plot_max_feret", + ), + [ + pytest.param(tiny_quadrilateral, 0, "k", ("g-", "r-"), "y-", "b:", "m--", "m--", id="Plot everything"), + pytest.param(tiny_quadrilateral, 0, None, ("g-", "r-"), "y-", "b:", "m--", "m--", id="Exclude points"), + pytest.param(tiny_quadrilateral, 0, "k", None, "y-", "b:", "m--", "m--", id="Exclude hull"), + pytest.param(tiny_quadrilateral, 0, "k", ("g-", "r-"), None, "b:", "m--", "m--", id="Exclude calipers"), + pytest.param(tiny_quadrilateral, 0, "k", ("g-", "r-"), "y-", None, "m--", "m--", id="Exclude triangle heights"), + pytest.param(tiny_quadrilateral, 0, "k", ("g-", "r-"), "y-", "b:", None, "m--", id="Exclude min feret"), + pytest.param(tiny_quadrilateral, 0, "k", ("g-", "r-"), "y-", "b:", "m--", None, id="Exclude max feret"), + ], +) +@pytest.mark.mpl_image_compare(baseline_dir="../resources/img/feret") +def test_plot_feret( # pylint: disable=too-many-arguments + shape: npt.NDArray, + axis: int, + plot_points: str | None, + plot_hulls: tuple | None, + plot_calipers: str | None, + plot_triangle_heights: str | None, + plot_min_feret: str | None, + plot_max_feret: str | None, +) -> None: + """Tests the plotting function used for investigating whether feret distances are correct.""" + fig, _ = feret.plot_feret( + np.argwhere(shape == 1), + axis, + plot_points, + plot_hulls, + plot_calipers, + plot_triangle_heights, + plot_min_feret, + plot_max_feret, + ) + return fig diff --git a/tests/resources/img/feret/test_plot_feret_Exclude calipers.png b/tests/resources/img/feret/test_plot_feret_Exclude calipers.png new file mode 100644 index 0000000000..1e6ad76c4b Binary files /dev/null and b/tests/resources/img/feret/test_plot_feret_Exclude calipers.png differ diff --git a/tests/resources/img/feret/test_plot_feret_Exclude hull.png b/tests/resources/img/feret/test_plot_feret_Exclude hull.png new file mode 100644 index 0000000000..655d64291c Binary files /dev/null and b/tests/resources/img/feret/test_plot_feret_Exclude hull.png differ diff --git a/tests/resources/img/feret/test_plot_feret_Exclude max feret.png b/tests/resources/img/feret/test_plot_feret_Exclude max feret.png new file mode 100644 index 0000000000..3d577657c8 Binary files /dev/null and b/tests/resources/img/feret/test_plot_feret_Exclude max feret.png differ diff --git a/tests/resources/img/feret/test_plot_feret_Exclude min feret.png b/tests/resources/img/feret/test_plot_feret_Exclude min feret.png new file mode 100644 index 0000000000..d77028f714 Binary files /dev/null and b/tests/resources/img/feret/test_plot_feret_Exclude min feret.png differ diff --git a/tests/resources/img/feret/test_plot_feret_Exclude points.png b/tests/resources/img/feret/test_plot_feret_Exclude points.png new file mode 100644 index 0000000000..4a251e68e5 Binary files /dev/null and b/tests/resources/img/feret/test_plot_feret_Exclude points.png differ diff --git a/tests/resources/img/feret/test_plot_feret_Exclude triangle heights.png b/tests/resources/img/feret/test_plot_feret_Exclude triangle heights.png new file mode 100644 index 0000000000..6c0d944c78 Binary files /dev/null and b/tests/resources/img/feret/test_plot_feret_Exclude triangle heights.png differ diff --git a/tests/resources/img/feret/test_plot_feret_Plot everything.png b/tests/resources/img/feret/test_plot_feret_Plot everything.png new file mode 100644 index 0000000000..ac795537a5 Binary files /dev/null and b/tests/resources/img/feret/test_plot_feret_Plot everything.png differ diff --git a/tests/test_grainstats.py b/tests/test_grainstats.py index 118907f366..2928de9085 100644 --- a/tests/test_grainstats.py +++ b/tests/test_grainstats.py @@ -300,7 +300,72 @@ def test_grainstats_get_triangle_height(base_point_1, base_point_2, top_point, e assert GrainStats.get_triangle_height(base_point_1, base_point_2, top_point) == expected -@pytest.mark.parametrize(("edge_points", "expected"), [([[0, 0], [0, 1], [1, 0], [1, 1]], (1.0, 1.4142135623730951))]) -def test_get_min_max_ferets(edge_points, expected) -> None: +@pytest.mark.parametrize( + ("edge_points", "min_expected", "max_expected"), + [ + pytest.param([[0, 0], [0, 1], [1, 0], [1, 1]], 1.0, 1.4142135623730951, id="square"), + pytest.param([[1, 1], [1, 2], [2, 1]], 0.7071067811865476, 1.4142135623730951, id="triangle (isosceles)"), + pytest.param([[0, 0], [1, 0], [0, 2]], 0.8944271909999159, 2.23606797749979, id="triangle"), + pytest.param([[0, 1], [1, 0], [1, 2], [2, 1]], 1.4142135623730951, 2.0, id="circle"), + pytest.param([[1, 2], [2, 1], [2, 4], [5, 2]], 2.4961508830135313, 4, id="quadrilateral"), + pytest.param( + [ + [1, 3], + [1, 4], + [1, 5], + [1, 6], + [1, 7], + [2, 2], + [2, 8], + [3, 1], + [3, 9], + [4, 1], + [4, 9], + [5, 1], + [5, 9], + [6, 2], + [6, 8], + [7, 3], + [7, 4], + [7, 5], + [7, 6], + [7, 7], + ], + 6.0, + 8.246211251235321, + id="horizontal ellipse", + ), + pytest.param( + [ + [1, 2], + [1, 3], + [1, 4], + [2, 1], + [2, 5], + [3, 2], + [3, 6], + [4, 3], + [4, 7], + [5, 4], + [5, 8], + [6, 5], + [6, 6], + [6, 7], + ], + 2.82842712474619, + 7.615773105863909, + id="angled ellipse", + ), + pytest.param( + [[1, 5], [2, 3], [2, 4], [3, 2], [4, 1], [5, 1], [6, 2], [6, 3], [7, 4], [7, 5], [7, 6], [8, 7], [8, 8]], + 5.252257314388902, + 8.06225774829855, + id="curved line", + ), + ], +) +def test_get_min_max_ferets(edge_points, min_expected, max_expected) -> None: """Tests the GrainStats.get_min_max_ferets method.""" - assert GrainStats.get_max_min_ferets(edge_points) == expected + min_feret, max_feret = GrainStats.get_max_min_ferets(edge_points) + np.testing.assert_almost_equal(min_feret, min_expected) + np.testing.assert_almost_equal(max_feret, max_expected) diff --git a/topostats/grainstats.py b/topostats/grainstats.py index 84f9b042f0..c711cfed4f 100644 --- a/topostats/grainstats.py +++ b/topostats/grainstats.py @@ -1019,7 +1019,6 @@ def get_max_min_ferets(edge_points: list): # noqa: C901 upper_hull = np.array(upper_hull) lower_hull = np.array(lower_hull) - # Create list of contact vertices for calipers on the antipodal hulls contact_points = [] upper_index = 0 @@ -1081,7 +1080,6 @@ def get_max_min_ferets(edge_points: list): # noqa: C901 min_feret = small_feret contact_points = np.array(contact_points) - # Find the minimum and maximum distance in the contact points max_feret = None for point_pair in contact_points: diff --git a/topostats/measure/feret.py b/topostats/measure/feret.py new file mode 100644 index 0000000000..1ae7b13bb2 --- /dev/null +++ b/topostats/measure/feret.py @@ -0,0 +1,550 @@ +"""Calculate feret distances for 2-D objects. + +This code comes from a gist written by @VolkerH under BSD-3 License + +https://gist.github.com/VolkerH/0d07d05d5cb189b56362e8ee41882abf + +During testing it was discovered that sorting points prior to derivation of upper and lower convex hulls was problematic +so this step was removed. +""" + +from __future__ import annotations + +import logging +import warnings +from collections.abc import Generator +from math import sqrt +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +import numpy.typing as npt +import skimage.morphology + +from topostats.logs.logs import LOGGER_NAME + +LOGGER = logging.getLogger(LOGGER_NAME) + +# Handle warnings as exceptions (encountered when gradient of base triangle is zero) +warnings.filterwarnings("error") + +# pylint: disable=fixme + + +def orientation(p: npt.NDArray, q: npt.NDArray, r: npt.NDArray) -> int: + """ + Determine the orientation of three points as either clockwise, counter-clock-wise or colinear. + + Parameters + ---------- + p : npt.NDArray + First point (assumed to have a length of 2). + q : npt.NDArray + Second point (assumed to have a length of 2). + r : npt.NDArray + Third point (assumed to have a length of 2). + + Returns + ------- + int: + Returns a positive value if p-q-r are clockwise, neg if counter-clock-wise, zero if colinear. + """ + return (q[1] - p[1]) * (r[0] - p[0]) - (q[0] - p[0]) * (r[1] - p[1]) + + +def sort_coords(points: npt.NDArray, axis: int = 1) -> npt.NDArray: + """ + Sort the coordinates. + + Parameters + ---------- + points : npt.NDArray + Array of coordinates + axis : int + Which axis to axis coordinates on 0 for row; 1 for columns (default). + + Returns + ------- + npt.NDArray + Array sorted by row then column. + """ + if axis == 1: + order = np.lexsort((points[:, 0], points[:, 1])) + elif axis == 0: + order = np.lexsort((points[:, 1], points[:, 0])) + else: + raise ValueError("Invalid axis provided for sorting, only 0 and 1 permitted.") + return points[order] + + +def hulls(points: npt.NDArray, axis: int = 1) -> tuple[list, list]: + """ + Graham scan to find upper and lower convex hulls of a set of 2-D points. + + Points should be sorted in asecnding order first. + + `Graham scan ` + + Parameters + ---------- + points : npt.NDArray + 2-D Array of points for the outline of an object. + axis : int + Which axis to sort coordinates on 0 for row; 1 for columns (default). + + Returns + ------- + tuple[list, list] + Tuple of two Numpy arrays of the original coordinates split into upper and lower hulls. + """ + upper_hull: list = [] + lower_hull: list = [] + if axis: + points = sort_coords(points, axis) + for p in points: + # Remove points if they are not in the correct hull + while len(upper_hull) > 1 and orientation(upper_hull[-2], upper_hull[-1], p) <= 0: + upper_hull.pop() + while len(lower_hull) > 1 and orientation(lower_hull[-2], lower_hull[-1], p) >= 0: + lower_hull.pop() + # Append point to each hull (removed from one in next pass) + upper_hull.append(list(p)) + lower_hull.append(list(p)) + + return upper_hull, lower_hull + + +def all_pairs(points: npt.NDArray) -> list[tuple[list, list]]: + """ + Given a list of 2-D points, finds all ways of sandwiching the points. + + Calculates the upper and lower convex hulls and then finds all pairwise combinations between each set of points. + + Parameters + ---------- + points : npt.NDArray + Numpy array of coordinates defining the outline of an object.mro + + Returns + ------- + List[tuple[int, int]] + """ + upper_hull, lower_hull = hulls(points) + unique_combinations = {} + for upper in upper_hull: + for lower in lower_hull: + if upper != lower: + lowest = min(upper, lower) + highest = max(upper, lower) + unique_combinations[f"{str(lowest)}-{str(highest)}"] = (lowest, highest) + return list(unique_combinations.values()) + + +def rotating_calipers(points: npt.NDArray, axis: int = 0) -> Generator: + """ + Given a list of 2-D points, finds all ways of sandwiching the points between two parallel lines. + + This yields the sequence of pairs of points touched by each pair of lines across all points around the hull of a + polygon. + + `Rotating Calipers _` + + Parameters + ---------- + points : npt.NDArray + Numpy array of coordinates defining the outline of an object. + axis : int + Which axis to sort coordinates on, 0 for row (default); 1 for columns. + + Returns + ------- + Generator + Numpy array of pairs of points + """ + upper_hull, lower_hull = hulls(points, axis) + upper_index = 0 + lower_index = len(lower_hull) - 1 + counter = 0 + while upper_index < len(upper_hull) - 1 or lower_index > 0: + # yield upper_hull[upper_index], lower_hull[lower_index] + calipers = (lower_hull[lower_index], upper_hull[upper_index]) + if upper_index == len(upper_hull) - 1: + lower_index -= 1 + base1 = lower_hull[lower_index + 1] # original lower caliper + base2 = lower_hull[lower_index] # previous point on lower hull + apex = upper_hull[upper_index] # original upper caliper + elif lower_index == 0: + upper_index += 1 + base1 = upper_hull[upper_index - 1] # original upper caliper + base2 = upper_hull[upper_index] # next point on upper hull + apex = lower_hull[lower_index] # original lower caliper + # still points left on both lists, compare slopes of next hull edges + # being careful to avoid ZeroDivisionError in slope calculation + elif ( + (upper_hull[upper_index + 1][1] - upper_hull[upper_index][1]) + * (lower_hull[lower_index][0] - lower_hull[lower_index - 1][0]) + ) > ( + (lower_hull[lower_index][1] - lower_hull[lower_index - 1][1]) + * (upper_hull[upper_index + 1][0] - upper_hull[upper_index][0]) + ): + upper_index += 1 + base1 = upper_hull[upper_index - 1] # original upper caliper + base2 = upper_hull[upper_index] # next point on upper hull + apex = lower_hull[lower_index] # original lower caliper + else: + lower_index -= 1 + base1 = lower_hull[lower_index + 1] # original lower caliper + base2 = lower_hull[lower_index] # previous point on lower hull + apex = upper_hull[upper_index] # original upper caliper + counter += 1 + yield triangle_height(base1, base2, apex), calipers, np.asarray( + [ + list(_min_feret_coord(np.asarray(base1), np.asarray(base2), np.asarray(apex))), + apex, + ] + ) + + +def triangle_height(base1: npt.NDArray | list, base2: npt.NDArray | list, apex: npt.NDArray | list) -> float: + """ + Calculate the height of triangle formed by three points. + + Parameters + ---------- + base1 : int + Coordinate of first base point of triangle. + base2 : int + Coordinate of second base point of triangle. + apex : int + Coordinate of the apex of the triangle. + + Returns + ------- + float + Height of the triangle. + + Examples + -------- + >>> min_feret([4, 0], [4, 3], [0,0]) + 4.0 + """ + base1_base2 = np.asarray(base1) - np.asarray(base2) + base1_apex = np.asarray(base1) - np.asarray(apex) + return np.linalg.norm(np.cross(base1_base2, base1_apex)) / np.linalg.norm(base1_base2) + + +def _min_feret_coord( + base1: npt.NDArray, base2: npt.NDArray, apex: npt.NDArray, round_coord: bool = False +) -> npt.NDArray: + """ + Calculate the coordinate opposite the apex that is prependicular to the base of the triangle. + + Code courtesy of @SylviaWhittle. + + Parameters + ---------- + base1 : npt.NDArray + Coordinates of one point on base of triangle, these are on the same side of the hull. + base2 : npt.NDArray + Coordinates of second point on base of triangle, these are on the same side of the hull. + apex : npt.NDArray + Coordinate of the apex of the triangle, this is on the opposite hull. + round_coord : bool + Whether to round the point to the nearest NumPy index relative to the apex's position (i.e. either floor or + ceiling). + + Returns + ------- + npt.NDArray + Coordinates of the point perpendicular to the base line that is opposite the apex, this line is the minimum + feret distance for acute triangles (but not scalene triangles). + """ + angle_apex_base1_base2 = _angle_between(apex - base1, base2 - base1) + len_apex_base2 = np.linalg.norm(apex - base1) + cos_apex_base1_base2 = np.cos(angle_apex_base1_base2) + k = len_apex_base2 * cos_apex_base1_base2 + unit_base1_base2 = (base1 - base2) / np.linalg.norm(base2 - base1) + d = base1 - k * unit_base1_base2 + if round_coord: + # Round up/down base on position relative to apex to get an actual cell + d[0] = np.ceil(d[0]) if d[0] > apex[0] else np.floor(d[0]) + d[1] = np.ceil(d[1]) if d[1] > apex[1] else np.floor(d[1]) + return np.asarray([int(d[0]), int(d[1])]) + return np.asarray([d[0], d[1]]) + + +def _angle_between(apex: npt.NDArray, b: npt.NDArray) -> float: + """ + Calculate the angle between the apex and base of the triangle. + + Parameters + ---------- + apex: npt.NDArray + Difference between apex and base1 coordinates. + b: npt.NDArray + Difference between base2 and base1 coordinates. + + Returns + ------- + float + The angle between the base and the apex. + """ + return np.arccos(np.dot(apex, b) / (np.linalg.norm(apex) * np.linalg.norm(b))) + + +def sort_clockwise(coordinates: npt.NDArray) -> npt.NDArray: + """Sort an array of coordinates in a clockwise order. + + Parameters + ---------- + coordinates : npt.NDArray + Unordered array of points. Typically a convex hull. + + Returns + ------- + npt.NDArray + Points ordered in a clockwise direction. + + Examples + -------- + >>> import numpy as np + >>> from topostats.measure import feret + >>> unordered = np.asarray([[0, 0], [5, 5], [0, 5], [5, 0]]) + >>> feret.sort_clockwise(unordered) + + array([[0, 0], + [0, 5], + [5, 5], + [5, 0]]) + """ + center_x, center_y = coordinates.mean(0) + x, y = coordinates.T + angles = np.arctan2(x - center_x, y - center_y) + order = np.argsort(angles) + return coordinates[order] + + +def min_max_feret(points: npt.NDArray, axis: int = 0) -> dict[float, tuple[int, int], float, tuple[int, int]]: + """ + Given a list of 2-D points, returns the minimum and maximum feret diameters. + + `Feret diameter ` + + Parameters + ---------- + points : npt.NDArray + A 2-D array of points for the outline of an object. + axis : int + Which axis to sort coordinates on, 0 for row (default); 1 for columns. + precision : int + Number of decimal places passed on to in_polygon() for rounding. + + Returns + ------- + dictionary + Tuple of the minimum feret distance and its coordinates and the maximum feret distance and its coordinates. + """ + caliper_min_feret = list(rotating_calipers(points, axis)) + min_ferets, calipers, min_feret_coords = zip(*caliper_min_feret) + # Calculate the squared distance between caliper pairs for max feret + calipers = np.asarray(calipers) + caliper1 = calipers[:, 0] + caliper2 = calipers[:, 1] + # Determine maximum feret (and coordinates) from all possible calipers + squared_distance_per_pair = [ + ((p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2, (list(p), list(q))) for p, q in zip(caliper1, caliper2) + ] + max_feret_sq, max_feret_coord = max(squared_distance_per_pair) + # Determine minimum feret (and coordinates) from all caliper triangles, but only if the min_feret_coords (y) are + # within the polygon + triangle_min_feret = [[x, (list(map(list, y)))] for x, y in zip(min_ferets, min_feret_coords)] + min_feret, min_feret_coord = min(triangle_min_feret) + return { + "min_feret": min_feret, + "min_feret_coords": np.asarray(min_feret_coord), + "max_feret": sqrt(max_feret_sq), + "max_feret_coords": np.asarray(max_feret_coord), + } + + +def get_feret_from_mask(mask_im: npt.NDArray, axis: int = 0) -> tuple[float, tuple[int, int], float, tuple[int, int]]: + """ + Calculate the minimum and maximum feret diameter of the foreground object of a binary mask. + + The outline of the object is calculated and the pixel coordinates transformed to a list for calculation. + + Parameters + ---------- + mask_im : npt.NDArray + Binary Numpy array. + axis : int + Which axis to sort coordinates on, 0 for row (default); 1 for columns. + + Returns + ------- + Tuple[float, Tuple[int, int], float, Tuple[int, int]] + Returns a tuple of the minimum feret and its coordinates and the maximum feret and its coordinates. + """ + eroded = skimage.morphology.erosion(mask_im) + outline = mask_im ^ eroded + boundary_points = np.argwhere(outline > 0) + boundary_point_list = np.asarray(list(map(list, list(boundary_points)))) + return min_max_feret(boundary_point_list, axis) + + +def get_feret_from_labelim(label_image: npt.NDArray, labels: None | list | set = None, axis: int = 0) -> dict: + """ + Calculate the minimum and maximum feret and coordinates of each connected component within a labelled image. + + If labels is None, all labels > 0 will be analyzed. + + Parameters + ---------- + label_image : npt.NDArray + Numpy array with labelled connected components (integer) + labels : None | list + A list of labelled objects for which to calculate + axis : int + Which axis to sort coordinates on, 0 for row (default); 1 for columns. + + Returns + ------- + dict + Dictionary with labels as keys and values are a tuple of the minimum and maximum feret distances and + coordinates. + """ + if labels is None: + labels = set(np.unique(label_image)) - {0} + results = {} + for label in labels: + results[label] = get_feret_from_mask(label_image == label, axis) + return results + + +def plot_feret( # pylint: disable=too-many-arguments,too-many-locals # noqa: C901 + points: npt.NDArray, + axis: int = 0, + plot_points: str | None = "k", + plot_hulls: tuple | None = ("g-", "r-"), + plot_calipers: str | None = "y-", + plot_triangle_heights: str | None = "b:", + plot_min_feret: str | None = "m--", + plot_max_feret: str | None = "m--", + filename: str | Path | None = "./feret.png", + show: bool = False, +) -> None: + """ + Plot upper and lower convex hulls with rotating calipers and optionally the minimum feret distances. + + Plot varying levels of details in constructing convex hulls and deriving the minimum and maximum feret. + + For format strings see the Notes section of `matplotlib.pyplot.plot + `. + + Parameters + ---------- + points : npt.NDArray + Points to be plotted which form the shape of interest. + axis : int + Which axis to sort coordinates on, 0 for row (default); 1 for columns. (Should give the same results!). + plot_points : str | None + Format string for plotting points. If 'None' points are not plotted. + plot_hulls : tuple | None + Tuple of length 2 of format strings for plotting the convex hull, these should differe to allow distinction + between hulls. If 'None' hulls are not plotted. + plot_calipers : str | None + Format string for plotting calipers. If 'None' calipers are not plotted. + plot_triangle_heights : str | None + Format string for plotting the triangle heights used in calulcating the minimum feret. These should cross the + opposite edge perpendicularly. If 'None' triangle heights are not plotted. + plot_min_feret : str | None + Format string for plotting the minimum feret. If 'None' the minimum feret is not plotted. + plot_max_feret : str | None + Format string for plotting the maximum feret. If 'None' the maximum feret is not plotted. + filename : str | Path | None + Location to save the image to. + show : bool + Whether to display the image. + + Examples + -------- + >>> from skimage import draw + >>> from topostats.measure import feret + + >>> tiny_quadrilateral = np.asarray( + [ + [0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0], + [0, 1, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + ], + dtype=np.uint8) + + >>> feret.plot_feret(np.argwhere(tiny_quadrilateral == 1)) + + >>> holo_ellipse_angled = np.zeros((8, 10), dtype=np.uint8) + rr, cc = draw.ellipse_perimeter(4, 5, 1, 3, orientation=np.deg2rad(30)) + holo_ellipse_angled[rr, cc] = 1 + + >>> feret.plot_feret(np.argwhere(holo_ellipse_angled == 1), plot_heights = None) + + >>> another_triangle = np.asarray([[5, 4], [2, 1], [8,2]]) + + >>> feret.plot_feret(another_triangle) + """ + # Derive everything needed regardless of required, routine is only for investigating/debugging so speed not + # critical. + upper_hull, lower_hull = hulls(points) + upper_hull = np.asarray(upper_hull) + lower_hull = np.asarray(lower_hull) + min_feret_calipers_base = list(rotating_calipers(points, axis)) + _, calipers, triangle_coords = zip(*min_feret_calipers_base) + calipers = np.asarray(calipers) + triangle_coords = np.asarray(triangle_coords) + statistics = min_max_feret(points, axis) + min_feret_coords = np.asarray(statistics["min_feret_coords"]) + max_feret_coords = np.asarray(statistics["max_feret_coords"]) + + fig, ax = plt.subplots(1, 1) + if plot_points is not None: + plt.scatter(points[:, 0], points[:, 1], c=plot_points) + if plot_hulls is not None: + plt.plot(upper_hull[:, 0], upper_hull[:, 1], plot_hulls[0], label="Upper Hull") + plt.scatter(upper_hull[:, 0], upper_hull[:, 1], c=plot_hulls[0][0]) + plt.plot(lower_hull[:, 0], lower_hull[:, 1], plot_hulls[1], label="Lower Hull") + plt.scatter(lower_hull[:, 0], lower_hull[:, 1], c=plot_hulls[1][0]) + if plot_calipers is not None: + for caliper in calipers: + plt.plot(caliper[:, 0], caliper[:, 1], plot_calipers) + if plot_triangle_heights is not None: + for triangle_h in triangle_coords: + plt.plot(triangle_h[:, 0], triangle_h[:, 1], plot_triangle_heights) + if plot_min_feret is not None: + # for min_feret in min_feret_coords: + plt.plot( + min_feret_coords[:, 0], + min_feret_coords[:, 1], + plot_min_feret, + label=f"Minimum Feret ({statistics['min_feret']:.3f})", + ) + if plot_max_feret is not None: + # for max_feret in max_feret_coords: + plt.plot( + max_feret_coords[:, 0], + max_feret_coords[:, 1], + plot_max_feret, + label=f"Maximum Feret ({statistics['max_feret']:.3f})", + ) + plt.title("Upper and Lower Convex Hulls") + plt.axis("equal") + plt.legend() + plt.grid(True) + if filename is not None: + plt.savefig(filename) + if show: + plt.show() + + return fig, ax diff --git a/topostats/validation.py b/topostats/validation.py index 46a0198917..c5d9664ffc 100644 --- a/topostats/validation.py +++ b/topostats/validation.py @@ -899,7 +899,7 @@ def validate_config(config: dict, schema: Schema, config_type: str) -> None: "svg", "tiff", "tif", - error=("Invalid value in config 'savefig_format', valid values are 'png', 'pdf', 'svg' or 'tif'"), + error=("Invalid value in config 'savefig_format', valid values are 'png', 'pdf', 'svg', 'tiff' or 'tif'"), ), "pickle_plots": Or( True,