Skip to content

Commit

Permalink
Feat: Add Circle.best_fit method (#294)
Browse files Browse the repository at this point in the history
Authored-by: cristiano.pizzamiglio <cristiano.pizzamiglio@limacorporate.com>
  • Loading branch information
CristianoPizzamiglio committed Feb 11, 2022
1 parent 7491176 commit 25be3c8
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 0 deletions.
2 changes: 2 additions & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ Contributors
------------

* Marcelo Moreno (https://github.com/martxelo)

* Cristiano Pizzamiglio (https://github.com/CristianoPizzamiglio)
1 change: 1 addition & 0 deletions docs/source/api_reference/skspatial.objects.Circle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Methods
:toctree: Circle/methods

~skspatial.objects.Circle.area
~skspatial.objects.Circle.best_fit
~skspatial.objects.Circle.circumference
~skspatial.objects.Circle.intersect_line
~skspatial.objects.Circle.plot_2d
66 changes: 66 additions & 0 deletions src/skspatial/objects/circle.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Module for the Circle class."""
from __future__ import annotations

import math
from typing import Tuple

Expand All @@ -10,6 +12,7 @@
from skspatial.objects._base_sphere import _BaseSphere
from skspatial.objects.line import Line
from skspatial.objects.point import Point
from skspatial.objects.points import Points
from skspatial.typing import array_like


Expand Down Expand Up @@ -234,6 +237,69 @@ def intersect_line(self, line: Line) -> Tuple[Point, Point]:

return point_a, point_b

@classmethod
def best_fit(cls, points: array_like) -> Circle:
"""
Return the sphere of best fit for a set of 2D points.
Parameters
----------
points : array_like
Input 2D points.
Returns
-------
Circle
The circle of best fit.
Raises
------
ValueError
If the points are not 2D.
If there are fewer than three points.
If the points are collinear.
Reference
---------
https://meshlogic.github.io/posts/jupyter/curve-fitting/fitting-a-circle-to-cluster-of-3d-points/
Examples
--------
>>> import numpy as np
>>> from skspatial.objects import Circle
>>> points = [[1, 1], [2, 2], [3, 1]]
>>> circle = Circle.best_fit(points)
>>> circle.point
Point([2., 1.])
>>> np.round(circle.radius, 2)
1.0
"""
points = Points(points)

if points.dimension != 2:
raise ValueError("The points must be 2D.")

if points.shape[0] < 3:
raise ValueError("There must be at least 3 points.")

if points.affine_rank() != 2:
raise ValueError("The points must not be collinear.")

n = points.shape[0]
A = np.hstack((2 * points, np.ones((n, 1))))
b = (points ** 2).sum(axis=1)
c = np.linalg.lstsq(A, b, rcond=None)[0]

center = c[:2]
radius = np.sqrt(c[2] + c[0] ** 2 + c[1] ** 2)

return cls(center, radius)

def plot_2d(self, ax_2d: Axes, **kwargs) -> None:
"""
Plot the circle in 2D.
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/objects/test_circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from skspatial.objects import Circle
from skspatial.objects import Line
from skspatial.objects import Points


@pytest.mark.parametrize(
Expand Down Expand Up @@ -116,6 +117,37 @@ def test_project_point_failure(circle, point):
circle.project_point(point)


@pytest.mark.parametrize(
("points", "circle_expected"),
[
([[1, 1], [2, 2], [3, 1]], Circle(point=[2, 1], radius=1)),
([[2, 0], [-2, 0], [0, 2]], Circle(point=[0, 0], radius=2)),
([[1, 0], [0, 1], [1, 2]], Circle(point=[1, 1], radius=1)),
],
)
def test_best_fit(points, circle_expected):

points = Points(points)
circle_fit = Circle.best_fit(points)

assert circle_fit.point.is_close(circle_expected.point, abs_tol=1e-9)
assert math.isclose(circle_fit.radius, circle_expected.radius)


@pytest.mark.parametrize(
("points", "message_expected"),
[
([[1, 0, 0], [-1, 0, 0], [0, 1, 0]], "The points must be 2D."),
([[2, 0], [-2, 0]], "There must be at least 3 points."),
([[0, 0], [1, 1], [2, 2]], "The points must not be collinear."),
],
)
def test_best_fit_failure(points, message_expected):

with pytest.raises(ValueError, match=message_expected):
Circle.best_fit(points)


@pytest.mark.parametrize(
("circle", "line", "point_a_expected", "point_b_expected"),
[
Expand Down

0 comments on commit 25be3c8

Please sign in to comment.