6
6
import numpy as np
7
7
from attr import attrib , attrs
8
8
from scipy import ndimage
9
- from scipy .optimize import least_squares
9
+ from scipy .optimize import OptimizeResult , least_squares
10
10
11
11
from fastimgproto .sourcefind .fit import (
12
12
Gaussian2dParams ,
@@ -102,11 +102,24 @@ class IslandParams(object):
102
102
extremum = attrib (validator = attr .validators .instance_of (Pixel ))
103
103
104
104
# Optional
105
+ moments_fit = attrib (
106
+ default = None ,
107
+ validator = attr .validators .optional (
108
+ attr .validators .instance_of ((Gaussian2dParams , bool ))))
109
+
105
110
leastsq_fit = attrib (
106
111
default = None ,
107
112
validator = attr .validators .optional (
108
113
attr .validators .instance_of ((Gaussian2dParams , bool ))))
109
114
115
+ # Useful for debugging - store the full report on the least-squares fit.
116
+ # Don't show it in the standard repr, though - too verbose!
117
+ optimize_result = attrib (
118
+ default = None , repr = False , cmp = False ,
119
+ validator = attr .validators .optional (
120
+ attr .validators .instance_of (OptimizeResult )
121
+ ))
122
+
110
123
111
124
@attrs
112
125
class Island (object ):
@@ -296,23 +309,75 @@ def _label_detection_islands(self, sign):
296
309
def calculate_moments (self , island ):
297
310
"""
298
311
Analyses an island to extract further parameters.
312
+
313
+ See Hanno Spreeuw's thesis for formulae (eqn 2.50 -- 2.54).
314
+ (Will add a derivation notebook to repo if time allows).
299
315
"""
300
316
sign = island .sign
301
- sum = sign * np .ma .sum (island .data )
302
- island .xbar = np .ma .sum (self .xgrid * sign * island .data ) / sum
303
- island .ybar = np .ma .sum (self .ygrid * sign * island .data ) / sum
317
+ # If working with a negative source, be sure to take a positive copy
318
+ # (modulus) of the island data to get the moment calculations correct.
319
+ abs_data = sign * island .data
320
+ sum = abs_data .sum ()
321
+ y = self .ygrid
322
+ x = self .xgrid
323
+ x_bar = (x * abs_data ).sum () / sum
324
+ y_bar = (y * abs_data ).sum () / sum
325
+ xx_bar = (x * x * abs_data ).sum () / sum - x_bar * x_bar
326
+ yy_bar = (y * y * abs_data ).sum () / sum - y_bar * y_bar
327
+ xy_bar = (x * y * abs_data ).sum () / sum - x_bar * y_bar
328
+
329
+ working1 = (xx_bar + yy_bar ) / 2.0
330
+ working2 = math .sqrt (((xx_bar - yy_bar ) / 2 ) ** 2 + xy_bar ** 2 )
331
+ trunc_semimajor_sq = working1 + working2
332
+ trunc_semiminor_sq = working1 - working2
333
+
334
+ # Semimajor / minor axes are under-estimated due to threholding
335
+ # Hanno calculated the following correction factor (eqns 2.60,2.61):
336
+
337
+ pixel_threshold = self .analysis_n_sigma * self .rms_est
338
+ # `cutoff_ratio` == 'C/T' in Hanno's formulae.
339
+ # Always >1.0, else the source would not be detected.
340
+ cutoff_ratio = sign * island .extremum .value / pixel_threshold
341
+ axes_scale_factor = 1.0 - math .log (cutoff_ratio ) / (cutoff_ratio - 1. )
342
+ semimajor_est = math .sqrt (trunc_semimajor_sq / axes_scale_factor )
343
+ semiminor_est = math .sqrt (trunc_semiminor_sq / axes_scale_factor )
344
+
345
+ # For theta, we differ from Hanno's algorithm - I think Hanno maybe made
346
+ # an error,or possibly this is due to different parameter
347
+ # bound-choices, not sure...
348
+ theta_est = 0.5 * math .atan (2. * xy_bar / (xx_bar - yy_bar ))
349
+
350
+ # Atan(theta) solutions are periodic - can add or subtract pi.
351
+ # math.atan(theta) returns an angle in the range (-pi/2,pi/2) (matching the
352
+ # sign of theta).
353
+ # No problem since we're robust to rotations of pi / 180 degrees.
354
+ # But atan(2theta) solutions are periodic in pi/2. This is an issue,
355
+ # since we could have the wrong solution. To do so, we can just check
356
+ # if we're in the correct quadrant - if it's the wrong solution,
357
+ # it will be flipped by pi/2, then constrained to the (-pi/2,pi/2)
358
+ # by an additional rotation of pi. So if needed we add *another* pi/2,
359
+ # and let the Gaussian2dParams constructor take care of correcting bounds.
360
+ # We expect the sign of theta to match the sign of the covariance:
361
+ if theta_est * xy_bar < 0. :
362
+ theta_est += math .pi / 2.0
363
+
364
+ moments_fits = Gaussian2dParams .from_unconstrained_parameters (
365
+ x_centre = x_bar ,
366
+ y_centre = y_bar ,
367
+ amplitude = island .extremum .value ,
368
+ semimajor = semimajor_est ,
369
+ semiminor = semiminor_est ,
370
+ theta = theta_est
371
+ )
372
+ island .params .moments_fit = moments_fits
373
+ return moments_fits
304
374
305
375
def fit_gaussian_2d (self , island , verbose = 0 ):
306
376
# x, y, x_centre, y_centre, amplitude, x_stddev, y_stddev, theta
307
377
y_indices , x_indices = island .unmasked_pixel_indices
308
378
fitting_data = island .data [y_indices , x_indices ]
309
379
310
- def island_residuals (x_centre ,
311
- y_centre ,
312
- amplitude ,
313
- semimajor ,
314
- semiminor ,
315
- theta ):
380
+ def island_residuals (pars ):
316
381
"""
317
382
A wrapped version of `gaussian2d` applied to this island's unmasked
318
383
pixels, then subtracting the island values
@@ -325,6 +390,13 @@ def island_residuals(x_centre,
325
390
326
391
"""
327
392
393
+ (x_centre ,
394
+ y_centre ,
395
+ amplitude ,
396
+ semimajor ,
397
+ semiminor ,
398
+ theta ) = pars
399
+
328
400
model_vals = gaussian2d (x_indices , y_indices ,
329
401
x_centre = x_centre ,
330
402
y_centre = y_centre ,
@@ -336,57 +408,37 @@ def island_residuals(x_centre,
336
408
assert model_vals .shape == fitting_data .shape
337
409
return fitting_data - model_vals
338
410
339
- def located_jacobian (pars ):
340
- """
341
- Wrapped version of `gaussian2d_jac` applied at these pixel positions.
342
- """
343
- (x_centre ,
344
- y_centre ,
345
- amplitude ,
346
- semimajor ,
347
- semiminor ,
348
- theta ) = pars
349
- return gaussian2d_jac (x_indices , y_indices ,
350
- x_centre = x_centre ,
351
- y_centre = y_centre ,
352
- amplitude = amplitude ,
353
- x_stddev = semimajor ,
354
- y_stddev = semiminor ,
355
- theta = theta ,
356
- )
357
-
358
- def wrapped_island_residuals (pars ):
359
- """
360
- Wrapped version of `island_residuals` that takes a single argument
361
-
362
- (a tuple of the varying parameters).
363
-
364
- Args:
365
- pars (tuple):
366
- (x_centre, y_centre, amplitude, x_stddev, y_stddev, theta)
367
-
368
- Returns:
369
- numpy.ndarray: vector of residuals
370
-
371
- """
372
- assert len (pars ) == 6
373
- return island_residuals (* pars )
374
-
375
- initial_params = Gaussian2dParams (x_centre = island .xbar ,
376
- y_centre = island .ybar ,
377
- amplitude = island .extremum .value ,
378
- semimajor = 1. ,
379
- semiminor = 1. ,
380
- theta = 0
381
- )
411
+ # def located_jacobian(pars):
412
+ # """
413
+ # Wrapped version of `gaussian2d_jac` applied at these pixel positions.
414
+ # """
415
+ # (x_centre,
416
+ # y_centre,
417
+ # amplitude,
418
+ # semimajor,
419
+ # semiminor,
420
+ # theta) = pars
421
+ # return gaussian2d_jac(x_indices, y_indices,
422
+ # x_centre=x_centre,
423
+ # y_centre=y_centre,
424
+ # amplitude=amplitude,
425
+ # x_stddev=semimajor,
426
+ # y_stddev=semiminor,
427
+ # theta=theta,
428
+ # )
429
+
430
+
431
+ initial_params = island .params .moments_fit
382
432
383
433
# Using the jacobian mostly gives bad fits?
384
- lsq_result = least_squares (fun = wrapped_island_residuals ,
434
+ lsq_result = least_squares (fun = island_residuals ,
385
435
# jac=located_jacobian,
386
436
x0 = attr .astuple (initial_params ),
387
- # method='dogbox',
437
+ method = 'dogbox' ,
388
438
verbose = verbose ,
439
+ # max_nfev=50,
389
440
)
441
+ island .params .optimize_result = lsq_result
390
442
island .params .leastsq_fit = Gaussian2dParams .from_unconstrained_parameters (
391
443
* tuple (lsq_result .x ))
392
444
return island .params .leastsq_fit
0 commit comments