Skip to content

Commit 57db9d8

Browse files
committed
Add fit-difference metrics.
1 parent 507a119 commit 57db9d8

5 files changed

Lines changed: 213 additions & 13 deletions

File tree

‎src/fastimgproto/sourcefind/fit.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ def covariance(self):
9393
9494
Returns:
9595
numpy.ndarray: 2x2 matrix representing covariance matrix in the
96-
reference x-y frame.
96+
reference x-y frame. Covariance matrix elements are ordered
97+
like::
98+
99+
[[sigma_x**2, rho*sigma_x*sigma_y],
100+
[rho*sigma_x*sigma_y, sigma_y**2]]
97101
"""
98102
rotated_cov = np.array([[self.semimajor ** 2, 0],
99103
[0, self.semiminor ** 2]],
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import math
2+
3+
import numpy as np
4+
from scipy.spatial.distance import mahalanobis as scipy_mahalanobis
5+
6+
from fastimgproto.sourcefind.fit import Gaussian2dParams
7+
8+
9+
def mahalanobis_distance(gaussian2d_params, x, y):
10+
"""
11+
Compute the Mahalanobis distance from Gaussian-centre to point (x,y).
12+
13+
Mahalanobis distance is the Euclidean distance normalized by the
14+
standard-deviation of the Gaussian:
15+
https://en.wikipedia.org/wiki/Mahalanobis_distance
16+
17+
Args:
18+
gaussian2d_params (Gaussian2dParams): Gaussian to compute distance for.
19+
x (float): X-coordinate of position
20+
y (float): Y-coordinate of position
21+
22+
Returns:
23+
float: Mahalanobis distance
24+
"""
25+
inv_covariance = np.linalg.inv(gaussian2d_params.covariance)
26+
g_centre = np.array(
27+
[gaussian2d_params.x_centre, gaussian2d_params.y_centre],
28+
dtype=np.float_
29+
)
30+
posn = np.array([x, y], dtype=np.float_)
31+
return scipy_mahalanobis(u=g_centre, v=posn, VI=inv_covariance)
32+
33+
34+
def pixel_distance(gaussian2d_params, x, y):
35+
"""
36+
Compute the distance from Gaussian-centre to point (x,y).
37+
38+
(Regular distance measured in units of pixels)
39+
40+
Args:
41+
gaussian2d_params (Gaussian2dParams): Gaussian to compute distance for.
42+
x (float): X-coordinate of position
43+
y (float): Y-coordinate of position
44+
45+
Returns:
46+
float: Euclidean distance (units of pixels)
47+
"""
48+
xdiff = gaussian2d_params.x_centre - x
49+
ydiff = gaussian2d_params.y_centre - y
50+
return np.sqrt(xdiff * xdiff + ydiff * ydiff)
51+
52+
53+
def shape_difference(g1, g2):
54+
"""
55+
A symmetric shape-difference metric for Gaussians
56+
57+
Computes the Covariance-difference term of the Bhattacharyya_distance, cf
58+
https://en.wikipedia.org/wiki/Bhattacharyya_distance
59+
60+
61+
Args:
62+
g1 (Gaussian2dParams):
63+
g2 (Gaussian2dParams):
64+
65+
Returns:
66+
float: Dimensionless semipositive value. 0 if g1 and g2 have same shape.
67+
"""
68+
cov1 = g1.covariance
69+
cov2 = g2.covariance
70+
halfway_cov = (cov1 + cov2) / 2.0
71+
ratio = np.linalg.det(halfway_cov) / np.sqrt(
72+
np.linalg.det(cov1) * np.linalg.det(cov2)
73+
)
74+
return 0.5 * math.log(ratio)

‎tests/test_sourcefind/test_detection.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
gaussian_point_source,
1212
uncorrelated_gaussian_noise_background,
1313
)
14-
from fastimgproto.fixtures.sourcefind import \
15-
check_single_source_extraction_successful
14+
from fastimgproto.fixtures.sourcefind import (
15+
check_single_source_extraction_successful,
16+
)
1617
from fastimgproto.sourcefind.fit import Gaussian2dParams
1718
from fastimgproto.sourcefind.image import (
1819
Island,

‎tests/test_sourcefind/test_gaussian2dfit.py‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,38 @@
1010
from fastimgproto.sourcefind.fit import Gaussian2dParams
1111

1212

13+
def test_covariance():
14+
"""
15+
Verify / document the ordering of the the covariance matrix components
16+
"""
17+
# A Gaussian elongated in the x-direction
18+
smaj = 2
19+
smin = 1.
20+
theta = 0
21+
x_long = Gaussian2dParams(x_centre=0,
22+
y_centre=0,
23+
amplitude=1,
24+
semimajor=smaj,
25+
semiminor=smin,
26+
theta=theta,
27+
)
28+
29+
print(x_long.covariance)
30+
expected_covariance = np.array(
31+
[[smaj ** 2, 0],
32+
[0, smin ** 2]],
33+
dtype=np.float_)
34+
assert_allclose(x_long.covariance, expected_covariance, atol=1e-14)
35+
36+
# A Gaussian elongated in the y-direction (Note theta)
37+
y_long = attr.evolve(x_long, theta=np.pi / 2)
38+
expected_covariance = np.array(
39+
[[smin ** 2, 0],
40+
[0, smaj ** 2]],
41+
dtype=np.float_)
42+
assert_allclose(y_long.covariance, expected_covariance, atol=1e-14)
43+
44+
1345
def test_correlation_coefficient():
1446
smaj = 1.5
1547
smin = 1.
@@ -93,18 +125,18 @@ def test_validation_after_evolve_call():
93125

94126
def test_unconstrained_initialization_theta_oob():
95127
pars = dict(x_centre=48.24, y_centre=52.66,
96-
amplitude=42.,
97-
semimajor=1.5,
98-
semiminor=1.4,
99-
theta=np.pi / 4,
100-
)
128+
amplitude=42.,
129+
semimajor=1.5,
130+
semiminor=1.4,
131+
theta=np.pi / 4,
132+
)
101133
g1 = Gaussian2dParams(**pars)
102134

103135
pars.update(theta=g1.theta + np.pi)
104136
g2 = Gaussian2dParams.from_unconstrained_parameters(**pars)
105137
assert g1 == g2
106138

107-
pars.update(theta=g1.theta + 2*np.pi)
139+
pars.update(theta=g1.theta + 2 * np.pi)
108140
g3 = Gaussian2dParams.from_unconstrained_parameters(**pars)
109141
assert g1 == g3
110142

@@ -114,19 +146,18 @@ def test_unconstrained_initialization_theta_oob():
114146

115147
# Check initialization at bounds:
116148
# Include upper bound
117-
pars.update(theta=np.pi/2.)
149+
pars.update(theta=np.pi / 2.)
118150
g5 = Gaussian2dParams(**pars)
119151

120152
# Shouldn't work - exclude lower bound
121-
pars.update(theta=-np.pi/2.)
153+
pars.update(theta=-np.pi / 2.)
122154
with pytest.raises(ValueError):
123155
g6 = Gaussian2dParams(**pars)
124156
# Should flip lower bound to upper bound:
125157
g6 = Gaussian2dParams.from_unconstrained_parameters(**pars)
126158
assert g6.theta == np.pi / 2.
127159

128160

129-
130161
def test_unconstrained_initialization_flipped_major_minor():
131162
smaj = 1.5
132163
smin = 1.3
@@ -142,6 +173,6 @@ def test_unconstrained_initialization_flipped_major_minor():
142173
g1.x_centre, g1.y_centre, g1.amplitude,
143174
semimajor=g1.semiminor,
144175
semiminor=g1.semimajor,
145-
theta=g1.theta + np.pi/2.
176+
theta=g1.theta + np.pi / 2.
146177
)
147178
assert g1 == g2
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import math
2+
3+
import attr
4+
import numpy as np
5+
from pytest import approx
6+
7+
from fastimgproto.sourcefind.fit import Gaussian2dParams
8+
from fastimgproto.sourcefind.metrics import (
9+
mahalanobis_distance,
10+
pixel_distance,
11+
shape_difference,
12+
)
13+
14+
15+
def test_distance_metrics():
16+
x_0, y_0 = 1., 4.
17+
g2d_x_long = Gaussian2dParams(x_centre=x_0,
18+
y_centre=y_0,
19+
amplitude=1,
20+
semimajor=2.,
21+
semiminor=1.,
22+
theta=0.,
23+
)
24+
25+
# A Gaussian elongated in the y-direction (Note theta)
26+
g2d_y_long = attr.evolve(g2d_x_long, theta=np.pi / 2)
27+
28+
x, y = x_0 + 2, y_0
29+
assert mahalanobis_distance(g2d_x_long, x=x, y=y) == approx(1.0)
30+
assert mahalanobis_distance(g2d_y_long, x=x, y=y) == approx(2.0)
31+
assert pixel_distance(g2d_x_long, x, y) == pixel_distance(g2d_y_long, x, y)
32+
assert pixel_distance(g2d_x_long, x, y) == 2.
33+
34+
x, y = x_0, y_0 + 1
35+
assert mahalanobis_distance(g2d_x_long, x=x, y=y) == approx(1.0)
36+
assert mahalanobis_distance(g2d_y_long, x=x, y=y) == approx(0.5)
37+
assert pixel_distance(g2d_x_long, x, y) == pixel_distance(g2d_y_long, x, y)
38+
assert pixel_distance(g2d_x_long, x, y) == 1.
39+
40+
g2d_diag_long = attr.evolve(g2d_x_long, theta=np.pi / 4.)
41+
x, y = x_0 + np.cos(np.pi / 4), y_0 + np.sin(np.pi / 4)
42+
assert mahalanobis_distance(g2d_diag_long, x=x, y=y) == approx(0.5)
43+
assert pixel_distance(g2d_diag_long, x, y) == approx(1.)
44+
45+
46+
def test_shape_metric():
47+
g_symmetric_1 = Gaussian2dParams(x_centre=0.,
48+
y_centre=0.,
49+
amplitude=1,
50+
semimajor=1.,
51+
semiminor=1.,
52+
theta=0.,
53+
)
54+
55+
assert shape_difference(g_symmetric_1, g_symmetric_1) == 0
56+
57+
translated_sym = attr.evolve(g_symmetric_1, x_centre=123,
58+
y_centre=456, )
59+
assert shape_difference(g_symmetric_1, translated_sym) == 0
60+
61+
# These are all the same 'shape distance' from the symmetric circular
62+
# Gaussian:
63+
g_x_2 = attr.evolve(g_symmetric_1, semimajor=2., semiminor=1.)
64+
g_y_2 = attr.evolve(g_x_2, theta=np.pi / 2.)
65+
g_diag_2 = attr.evolve(g_x_2, theta=np.pi / 4.)
66+
g_x_half = attr.evolve(g_symmetric_1, semiminor=0.5)
67+
68+
symmetric_to_elongated_by_2 = shape_difference(g_symmetric_1, g_x_2)
69+
for g_pars in (g_x_2, g_y_2, g_diag_2, g_x_half):
70+
assert shape_difference(g_symmetric_1,
71+
g_pars) == symmetric_to_elongated_by_2
72+
73+
# Twice the elongation factor -> Twice the distance metric
74+
assert shape_difference(g_x_half, g_x_2) == 2 * symmetric_to_elongated_by_2
75+
76+
# Elongated in both directions -> Twice the distance metric
77+
g_symmetric_2 = attr.evolve(g_symmetric_1, semimajor=2., semiminor=2.)
78+
assert (shape_difference(g_symmetric_1, g_symmetric_2) ==
79+
approx(2 * symmetric_to_elongated_by_2))
80+
81+
# Elongated and rotated by 90-degrees:
82+
# (Equivalent to shrink in x, stretch in y)
83+
# -> Twice the distance metric
84+
assert (shape_difference(g_x_2, g_y_2) ==
85+
approx(2 * symmetric_to_elongated_by_2))
86+
87+
# Rotation by 45-degrees:
88+
assert (symmetric_to_elongated_by_2 <
89+
shape_difference(g_diag_2, g_x_2) <
90+
2 * symmetric_to_elongated_by_2)

0 commit comments

Comments
 (0)