Coverage for python/lsst/meas/deblender/plugins.py : 3%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of meas_deblender.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import numpy as np
24import scarlet
26import lsst.pex.exceptions
27import lsst.afw.image as afwImage
28import lsst.afw.detection as afwDet
29import lsst.afw.geom as afwGeom
30import lsst.geom as geom
32# Import C++ routines
33from .baselineUtils import BaselineUtilsF as bUtils
36def clipFootprintToNonzeroImpl(foot, image):
37 '''
38 Clips the given *Footprint* to the region in the *Image*
39 containing non-zero values. The clipping drops spans that are
40 totally zero, and moves endpoints to non-zero; it does not
41 split spans that have internal zeros.
42 '''
43 x0 = image.getX0()
44 y0 = image.getY0()
45 xImMax = x0 + image.getDimensions().getX()
46 yImMax = y0 + image.getDimensions().getY()
47 newSpans = []
48 arr = image.getArray()
49 for span in foot.spans:
50 y = span.getY()
51 if y < y0 or y > yImMax:
52 continue
53 spanX0 = span.getX0()
54 spanX1 = span.getX1()
55 xMin = spanX0 if spanX0 >= x0 else x0
56 xMax = spanX1 if spanX1 <= xImMax else xImMax
57 xarray = np.arange(xMin, xMax+1)[arr[y-y0, xMin-x0:xMax-x0+1] != 0]
58 if len(xarray) > 0:
59 newSpans.append(afwGeom.Span(y, xarray[0], xarray[-1]))
60 # Time to update the SpanSet
61 foot.setSpans(afwGeom.SpanSet(newSpans, normalize=False))
62 foot.removeOrphanPeaks()
65class DeblenderPlugin:
66 """Class to define plugins for the deblender.
68 The new deblender executes a series of plugins specified by the user.
69 Each plugin defines the function to be executed, the keyword arguments required by the function,
70 and whether or not certain portions of the deblender might need to be rerun as a result of
71 the function.
72 """
73 def __init__(self, func, onReset=None, maxIterations=50, **kwargs):
74 """Initialize a deblender plugin
76 Parameters
77 ----------
78 func: `function`
79 Function to run when the plugin is executed. The function should always take
80 `debResult`, a `DeblenderResult` that stores the deblender result, and
81 `log`, an `lsst.log`, as the first two arguments, as well as any additional
82 keyword arguments (that must be specified in ``kwargs``).
83 The function should also return ``modified``, a `bool` that tells the deblender whether
84 or not any templates have been modified by the function.
85 If ``modified==True``, the deblender will go back to step ``onReset``,
86 unless the has already been run ``maxIterations``.
87 onReset: `int`
88 Index of the deblender plugin to return to if ``func`` modifies any templates.
89 The default is ``None``, which does not re-run any plugins.
90 maxIterations: `int`
91 Maximum number of times the deblender will reset when the current plugin
92 returns ``True``.
93 """
94 self.func = func
95 self.kwargs = kwargs
96 self.onReset = onReset
97 self.maxIterations = maxIterations
98 self.kwargs = kwargs
99 self.iterations = 0
101 def run(self, debResult, log):
102 """Execute the current plugin
104 Once the plugin has finished, check to see if part of the deblender must be executed again.
105 """
106 log.trace("Executing %s", self.func.__name__)
107 reset = self.func(debResult, log, **self.kwargs)
108 if reset:
109 self.iterations += 1
110 if self.iterations < self.maxIterations:
111 return self.onReset
112 return None
114 def __str__(self):
115 return ("<Deblender Plugin: func={0}, kwargs={1}".format(self.func.__name__, self.kwargs))
117 def __repr__(self):
118 return self.__str__()
121def _setPeakError(debResult, log, pk, cx, cy, filters, msg, flag):
122 """Update the peak in each band with an error
124 This function logs an error that occurs during deblending and sets the
125 relevant flag.
127 Parameters
128 ----------
129 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
130 Container for the final deblender results.
131 log: `log.Log`
132 LSST logger for logging purposes.
133 pk: int
134 Number of the peak that failed
135 cx: float
136 x coordinate of the peak
137 cy: float
138 y coordinate of the peak
139 filters: list of str
140 List of filter names for the exposures
141 msg: str
142 Message to display in log traceback
143 flag: str
144 Name of the flag to set
146 Returns
147 -------
148 None
149 """
150 log.trace("Peak {0} at ({1},{2}):{3}".format(pk, cx, cy, msg))
151 for fidx, f in enumerate(filters):
152 pkResult = debResult.deblendedParents[f].peaks[pk]
153 getattr(pkResult, flag)()
156def buildMultibandTemplates(debResult, log, useWeights=False, usePsf=False,
157 sources=None, constraints=None, config=None, maxIter=100, bgScale=0.5,
158 relativeError=1e-2, badMask=None):
159 """Run the Multiband Deblender to build templates
161 Parameters
162 ----------
163 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
164 Container for the final deblender results.
165 log: `log.Log`
166 LSST logger for logging purposes.
167 useWeights: bool, default=False
168 Whether or not to use the variance map in each filter for the fit.
169 usePsf: bool, default=False
170 Whether or not to convolve the image with the PSF in each band.
171 This is not yet implemented in an optimized algorithm, so it is recommended
172 to leave this term off for now
173 sources: list of `scarlet.source.Source` objects, default=None
174 List of sources to use in the blend. By default the
175 `scarlet.source.ExtendedSource` class is used, which initializes each
176 source as symmetric and monotonic about a peak in the footprint peak catalog.
177 constraints: `scarlet.constraint.Constraint`, default=None
178 Constraint to be applied to each source. If sources require different constraints,
179 a list of `sources` must be created instead, which ignores the `constraints` parameter.
180 When `constraints` is `None` the default constraints are used.
181 config: `scarlet.config.Config`, default=None
182 Configuration for the blend.
183 If `config` is `None` then the default `Config` is used.
184 maxIter: int, default=100
185 Maximum iterations for a single blend.
186 bgScale: float
187 Amount to scale the background RMS to set the floor for deblender model sizes
188 relativeError: float, default=1e-2
189 Relative error to reach for convergence
190 badMask: list of str, default=`None`
191 List of mask plane names to mark bad pixels.
192 If `badPixelKeys` is `None`, the default keywords used are
193 `["BAD", "CR", "NO_DATA", "SAT", "SUSPECT"]`.
195 Returns
196 -------
197 modified: `bool`
198 If any templates have been created then ``modified`` is ``True``,
199 otherwise it is ``False`` (meaning all of the peaks were skipped).
200 """
201 # Extract coordinates from each MultiColorPeak
202 bbox = debResult.footprint.getBBox()
203 peakSchema = debResult.footprint.peaks.getSchema()
204 xmin = bbox.getMinX()
205 ymin = bbox.getMinY()
206 peaks = [[pk.y-ymin, pk.x-xmin] for pk in debResult.peaks]
207 xy0 = bbox.getMin()
209 # Create the data array from the masked images
210 mMaskedImage = debResult.mMaskedImage[:, debResult.footprint.getBBox()]
211 data = mMaskedImage.image.array
213 # Use the inverse variance as the weights
214 if useWeights:
215 weights = 1/mMaskedImage.variance.array
216 else:
217 weights = np.ones_like(data)
219 # Use the mask plane to mask bad pixels and
220 # the footprint to mask out pixels outside the footprint
221 if badMask is None:
222 badMask = ["BAD", "CR", "NO_DATA", "SAT", "SUSPECT"]
223 fpMask = afwImage.Mask(bbox)
224 debResult.footprint.spans.setMask(fpMask, 1)
225 fpMask = ~fpMask.getArray().astype(bool)
226 badPixels = mMaskedImage.mask.getPlaneBitMask(badMask)
227 mask = (mMaskedImage.mask.array & badPixels) | fpMask[None, :]
228 weights[mask > 0] = 0
230 # Extract the PSF from each band for PSF convolution
231 if usePsf:
232 psfs = []
233 for psf in debResult.psfs:
234 psfs.append(psf.computeKernelImage().array)
235 psf = np.array(psfs)
236 else:
237 psf = None
239 bg_rms = np.array([debResult.deblendedParents[f].avgNoise for f in debResult.filters])*bgScale
240 if sources is None:
241 # If only a single constraint was given, use it for all of the sources
242 if constraints is None or isinstance(constraints[0], scarlet.constraints.Constraint):
243 constraints = [constraints] * len(peaks)
244 sources = [
245 scarlet.source.ExtendedSource(center=peak,
246 img=data,
247 bg_rms=bg_rms,
248 constraints=constraints[pk],
249 psf=psf,
250 symmetric=True,
251 monotonic=True,
252 thresh=1.0,
253 config=config)
254 for pk, peak in enumerate(peaks)
255 ]
257 # When a footprint includes only non-detections
258 # (peaks in the noise too low to deblend as a source)
259 # the deblender currently fails.
260 try:
261 blend = scarlet.blend.Blend(components=sources)
262 blend.set_data(img=data, weights=weights, bg_rms=bg_rms, config=config)
263 blend.fit(maxIter, e_rel=relativeError)
264 except scarlet.source.SourceInitError as e:
265 log.warn(e.args[0])
266 debResult.failed = True
267 return False
268 except np.linalg.LinAlgError:
269 log.warn("Deblend failed catastrophically, most likely due to no signal in the footprint")
270 debResult.failed = True
271 return False
272 debResult.blend = blend
274 modified = False
275 # Create the Templates for each peak in each filter
276 for pk, source in enumerate(blend.sources):
277 src = source.components[0]
278 _cx = src.Nx >> 1
279 _cy = src.Ny >> 1
281 if debResult.peaks[pk].skip:
282 continue
283 modified = True
284 cx = src.center[1]+xmin
285 cy = src.center[0]+ymin
286 icx = int(np.round(cx))
287 icy = int(np.round(cy))
288 imbb = debResult.deblendedParents[debResult.filters[0]].img.getBBox()
290 # Footprint must be inside the image
291 if not imbb.contains(geom.Point2I(cx, cy)):
292 _setPeakError(debResult, log, pk, cx, cy, debResult.filters,
293 "peak center is not inside image", "setOutOfBounds")
294 continue
295 # Only save templates that have nonzero flux
296 if np.sum(src.morph) == 0:
297 _setPeakError(debResult, log, pk, cx, cy, debResult.filters,
298 "had no flux", "setFailedSymmetricTemplate")
299 continue
301 # Temporary for initial testing: combine multiple components
302 model = blend.get_model(k=pk).astype(np.float32)
304 # The peak in each band will have the same SpanSet
305 mask = afwImage.Mask(np.array(np.sum(model, axis=0) > 0, dtype=np.int32), xy0=xy0)
306 ss = afwGeom.SpanSet.fromMask(mask)
308 if len(ss) == 0:
309 log.warn("No flux in parent footprint")
310 debResult.failed = True
311 return False
313 # Add the template footprint and image to the deblender result for each peak
314 for fidx, f in enumerate(debResult.filters):
315 pkResult = debResult.deblendedParents[f].peaks[pk]
316 tfoot = afwDet.Footprint(ss, peakSchema=peakSchema)
317 # Add the peak with the intensity of the centered model,
318 # which might be slightly larger than the shifted model
319 peakFlux = np.sum(src.sed[fidx]*src.morph[_cy, _cx])
320 tfoot.addPeak(cx, cy, peakFlux)
321 timg = afwImage.ImageF(model[fidx], xy0=xy0)
322 timg = timg[tfoot.getBBox()]
323 pkResult.setOrigTemplate(timg, tfoot)
324 pkResult.setTemplate(timg, tfoot)
325 pkResult.setFluxPortion(afwImage.MaskedImageF(timg))
326 pkResult.multiColorPeak.x = cx
327 pkResult.multiColorPeak.y = cy
328 pkResult.peak.setFx(cx)
329 pkResult.peak.setFy(cy)
330 pkResult.peak.setIx(icx)
331 pkResult.peak.setIy(icy)
332 return modified
335def fitPsfs(debResult, log, psfChisqCut1=1.5, psfChisqCut2=1.5, psfChisqCut2b=1.5, tinyFootprintSize=2):
336 """Fit a PSF + smooth background model (linear) to a small region around each peak
338 This function will iterate over all filters in deblender result but does not compare
339 results across filters.
340 DeblendedPeaks that pass the cuts have their templates modified to the PSF + background model
341 and their ``deblendedAsPsf`` property set to ``True``.
343 This will likely be replaced in the future with a function that compares the psf chi-squared cuts
344 so that peaks flagged as point sources will be considered point sources in all bands.
346 Parameters
347 ----------
348 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
349 Container for the final deblender results.
350 log: `log.Log`
351 LSST logger for logging purposes.
352 psfChisqCut*: `float`, optional
353 ``psfChisqCut1`` is the maximum chi-squared-per-degree-of-freedom allowed for a peak to
354 be considered a PSF match without recentering.
355 A fit is also made that includes terms to recenter the PSF.
356 ``psfChisqCut2`` is the same as ``psfChisqCut1`` except it determines the restriction on the
357 fit that includes recentering terms.
358 If the peak is a match for a re-centered PSF, the PSF is repositioned at the new center and
359 the peak footprint is fit again, this time to the new PSF.
360 If the resulting chi-squared-per-degree-of-freedom is less than ``psfChisqCut2b`` then it
361 passes the re-centering algorithm.
362 If the peak passes both the re-centered and fixed position cuts, the better of the two is accepted,
363 but parameters for all three psf fits are stored in the ``DebldendedPeak``.
364 The default for ``psfChisqCut1``, ``psfChisqCut2``, and ``psfChisqCut2b`` is ``1.5``.
365 tinyFootprintSize: `float`, optional
366 The PSF model is shrunk to the size that contains the original footprint.
367 If the bbox of the clipped PSF model for a peak is smaller than ``max(tinyFootprintSize,2)``
368 then ``tinyFootprint`` for the peak is set to ``True`` and the peak is not fit.
369 The default is 2.
371 Returns
372 -------
373 modified: `bool`
374 If any templates have been assigned to PSF point sources then ``modified`` is ``True``,
375 otherwise it is ``False``.
376 """
377 from .baseline import CachingPsf
378 modified = False
379 # Loop over all of the filters to build the PSF
380 for fidx in debResult.filters:
381 dp = debResult.deblendedParents[fidx]
382 peaks = dp.fp.getPeaks()
383 cpsf = CachingPsf(dp.psf)
385 # create mask image for pixels within the footprint
386 fmask = afwImage.Mask(dp.bb)
387 fmask.setXY0(dp.bb.getMinX(), dp.bb.getMinY())
388 dp.fp.spans.setMask(fmask, 1)
390 # pk.getF() -- retrieving the floating-point location of the peak
391 # -- actually shows up in the profile if we do it in the loop, so
392 # grab them all here.
393 peakF = [pk.getF() for pk in peaks]
395 for pki, (pk, pkres, pkF) in enumerate(zip(peaks, dp.peaks, peakF)):
396 log.trace('Filter %s, Peak %i', fidx, pki)
397 ispsf = _fitPsf(dp.fp, fmask, pk, pkF, pkres, dp.bb, peaks, peakF, log, cpsf, dp.psffwhm,
398 dp.img, dp.varimg, psfChisqCut1, psfChisqCut2, psfChisqCut2b, tinyFootprintSize)
399 modified = modified or ispsf
400 return modified
403def _fitPsf(fp, fmask, pk, pkF, pkres, fbb, peaks, peaksF, log, psf, psffwhm,
404 img, varimg, psfChisqCut1, psfChisqCut2, psfChisqCut2b,
405 tinyFootprintSize=2,
406 ):
407 """Fit a PSF + smooth background model (linear) to a small region around a peak.
409 See fitPsfs for a more thorough description, including all parameters not described below.
411 Parameters
412 ----------
413 fp: `afw.detection.Footprint`
414 Footprint containing the Peaks to model.
415 fmask: `afw.image.Mask`
416 The Mask plane for pixels in the Footprint
417 pk: `afw.detection.PeakRecord`
418 The peak within the Footprint that we are going to fit with PSF model
419 pkF: `afw.geom.Point2D`
420 Floating point coordinates of the peak.
421 pkres: `meas.deblender.DeblendedPeak`
422 Peak results object that will hold the results.
423 fbb: `afw.geom.Box2I`
424 Bounding box of ``fp``
425 peaks: `afw.detection.PeakCatalog`
426 Catalog of peaks contained in the parent footprint.
427 peaksF: list of `afw.geom.Point2D`
428 List of floating point coordinates of all of the peaks.
429 psf: list of `afw.detection.Psf`s
430 Psf of the ``maskedImage`` for each band.
431 psffwhm: list pf `float`s
432 FWHM of the ``maskedImage``'s ``psf`` in each band.
433 img: `afw.image.ImageF`
434 The image that contains the footprint.
435 varimg: `afw.image.ImageF`
436 The variance of the image that contains the footprint.
438 Results
439 -------
440 ispsf: `bool`
441 Whether or not the peak matches a PSF model.
442 """
443 import lsstDebug
445 # my __name__ is lsst.meas.deblender.baseline
446 debugPlots = lsstDebug.Info(__name__).plots
447 debugPsf = lsstDebug.Info(__name__).psf
449 # The small region is a disk out to R0, plus a ramp with
450 # decreasing weight down to R1.
451 R0 = int(np.ceil(psffwhm*1.))
452 # ramp down to zero weight at this radius...
453 R1 = int(np.ceil(psffwhm*1.5))
454 cx, cy = pkF.getX(), pkF.getY()
455 psfimg = psf.computeImage(cx, cy)
456 # R2: distance to neighbouring peak in order to put it into the model
457 R2 = R1 + min(psfimg.getWidth(), psfimg.getHeight())/2.
459 pbb = psfimg.getBBox()
460 pbb.clip(fbb)
461 px0, py0 = psfimg.getX0(), psfimg.getY0()
463 # Make sure we haven't been given a substitute PSF that's nowhere near where we want, as may occur if
464 # "Cannot compute CoaddPsf at point (xx,yy); no input images at that point."
465 if not pbb.contains(geom.Point2I(int(cx), int(cy))):
466 pkres.setOutOfBounds()
467 return
469 # The bounding-box of the local region we are going to fit ("stamp")
470 xlo = int(np.floor(cx - R1))
471 ylo = int(np.floor(cy - R1))
472 xhi = int(np.ceil(cx + R1))
473 yhi = int(np.ceil(cy + R1))
474 stampbb = geom.Box2I(geom.Point2I(xlo, ylo), geom.Point2I(xhi, yhi))
475 stampbb.clip(fbb)
476 xlo, xhi = stampbb.getMinX(), stampbb.getMaxX()
477 ylo, yhi = stampbb.getMinY(), stampbb.getMaxY()
478 if xlo > xhi or ylo > yhi:
479 log.trace('Skipping this peak: out of bounds')
480 pkres.setOutOfBounds()
481 return
483 # drop tiny footprints too?
484 if min(stampbb.getWidth(), stampbb.getHeight()) <= max(tinyFootprintSize, 2):
485 # Minimum size limit of 2 comes from the "PSF dx" calculation, which involves shifting the PSF
486 # by one pixel to the left and right.
487 log.trace('Skipping this peak: tiny footprint / close to edge')
488 pkres.setTinyFootprint()
489 return
491 # find other peaks within range...
492 otherpeaks = []
493 for pk2, pkF2 in zip(peaks, peaksF):
494 if pk2 == pk:
495 continue
496 if pkF.distanceSquared(pkF2) > R2**2:
497 continue
498 opsfimg = psf.computeImage(pkF2.getX(), pkF2.getY())
499 if not opsfimg.getBBox().overlaps(stampbb):
500 continue
501 otherpeaks.append(opsfimg)
502 log.trace('%i other peaks within range', len(otherpeaks))
504 # Now we are going to do a least-squares fit for the flux in this
505 # PSF, plus a decenter term, a linear sky, and fluxes of nearby
506 # sources (assumed point sources). Build up the matrix...
507 # Number of terms -- PSF flux, constant sky, X, Y, + other PSF fluxes
508 NT1 = 4 + len(otherpeaks)
509 # + PSF dx, dy
510 NT2 = NT1 + 2
511 # Number of pixels -- at most
512 NP = (1 + yhi - ylo)*(1 + xhi - xlo)
513 # indices of columns in the "A" matrix.
514 I_psf = 0
515 I_sky = 1
516 I_sky_ramp_x = 2
517 I_sky_ramp_y = 3
518 # offset of other psf fluxes:
519 I_opsf = 4
520 I_dx = NT1 + 0
521 I_dy = NT1 + 1
523 # Build the matrix "A", rhs "b" and weight "w".
524 ix0, iy0 = img.getX0(), img.getY0()
525 fx0, fy0 = fbb.getMinX(), fbb.getMinY()
526 fslice = (slice(ylo-fy0, yhi-fy0+1), slice(xlo-fx0, xhi-fx0+1))
527 islice = (slice(ylo-iy0, yhi-iy0+1), slice(xlo-ix0, xhi-ix0+1))
528 fmask_sub = fmask .getArray()[fslice]
529 var_sub = varimg.getArray()[islice]
530 img_sub = img.getArray()[islice]
532 # Clip the PSF image to match its bbox
533 psfarr = psfimg.getArray()[pbb.getMinY()-py0: 1+pbb.getMaxY()-py0,
534 pbb.getMinX()-px0: 1+pbb.getMaxX()-px0]
535 px0, px1 = pbb.getMinX(), pbb.getMaxX()
536 py0, py1 = pbb.getMinY(), pbb.getMaxY()
538 # Compute the "valid" pixels within our region-of-interest
539 valid = (fmask_sub > 0)
540 xx, yy = np.arange(xlo, xhi+1), np.arange(ylo, yhi+1)
541 RR = ((xx - cx)**2)[np.newaxis, :] + ((yy - cy)**2)[:, np.newaxis]
542 valid *= (RR <= R1**2)
543 valid *= (var_sub > 0)
544 NP = valid.sum()
546 if NP == 0:
547 log.warn('Skipping peak at (%.1f, %.1f): no unmasked pixels nearby', cx, cy)
548 pkres.setNoValidPixels()
549 return
551 # pixel coords of valid pixels
552 XX, YY = np.meshgrid(xx, yy)
553 ipixes = np.vstack((XX[valid] - xlo, YY[valid] - ylo)).T
555 inpsfx = (xx >= px0)*(xx <= px1)
556 inpsfy = (yy >= py0)*(yy <= py1)
557 inpsf = np.outer(inpsfy, inpsfx)
558 indx = np.outer(inpsfy, (xx > px0)*(xx < px1))
559 indy = np.outer((yy > py0)*(yy < py1), inpsfx)
561 del inpsfx
562 del inpsfy
564 def _overlap(xlo, xhi, xmin, xmax):
565 assert((xlo <= xmax) and (xhi >= xmin) and
566 (xlo <= xhi) and (xmin <= xmax))
567 xloclamp = max(xlo, xmin)
568 Xlo = xloclamp - xlo
569 xhiclamp = min(xhi, xmax)
570 Xhi = Xlo + (xhiclamp - xloclamp)
571 assert(xloclamp >= 0)
572 assert(Xlo >= 0)
573 return (xloclamp, xhiclamp+1, Xlo, Xhi+1)
575 A = np.zeros((NP, NT2))
576 # Constant term
577 A[:, I_sky] = 1.
578 # Sky slope terms: dx, dy
579 A[:, I_sky_ramp_x] = ipixes[:, 0] + (xlo-cx)
580 A[:, I_sky_ramp_y] = ipixes[:, 1] + (ylo-cy)
582 # whew, grab the valid overlapping PSF pixels
583 px0, px1 = pbb.getMinX(), pbb.getMaxX()
584 py0, py1 = pbb.getMinY(), pbb.getMaxY()
585 sx1, sx2, sx3, sx4 = _overlap(xlo, xhi, px0, px1)
586 sy1, sy2, sy3, sy4 = _overlap(ylo, yhi, py0, py1)
587 dpx0, dpy0 = px0 - xlo, py0 - ylo
588 psf_y_slice = slice(sy3 - dpy0, sy4 - dpy0)
589 psf_x_slice = slice(sx3 - dpx0, sx4 - dpx0)
590 psfsub = psfarr[psf_y_slice, psf_x_slice]
591 vsub = valid[sy1-ylo: sy2-ylo, sx1-xlo: sx2-xlo]
592 A[inpsf[valid], I_psf] = psfsub[vsub]
594 # PSF dx -- by taking the half-difference of shifted-by-one and
595 # shifted-by-minus-one.
596 oldsx = (sx1, sx2, sx3, sx4)
597 sx1, sx2, sx3, sx4 = _overlap(xlo, xhi, px0+1, px1-1)
598 psfsub = (psfarr[psf_y_slice, sx3 - dpx0 + 1: sx4 - dpx0 + 1] -
599 psfarr[psf_y_slice, sx3 - dpx0 - 1: sx4 - dpx0 - 1])/2.
600 vsub = valid[sy1-ylo: sy2-ylo, sx1-xlo: sx2-xlo]
601 A[indx[valid], I_dx] = psfsub[vsub]
602 # revert x indices...
603 (sx1, sx2, sx3, sx4) = oldsx
605 # PSF dy
606 sy1, sy2, sy3, sy4 = _overlap(ylo, yhi, py0+1, py1-1)
607 psfsub = (psfarr[sy3 - dpy0 + 1: sy4 - dpy0 + 1, psf_x_slice] -
608 psfarr[sy3 - dpy0 - 1: sy4 - dpy0 - 1, psf_x_slice])/2.
609 vsub = valid[sy1-ylo: sy2-ylo, sx1-xlo: sx2-xlo]
610 A[indy[valid], I_dy] = psfsub[vsub]
612 # other PSFs...
613 for j, opsf in enumerate(otherpeaks):
614 obb = opsf.getBBox()
615 ino = np.outer((yy >= obb.getMinY())*(yy <= obb.getMaxY()),
616 (xx >= obb.getMinX())*(xx <= obb.getMaxX()))
617 dpx0, dpy0 = obb.getMinX() - xlo, obb.getMinY() - ylo
618 sx1, sx2, sx3, sx4 = _overlap(xlo, xhi, obb.getMinX(), obb.getMaxX())
619 sy1, sy2, sy3, sy4 = _overlap(ylo, yhi, obb.getMinY(), obb.getMaxY())
620 opsfarr = opsf.getArray()
621 psfsub = opsfarr[sy3 - dpy0: sy4 - dpy0, sx3 - dpx0: sx4 - dpx0]
622 vsub = valid[sy1-ylo: sy2-ylo, sx1-xlo: sx2-xlo]
623 A[ino[valid], I_opsf + j] = psfsub[vsub]
625 b = img_sub[valid]
627 # Weights -- from ramp and image variance map.
628 # Ramp weights -- from 1 at R0 down to 0 at R1.
629 rw = np.ones_like(RR)
630 ii = (RR > R0**2)
631 rr = np.sqrt(RR[ii])
632 rw[ii] = np.maximum(0, 1. - ((rr - R0)/(R1 - R0)))
633 w = np.sqrt(rw[valid]/var_sub[valid])
634 # save the effective number of pixels
635 sumr = np.sum(rw[valid])
636 log.debug('sumr = %g', sumr)
638 del ii
640 Aw = A*w[:, np.newaxis]
641 bw = b*w
643 if debugPlots:
644 import pylab as plt
645 plt.clf()
646 N = NT2 + 2
647 R, C = 2, (N+1)/2
648 for i in range(NT2):
649 im1 = np.zeros((1+yhi-ylo, 1+xhi-xlo))
650 im1[ipixes[:, 1], ipixes[:, 0]] = A[:, i]
651 plt.subplot(R, C, i+1)
652 plt.imshow(im1, interpolation='nearest', origin='lower')
653 plt.subplot(R, C, NT2+1)
654 im1 = np.zeros((1+yhi-ylo, 1+xhi-xlo))
655 im1[ipixes[:, 1], ipixes[:, 0]] = b
656 plt.imshow(im1, interpolation='nearest', origin='lower')
657 plt.subplot(R, C, NT2+2)
658 im1 = np.zeros((1+yhi-ylo, 1+xhi-xlo))
659 im1[ipixes[:, 1], ipixes[:, 0]] = w
660 plt.imshow(im1, interpolation='nearest', origin='lower')
661 plt.savefig('A.png')
663 # We do fits with and without the decenter (dx,dy) terms.
664 # Since the dx,dy terms are at the end of the matrix,
665 # we can do that just by trimming off those elements.
666 #
667 # The SVD can fail if there are NaNs in the matrices; this should
668 # really be handled upstream
669 try:
670 # NT1 is number of terms without dx,dy;
671 # X1 is the result without decenter
672 X1, r1, rank1, s1 = np.linalg.lstsq(Aw[:, :NT1], bw, rcond=-1)
673 # X2 is with decenter
674 X2, r2, rank2, s2 = np.linalg.lstsq(Aw, bw, rcond=-1)
675 except np.linalg.LinAlgError as e:
676 log.warn("Failed to fit PSF to child: %s", e)
677 pkres.setPsfFitFailed()
678 return
680 log.debug('r1 r2 %s %s', r1, r2)
682 # r is weighted chi-squared = sum over pixels: ramp * (model -
683 # data)**2/sigma**2
684 if len(r1) > 0:
685 chisq1 = r1[0]
686 else:
687 chisq1 = 1e30
688 if len(r2) > 0:
689 chisq2 = r2[0]
690 else:
691 chisq2 = 1e30
692 dof1 = sumr - len(X1)
693 dof2 = sumr - len(X2)
694 log.debug('dof1, dof2 %g %g', dof1, dof2)
696 # This can happen if we're very close to the edge (?)
697 if dof1 <= 0 or dof2 <= 0:
698 log.trace('Skipping this peak: bad DOF %g, %g', dof1, dof2)
699 pkres.setBadPsfDof()
700 return
702 q1 = chisq1/dof1
703 q2 = chisq2/dof2
704 log.trace('PSF fits: chisq/dof = %g, %g', q1, q2)
705 ispsf1 = (q1 < psfChisqCut1)
706 ispsf2 = (q2 < psfChisqCut2)
708 pkres.psfFit1 = (chisq1, dof1)
709 pkres.psfFit2 = (chisq2, dof2)
711 # check that the fit PSF spatial derivative terms aren't too big
712 if ispsf2:
713 fdx, fdy = X2[I_dx], X2[I_dy]
714 f0 = X2[I_psf]
715 # as a fraction of the PSF flux
716 dx = fdx/f0
717 dy = fdy/f0
718 ispsf2 = ispsf2 and (abs(dx) < 1. and abs(dy) < 1.)
719 log.trace('isPSF2 -- checking derivatives: dx,dy = %g, %g -> %s', dx, dy, str(ispsf2))
720 if not ispsf2:
721 pkres.psfFitBigDecenter = True
723 # Looks like a shifted PSF: try actually shifting the PSF by that amount
724 # and re-evaluate the fit.
725 if ispsf2:
726 psfimg2 = psf.computeImage(cx + dx, cy + dy)
727 # clip
728 pbb2 = psfimg2.getBBox()
729 pbb2.clip(fbb)
731 # Make sure we haven't been given a substitute PSF that's nowhere near where we want, as may occur if
732 # "Cannot compute CoaddPsf at point (xx,yy); no input images at that point."
733 if not pbb2.contains(geom.Point2I(int(cx + dx), int(cy + dy))):
734 ispsf2 = False
735 else:
736 # clip image to bbox
737 px0, py0 = psfimg2.getX0(), psfimg2.getY0()
738 psfarr = psfimg2.getArray()[pbb2.getMinY()-py0:1+pbb2.getMaxY()-py0,
739 pbb2.getMinX()-px0:1+pbb2.getMaxX()-px0]
740 px0, py0 = pbb2.getMinX(), pbb2.getMinY()
741 px1, py1 = pbb2.getMaxX(), pbb2.getMaxY()
743 # yuck! Update the PSF terms in the least-squares fit matrix.
744 Ab = A[:, :NT1]
746 sx1, sx2, sx3, sx4 = _overlap(xlo, xhi, px0, px1)
747 sy1, sy2, sy3, sy4 = _overlap(ylo, yhi, py0, py1)
748 dpx0, dpy0 = px0 - xlo, py0 - ylo
749 psfsub = psfarr[sy3-dpy0:sy4-dpy0, sx3-dpx0:sx4-dpx0]
750 vsub = valid[sy1-ylo:sy2-ylo, sx1-xlo:sx2-xlo]
751 xx, yy = np.arange(xlo, xhi+1), np.arange(ylo, yhi+1)
752 inpsf = np.outer((yy >= py0)*(yy <= py1), (xx >= px0)*(xx <= px1))
753 Ab[inpsf[valid], I_psf] = psfsub[vsub]
755 Aw = Ab*w[:, np.newaxis]
756 # re-solve...
757 Xb, rb, rankb, sb = np.linalg.lstsq(Aw, bw, rcond=-1)
758 if len(rb) > 0:
759 chisqb = rb[0]
760 else:
761 chisqb = 1e30
762 dofb = sumr - len(Xb)
763 qb = chisqb/dofb
764 ispsf2 = (qb < psfChisqCut2b)
765 q2 = qb
766 X2 = Xb
767 log.trace('shifted PSF: new chisq/dof = %g; good? %s', qb, ispsf2)
768 pkres.psfFit3 = (chisqb, dofb)
770 # Which one do we keep?
771 if (((ispsf1 and ispsf2) and (q2 < q1)) or
772 (ispsf2 and not ispsf1)):
773 Xpsf = X2
774 chisq = chisq2
775 dof = dof2
776 log.debug('dof %g', dof)
777 log.trace('Keeping shifted-PSF model')
778 cx += dx
779 cy += dy
780 pkres.psfFitWithDecenter = True
781 else:
782 # (arbitrarily set to X1 when neither fits well)
783 Xpsf = X1
784 chisq = chisq1
785 dof = dof1
786 log.debug('dof %g', dof)
787 log.trace('Keeping unshifted PSF model')
789 ispsf = (ispsf1 or ispsf2)
791 # Save the PSF models in images for posterity.
792 if debugPsf:
793 SW, SH = 1+xhi-xlo, 1+yhi-ylo
794 psfmod = afwImage.ImageF(SW, SH)
795 psfmod.setXY0(xlo, ylo)
796 psfderivmodm = afwImage.MaskedImageF(SW, SH)
797 psfderivmod = psfderivmodm.getImage()
798 psfderivmod.setXY0(xlo, ylo)
799 model = afwImage.ImageF(SW, SH)
800 model.setXY0(xlo, ylo)
801 for i in range(len(Xpsf)):
802 for (x, y), v in zip(ipixes, A[:, i]*Xpsf[i]):
803 ix, iy = int(x), int(y)
804 model.set(ix, iy, model.get(ix, iy) + float(v))
805 if i in [I_psf, I_dx, I_dy]:
806 psfderivmod.set(ix, iy, psfderivmod.get(ix, iy) + float(v))
807 for ii in range(NP):
808 x, y = ipixes[ii, :]
809 psfmod.set(int(x), int(y), float(A[ii, I_psf]*Xpsf[I_psf]))
810 modelfp = afwDet.Footprint(fp.getPeaks().getSchema())
811 for (x, y) in ipixes:
812 modelfp.addSpan(int(y+ylo), int(x+xlo), int(x+xlo))
813 modelfp.normalize()
815 pkres.psfFitDebugPsf0Img = psfimg
816 pkres.psfFitDebugPsfImg = psfmod
817 pkres.psfFitDebugPsfDerivImg = psfderivmod
818 pkres.psfFitDebugPsfModel = model
819 pkres.psfFitDebugStamp = img.Factory(img, stampbb, True)
820 pkres.psfFitDebugValidPix = valid # numpy array
821 pkres.psfFitDebugVar = varimg.Factory(varimg, stampbb, True)
822 ww = np.zeros(valid.shape, np.float)
823 ww[valid] = w
824 pkres.psfFitDebugWeight = ww # numpy
825 pkres.psfFitDebugRampWeight = rw
827 # Save things we learned about this peak for posterity...
828 pkres.psfFitR0 = R0
829 pkres.psfFitR1 = R1
830 pkres.psfFitStampExtent = (xlo, xhi, ylo, yhi)
831 pkres.psfFitCenter = (cx, cy)
832 log.debug('saving chisq,dof %g %g', chisq, dof)
833 pkres.psfFitBest = (chisq, dof)
834 pkres.psfFitParams = Xpsf
835 pkres.psfFitFlux = Xpsf[I_psf]
836 pkres.psfFitNOthers = len(otherpeaks)
838 if ispsf:
839 pkres.setDeblendedAsPsf()
841 # replace the template image by the PSF + derivatives
842 # image.
843 log.trace('Deblending as PSF; setting template to PSF model')
845 # Instantiate the PSF model and clip it to the footprint
846 psfimg = psf.computeImage(cx, cy)
847 # Scale by fit flux.
848 psfimg *= Xpsf[I_psf]
849 psfimg = psfimg.convertF()
851 # Clip the Footprint to the PSF model image bbox.
852 fpcopy = afwDet.Footprint(fp)
853 psfbb = psfimg.getBBox()
854 fpcopy.clipTo(psfbb)
855 bb = fpcopy.getBBox()
857 # Copy the part of the PSF model within the clipped footprint.
858 psfmod = afwImage.ImageF(bb)
859 fpcopy.spans.copyImage(psfimg, psfmod)
860 # Save it as our template.
861 clipFootprintToNonzeroImpl(fpcopy, psfmod)
862 pkres.setTemplate(psfmod, fpcopy)
864 # DEBUG
865 pkres.setPsfTemplate(psfmod, fpcopy)
867 return ispsf
870def buildSymmetricTemplates(debResult, log, patchEdges=False, setOrigTemplate=True):
871 """Build a symmetric template for each peak in each filter
873 Given ``maskedImageF``, ``footprint``, and a ``DebldendedPeak``, creates a symmetric template
874 (``templateImage`` and ``templateFootprint``) around the peak for all peaks not flagged as
875 ``skip`` or ``deblendedAsPsf``.
877 Parameters
878 ----------
879 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
880 Container for the final deblender results.
881 log: `log.Log`
882 LSST logger for logging purposes.
883 patchEdges: `bool`, optional
884 If True and if the parent Footprint touches pixels with the ``EDGE`` bit set,
885 then grow the parent Footprint to include all symmetric templates.
887 Returns
888 -------
889 modified: `bool`
890 If any peaks are not skipped or marked as point sources, ``modified`` is ``True.
891 Otherwise ``modified`` is ``False``.
892 """
893 modified = False
894 # Create the Templates for each peak in each filter
895 for fidx in debResult.filters:
896 dp = debResult.deblendedParents[fidx]
897 imbb = dp.img.getBBox()
898 log.trace('Creating templates for footprint at x0,y0,W,H = %i, %i, %i, %i)', dp.x0, dp.y0, dp.W, dp.H)
900 for peaki, pkres in enumerate(dp.peaks):
901 log.trace('Deblending peak %i of %i', peaki, len(dp.peaks))
902 # TODO: Check debResult to see if the peak is deblended as a point source
903 # when comparing all bands, not just a single band
904 if pkres.skip or pkres.deblendedAsPsf:
905 continue
906 modified = True
907 pk = pkres.peak
908 cx, cy = pk.getIx(), pk.getIy()
909 if not imbb.contains(geom.Point2I(cx, cy)):
910 log.trace('Peak center is not inside image; skipping %i', pkres.pki)
911 pkres.setOutOfBounds()
912 continue
913 log.trace('computing template for peak %i at (%i, %i)', pkres.pki, cx, cy)
914 timg, tfoot, patched = bUtils.buildSymmetricTemplate(dp.maskedImage, dp.fp, pk, dp.avgNoise,
915 True, patchEdges)
916 if timg is None:
917 log.trace('Peak %i at (%i, %i): failed to build symmetric template', pkres.pki, cx, cy)
918 pkres.setFailedSymmetricTemplate()
919 continue
921 if patched:
922 pkres.setPatched()
924 # possibly save the original symmetric template
925 if setOrigTemplate:
926 pkres.setOrigTemplate(timg, tfoot)
927 pkres.setTemplate(timg, tfoot)
928 return modified
931def rampFluxAtEdge(debResult, log, patchEdges=False):
932 """Adjust flux on the edges of the template footprints.
934 Using the PSF, a peak ``Footprint`` with pixels on the edge of ``footprint``
935 is grown by the ``psffwhm``*1.5 and filled in with ramped pixels.
936 The result is a new symmetric footprint template for the peaks near the edge.
938 Parameters
939 ----------
940 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
941 Container for the final deblender results.
942 log: `log.Log`
943 LSST logger for logging purposes.
944 patchEdges: `bool`, optional
945 If True and if the parent Footprint touches pixels with the ``EDGE`` bit set,
946 then grow the parent Footprint to include all symmetric templates.
948 Returns
949 -------
950 modified: `bool`
951 If any peaks have their templates modified to include flux at the edges,
952 ``modified`` is ``True``.
953 """
954 modified = False
955 # Loop over all filters
956 for fidx in debResult.filters:
957 dp = debResult.deblendedParents[fidx]
958 log.trace('Checking for significant flux at edge: sigma1=%g', dp.avgNoise)
960 for peaki, pkres in enumerate(dp.peaks):
961 if pkres.skip or pkres.deblendedAsPsf:
962 continue
963 timg, tfoot = pkres.templateImage, pkres.templateFootprint
964 if bUtils.hasSignificantFluxAtEdge(timg, tfoot, 3*dp.avgNoise):
965 log.trace("Template %i has significant flux at edge: ramping", pkres.pki)
966 try:
967 (timg2, tfoot2, patched) = _handle_flux_at_edge(log, dp.psffwhm, timg, tfoot, dp.fp,
968 dp.maskedImage, dp.x0, dp.x1,
969 dp.y0, dp.y1, dp.psf, pkres.peak,
970 dp.avgNoise, patchEdges)
971 except lsst.pex.exceptions.Exception as exc:
972 if (isinstance(exc, lsst.pex.exceptions.InvalidParameterError) and
973 "CoaddPsf" in str(exc)):
974 pkres.setOutOfBounds()
975 continue
976 raise
977 pkres.setRampedTemplate(timg2, tfoot2)
978 if patched:
979 pkres.setPatched()
980 pkres.setTemplate(timg2, tfoot2)
981 modified = True
982 return modified
985def _handle_flux_at_edge(log, psffwhm, t1, tfoot, fp, maskedImage,
986 x0, x1, y0, y1, psf, pk, sigma1, patchEdges):
987 """Extend a template by the PSF to fill in the footprint.
989 Using the PSF, a footprint that touches the edge is passed to the function
990 and is grown by the psffwhm*1.5 and filled in with ramped pixels.
992 Parameters
993 ----------
994 log: `log.Log`
995 LSST logger for logging purposes.
996 psffwhm: `float`
997 PSF FWHM in pixels.
998 t1: `afw.image.ImageF`
999 The image template that contains the footprint to extend.
1000 tfoot: `afw.detection.Footprint`
1001 Symmetric Footprint to extend.
1002 fp: `afw.detection.Footprint`
1003 Parent Footprint that is being deblended.
1004 maskedImage: `afw.image.MaskedImageF`
1005 Full MaskedImage containing the parent footprint ``fp``.
1006 x0,y0: `init`
1007 Minimum x,y for the bounding box of the footprint ``fp``.
1008 x1,y1: `int`
1009 Maximum x,y for the bounding box of the footprint ``fp``.
1010 psf: `afw.detection.Psf`
1011 PSF of the image.
1012 pk: `afw.detection.PeakRecord`
1013 The peak within the Footprint whose footprint is being extended.
1014 sigma1: `float`
1015 Estimated noise level in the image.
1016 patchEdges: `bool`
1017 If ``patchEdges==True`` and if the footprint touches pixels with the
1018 ``EDGE`` bit set, then for spans whose symmetric mirror are outside the
1019 image, the symmetric footprint is grown to include them and their
1020 pixel values are stored.
1022 Results
1023 -------
1024 t2: `afw.image.ImageF`
1025 Image of the extended footprint.
1026 tfoot2: `afw.detection.Footprint`
1027 Extended Footprint.
1028 patched: `bool`
1029 If the footprint touches an edge pixel, ``patched`` will be set to ``True``.
1030 Otherwise ``patched`` is ``False``.
1031 """
1032 log.trace('Found significant flux at template edge.')
1033 # Compute the max of:
1034 # -symmetric-template-clipped image * PSF
1035 # -footprint-clipped image
1036 # Ie, extend the template by the PSF and "fill in" the footprint.
1037 # Then find the symmetric template of that image.
1039 # The size we'll grow by
1040 S = psffwhm*1.5
1041 # make it an odd integer
1042 S = int((S + 0.5)/2)*2 + 1
1044 tbb = tfoot.getBBox()
1045 tbb.grow(S)
1047 # (footprint+margin)-clipped image;
1048 # we need the pixels OUTSIDE the footprint to be 0.
1049 fpcopy = afwDet.Footprint(fp)
1050 fpcopy.dilate(S)
1051 fpcopy.setSpans(fpcopy.spans.clippedTo(tbb))
1052 fpcopy.removeOrphanPeaks()
1053 padim = maskedImage.Factory(tbb)
1054 fpcopy.spans.clippedTo(maskedImage.getBBox()).copyMaskedImage(maskedImage, padim)
1056 # find pixels on the edge of the template
1057 edgepix = bUtils.getSignificantEdgePixels(t1, tfoot, -1e6)
1059 # instantiate PSF image
1060 xc = int((x0 + x1)/2)
1061 yc = int((y0 + y1)/2)
1062 psfim = psf.computeImage(geom.Point2D(xc, yc))
1063 pbb = psfim.getBBox()
1064 # shift PSF image to be centered on zero
1065 lx, ly = pbb.getMinX(), pbb.getMinY()
1066 psfim.setXY0(lx - xc, ly - yc)
1067 pbb = psfim.getBBox()
1068 # clip PSF to S, if necessary
1069 Sbox = geom.Box2I(geom.Point2I(-S, -S), geom.Extent2I(2*S+1, 2*S+1))
1070 if not Sbox.contains(pbb):
1071 # clip PSF image
1072 psfim = psfim.Factory(psfim, Sbox, afwImage.PARENT, True)
1073 pbb = psfim.getBBox()
1074 px0 = pbb.getMinX()
1075 px1 = pbb.getMaxX()
1076 py0 = pbb.getMinY()
1077 py1 = pbb.getMaxY()
1079 # Compute the ramped-down edge pixels
1080 ramped = t1.Factory(tbb)
1081 Tout = ramped.getArray()
1082 Tin = t1.getArray()
1083 tx0, ty0 = t1.getX0(), t1.getY0()
1084 ox0, oy0 = ramped.getX0(), ramped.getY0()
1085 P = psfim.getArray()
1086 P /= P.max()
1087 # For each edge pixel, Tout = max(Tout, edgepix * PSF)
1088 for span in edgepix.getSpans():
1089 y = span.getY()
1090 for x in range(span.getX0(), span.getX1()+1):
1091 slc = (slice(y+py0 - oy0, y+py1+1 - oy0),
1092 slice(x+px0 - ox0, x+px1+1 - ox0))
1093 Tout[slc] = np.maximum(Tout[slc], Tin[y-ty0, x-tx0]*P)
1095 # Fill in the "padim" (which has the right variance and
1096 # mask planes) with the ramped pixels, outside the footprint
1097 imZeros = (padim.getImage().getArray() == 0)
1098 padim.getImage().getArray()[imZeros] = ramped.getArray()[imZeros]
1100 t2, tfoot2, patched = bUtils.buildSymmetricTemplate(padim, fpcopy, pk, sigma1, True, patchEdges)
1102 # This template footprint may extend outside the parent
1103 # footprint -- or the image. Clip it.
1104 # NOTE that this may make it asymmetric, unlike normal templates.
1105 imbb = maskedImage.getBBox()
1106 tfoot2.clipTo(imbb)
1107 tbb = tfoot2.getBBox()
1108 # clip template image to bbox
1109 t2 = t2.Factory(t2, tbb, afwImage.PARENT, True)
1111 return t2, tfoot2, patched
1114def medianSmoothTemplates(debResult, log, medianFilterHalfsize=2):
1115 """Applying median smoothing filter to the template images for every peak in every filter.
1117 Parameters
1118 ----------
1119 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
1120 Container for the final deblender results.
1121 log: `log.Log`
1122 LSST logger for logging purposes.
1123 medianFilterHalfSize: `int`, optional
1124 Half the box size of the median filter, i.e. a ``medianFilterHalfSize`` of 50 means that
1125 each output pixel will be the median of the pixels in a 101 x 101-pixel box in the input image.
1126 This parameter is only used when ``medianSmoothTemplate==True``, otherwise it is ignored.
1128 Returns
1129 -------
1130 modified: `bool`
1131 Whether or not any templates were modified.
1132 This will be ``True`` as long as there is at least one source that is not flagged as a PSF.
1133 """
1134 modified = False
1135 # Loop over all filters
1136 for fidx in debResult.filters:
1137 dp = debResult.deblendedParents[fidx]
1138 for peaki, pkres in enumerate(dp.peaks):
1139 if pkres.skip or pkres.deblendedAsPsf:
1140 continue
1141 modified = True
1142 timg, tfoot = pkres.templateImage, pkres.templateFootprint
1143 filtsize = medianFilterHalfsize*2 + 1
1144 if timg.getWidth() >= filtsize and timg.getHeight() >= filtsize:
1145 log.trace('Median filtering template %i', pkres.pki)
1146 # We want the output to go in "t1", so copy it into
1147 # "inimg" for input
1148 inimg = timg.Factory(timg, True)
1149 bUtils.medianFilter(inimg, timg, medianFilterHalfsize)
1150 # possible save this median-filtered template
1151 pkres.setMedianFilteredTemplate(timg, tfoot)
1152 else:
1153 log.trace('Not median-filtering template %i: size %i x %i smaller than required %i x %i',
1154 pkres.pki, timg.getWidth(), timg.getHeight(), filtsize, filtsize)
1155 pkres.setTemplate(timg, tfoot)
1156 return modified
1159def makeTemplatesMonotonic(debResult, log):
1160 """Make the templates monotonic.
1162 The pixels in the templates are modified such that pixels further from the peak will
1163 have values smaller than those closer to the peak.
1165 Parameters
1166 ----------
1167 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
1168 Container for the final deblender results.
1169 log: `log.Log`
1170 LSST logger for logging purposes.
1172 Returns
1173 -------
1174 modified: `bool`
1175 Whether or not any templates were modified.
1176 This will be ``True`` as long as there is at least one source that is not flagged as a PSF.
1177 """
1178 modified = False
1179 # Loop over all filters
1180 for fidx in debResult.filters:
1181 dp = debResult.deblendedParents[fidx]
1182 for peaki, pkres in enumerate(dp.peaks):
1183 if pkres.skip or pkres.deblendedAsPsf:
1184 continue
1185 modified = True
1186 timg, tfoot = pkres.templateImage, pkres.templateFootprint
1187 pk = pkres.peak
1188 log.trace('Making template %i monotonic', pkres.pki)
1189 bUtils.makeMonotonic(timg, pk)
1190 pkres.setTemplate(timg, tfoot)
1191 return modified
1194def clipFootprintsToNonzero(debResult, log):
1195 """Clip non-zero spans in the template footprints for every peak in each filter.
1197 Peak ``Footprint``s are clipped to the region in the image containing non-zero values
1198 by dropping spans that are completely zero and moving endpoints to non-zero pixels
1199 (but does not split spans that have internal zeros).
1201 Parameters
1202 ----------
1203 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
1204 Container for the final deblender results.
1205 log: `log.Log`
1206 LSST logger for logging purposes.
1208 Returns
1209 -------
1210 modified: `bool`
1211 Whether or not any templates were modified.
1212 This will be ``True`` as long as there is at least one source that is not flagged as a PSF.
1213 """
1214 # Loop over all filters
1215 for fidx in debResult.filters:
1216 dp = debResult.deblendedParents[fidx]
1217 for peaki, pkres in enumerate(dp.peaks):
1218 if pkres.skip or pkres.deblendedAsPsf:
1219 continue
1220 timg, tfoot = pkres.templateImage, pkres.templateFootprint
1221 clipFootprintToNonzeroImpl(tfoot, timg)
1222 if not tfoot.getBBox().isEmpty() and tfoot.getBBox() != timg.getBBox(afwImage.PARENT):
1223 timg = timg.Factory(timg, tfoot.getBBox(), afwImage.PARENT, True)
1224 pkres.setTemplate(timg, tfoot)
1225 return False
1228def weightTemplates(debResult, log):
1229 """Weight the templates to best fit the observed image in each filter
1231 This function re-weights the templates so that their linear combination best represents
1232 the observed image in that filter.
1233 In the future it may be useful to simultaneously weight all of the filters together.
1235 Parameters
1236 ----------
1237 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
1238 Container for the final deblender results.
1239 log: `log.Log`
1240 LSST logger for logging purposes.
1242 Returns
1243 -------
1244 modified: `bool`
1245 ``weightTemplates`` does not actually modify the ``Footprint`` templates other than
1246 to add a weight to them, so ``modified`` is always ``False``.
1247 """
1248 # Weight the templates by doing a least-squares fit to the image
1249 log.trace('Weighting templates')
1250 for fidx in debResult.filters:
1251 _weightTemplates(debResult.deblendedParents[fidx])
1252 return False
1255def _weightTemplates(dp):
1256 """Weight the templates to best match the parent Footprint in a single filter
1258 This includes weighting both regular templates and point source templates
1260 Parameter
1261 ---------
1262 dp: `DeblendedParent`
1263 The deblended parent to re-weight
1265 Returns
1266 -------
1267 None
1268 """
1269 nchild = np.sum([pkres.skip is False for pkres in dp.peaks])
1270 A = np.zeros((dp.W*dp.H, nchild))
1271 parentImage = afwImage.ImageF(dp.bb)
1272 afwDet.copyWithinFootprintImage(dp.fp, dp.img, parentImage)
1273 b = parentImage.getArray().ravel()
1275 index = 0
1276 for pkres in dp.peaks:
1277 if pkres.skip:
1278 continue
1279 childImage = afwImage.ImageF(dp.bb)
1280 afwDet.copyWithinFootprintImage(dp.fp, pkres.templateImage, childImage)
1281 A[:, index] = childImage.getArray().ravel()
1282 index += 1
1284 X1, r1, rank1, s1 = np.linalg.lstsq(A, b, rcond=-1)
1285 del A
1286 del b
1288 index = 0
1289 for pkres in dp.peaks:
1290 if pkres.skip:
1291 continue
1292 pkres.templateImage *= X1[index]
1293 pkres.setTemplateWeight(X1[index])
1294 index += 1
1297def reconstructTemplates(debResult, log, maxTempDotProd=0.5):
1298 """Remove "degenerate templates"
1300 If galaxies have substructure, such as face-on spirals, the process of identifying peaks can
1301 "shred" the galaxy into many pieces. The templates of shredded galaxies are typically quite
1302 similar because they represent the same galaxy, so we try to identify these "degenerate" peaks
1303 by looking at the inner product (in pixel space) of pairs of templates.
1304 If they are nearly parallel, we only keep one of the peaks and reject the other.
1305 If only one of the peaks is a PSF template, the other template is used,
1306 otherwise the one with the maximum template value is kept.
1308 Parameters
1309 ----------
1310 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
1311 Container for the final deblender results.
1312 log: `log.Log`
1313 LSST logger for logging purposes.
1314 maxTempDotProd: `float`, optional
1315 All dot products between templates greater than ``maxTempDotProd`` will result in one
1316 of the templates removed.
1318 Returns
1319 -------
1320 modified: `bool`
1321 If any degenerate templates are found, ``modified`` is ``True``.
1322 """
1323 log.trace('Looking for degnerate templates')
1325 foundReject = False
1326 for fidx in debResult.filters:
1327 dp = debResult.deblendedParents[fidx]
1328 nchild = np.sum([pkres.skip is False for pkres in dp.peaks])
1329 indexes = [pkres.pki for pkres in dp.peaks if pkres.skip is False]
1331 # We build a matrix that stores the dot product between templates.
1332 # We convert the template images to HeavyFootprints because they already have a method
1333 # to compute the dot product.
1334 A = np.zeros((nchild, nchild))
1335 maxTemplate = []
1336 heavies = []
1337 for pkres in dp.peaks:
1338 if pkres.skip:
1339 continue
1340 heavies.append(afwDet.makeHeavyFootprint(pkres.templateFootprint,
1341 afwImage.MaskedImageF(pkres.templateImage)))
1342 maxTemplate.append(np.max(pkres.templateImage.getArray()))
1344 for i in range(nchild):
1345 for j in range(i + 1):
1346 A[i, j] = heavies[i].dot(heavies[j])
1348 # Normalize the dot products to get the cosine of the angle between templates
1349 for i in range(nchild):
1350 for j in range(i):
1351 norm = A[i, i]*A[j, j]
1352 if norm <= 0:
1353 A[i, j] = 0
1354 else:
1355 A[i, j] /= np.sqrt(norm)
1357 # Iterate over pairs of objects and find the maximum non-diagonal element of the matrix.
1358 # Exit the loop once we find a single degenerate pair greater than the threshold.
1359 rejectedIndex = -1
1360 for i in range(nchild):
1361 currentMax = 0.
1362 for j in range(i):
1363 if A[i, j] > currentMax:
1364 currentMax = A[i, j]
1365 if currentMax > maxTempDotProd:
1366 foundReject = True
1367 rejectedIndex = j
1369 if foundReject:
1370 break
1372 del A
1374 # If one of the objects is identified as a PSF keep the other one, otherwise keep the one
1375 # with the maximum template value
1376 if foundReject:
1377 keep = indexes[i]
1378 reject = indexes[rejectedIndex]
1379 if dp.peaks[keep].deblendedAsPsf and dp.peaks[reject].deblendedAsPsf is False:
1380 keep = indexes[rejectedIndex]
1381 reject = indexes[i]
1382 elif dp.peaks[keep].deblendedAsPsf is False and dp.peaks[reject].deblendedAsPsf:
1383 reject = indexes[rejectedIndex]
1384 keep = indexes[i]
1385 else:
1386 if maxTemplate[rejectedIndex] > maxTemplate[i]:
1387 keep = indexes[rejectedIndex]
1388 reject = indexes[i]
1389 log.trace('Removing object with index %d : %f. Degenerate with %d' % (reject, currentMax,
1390 keep))
1391 dp.peaks[reject].skip = True
1392 dp.peaks[reject].degenerate = True
1394 return foundReject
1397def apportionFlux(debResult, log, assignStrayFlux=True, strayFluxAssignment='r-to-peak',
1398 strayFluxToPointSources='necessary', clipStrayFluxFraction=0.001,
1399 getTemplateSum=False):
1400 """Apportion flux to all of the peak templates in each filter
1402 Divide the ``maskedImage`` flux amongst all of the templates based on the fraction of
1403 flux assigned to each ``template``.
1404 Leftover "stray flux" is assigned to peaks based on the other parameters.
1406 Parameters
1407 ----------
1408 debResult: `lsst.meas.deblender.baseline.DeblenderResult`
1409 Container for the final deblender results.
1410 log: `log.Log`
1411 LSST logger for logging purposes.
1412 assignStrayFlux: `bool`, optional
1413 If True then flux in the parent footprint that is not covered by any of the
1414 template footprints is assigned to templates based on their 1/(1+r^2) distance.
1415 How the flux is apportioned is determined by ``strayFluxAssignment``.
1416 strayFluxAssignment: `string`, optional
1417 Determines how stray flux is apportioned.
1418 * ``trim``: Trim stray flux and do not include in any footprints
1419 * ``r-to-peak`` (default): Stray flux is assigned based on (1/(1+r^2) from the peaks
1420 * ``r-to-footprint``: Stray flux is distributed to the footprints based on 1/(1+r^2) of the
1421 minimum distance from the stray flux to footprint
1422 * ``nearest-footprint``: Stray flux is assigned to the footprint with lowest L-1 (Manhattan)
1423 distance to the stray flux
1424 strayFluxToPointSources: `string`, optional
1425 Determines how stray flux is apportioned to point sources
1426 * ``never``: never apportion stray flux to point sources
1427 * ``necessary`` (default): point sources are included only if there are no extended sources nearby
1428 * ``always``: point sources are always included in the 1/(1+r^2) splitting
1429 clipStrayFluxFraction: `float`, optional
1430 Minimum stray-flux portion.
1431 Any stray-flux portion less than ``clipStrayFluxFraction`` is clipped to zero.
1432 getTemplateSum: `bool`, optional
1433 As part of the flux calculation, the sum of the templates is calculated.
1434 If ``getTemplateSum==True`` then the sum of the templates is stored in the result
1435 (a `DeblendedFootprint`).
1437 Returns
1438 -------
1439 modified: `bool`
1440 Apportion flux always modifies the templates, so ``modified`` is always ``True``.
1441 However, this should likely be the final step and it is unlikely that
1442 any deblender plugins will be re-run.
1443 """
1444 validStrayPtSrc = ['never', 'necessary', 'always']
1445 validStrayAssign = ['r-to-peak', 'r-to-footprint', 'nearest-footprint', 'trim']
1446 if strayFluxToPointSources not in validStrayPtSrc:
1447 raise ValueError((('strayFluxToPointSources: value \"%s\" not in the set of allowed values: ') %
1448 strayFluxToPointSources) + str(validStrayPtSrc))
1449 if strayFluxAssignment not in validStrayAssign:
1450 raise ValueError((('strayFluxAssignment: value \"%s\" not in the set of allowed values: ') %
1451 strayFluxAssignment) + str(validStrayAssign))
1453 for fidx in debResult.filters:
1454 dp = debResult.deblendedParents[fidx]
1455 # Prepare inputs to "apportionFlux" call.
1456 # template maskedImages
1457 tmimgs = []
1458 # template footprints
1459 tfoots = []
1460 # deblended as psf
1461 dpsf = []
1462 # peak x,y
1463 pkx = []
1464 pky = []
1465 # indices of valid templates
1466 ibi = []
1467 bb = dp.fp.getBBox()
1469 for peaki, pkres in enumerate(dp.peaks):
1470 if pkres.skip:
1471 continue
1472 tmimgs.append(pkres.templateImage)
1473 tfoots.append(pkres.templateFootprint)
1474 # for stray flux...
1475 dpsf.append(pkres.deblendedAsPsf)
1476 pk = pkres.peak
1477 pkx.append(pk.getIx())
1478 pky.append(pk.getIy())
1479 ibi.append(pkres.pki)
1481 # Now apportion flux according to the templates
1482 log.trace('Apportioning flux among %i templates', len(tmimgs))
1483 sumimg = afwImage.ImageF(bb)
1484 # .getDimensions())
1485 # sumimg.setXY0(bb.getMinX(), bb.getMinY())
1487 strayopts = 0
1488 if strayFluxAssignment == 'trim':
1489 assignStrayFlux = False
1490 strayopts |= bUtils.STRAYFLUX_TRIM
1491 if assignStrayFlux:
1492 strayopts |= bUtils.ASSIGN_STRAYFLUX
1493 if strayFluxToPointSources == 'necessary':
1494 strayopts |= bUtils.STRAYFLUX_TO_POINT_SOURCES_WHEN_NECESSARY
1495 elif strayFluxToPointSources == 'always':
1496 strayopts |= bUtils.STRAYFLUX_TO_POINT_SOURCES_ALWAYS
1498 if strayFluxAssignment == 'r-to-peak':
1499 # this is the default
1500 pass
1501 elif strayFluxAssignment == 'r-to-footprint':
1502 strayopts |= bUtils.STRAYFLUX_R_TO_FOOTPRINT
1503 elif strayFluxAssignment == 'nearest-footprint':
1504 strayopts |= bUtils.STRAYFLUX_NEAREST_FOOTPRINT
1506 portions, strayflux = bUtils.apportionFlux(dp.maskedImage, dp.fp, tmimgs, tfoots, sumimg, dpsf,
1507 pkx, pky, strayopts, clipStrayFluxFraction)
1509 # Shrink parent to union of children
1510 if strayFluxAssignment == 'trim':
1511 finalSpanSet = afwGeom.SpanSet()
1512 for foot in tfoots:
1513 finalSpanSet = finalSpanSet.union(foot.spans)
1514 dp.fp.setSpans(finalSpanSet)
1516 # Store the template sum in the deblender result
1517 if getTemplateSum:
1518 debResult.setTemplateSums(sumimg, fidx)
1520 # Save the apportioned fluxes
1521 ii = 0
1522 for j, (pk, pkres) in enumerate(zip(dp.fp.getPeaks(), dp.peaks)):
1523 if pkres.skip:
1524 continue
1525 pkres.setFluxPortion(portions[ii])
1527 if assignStrayFlux:
1528 # NOTE that due to a swig bug (https://github.com/swig/swig/issues/59)
1529 # we CANNOT iterate over "strayflux", but must index into it.
1530 stray = strayflux[ii]
1531 else:
1532 stray = None
1533 ii += 1
1535 pkres.setStrayFlux(stray)
1537 # Set child footprints to contain the right number of peaks.
1538 for j, (pk, pkres) in enumerate(zip(dp.fp.getPeaks(), dp.peaks)):
1539 if pkres.skip:
1540 continue
1542 for foot, add in [(pkres.templateFootprint, True), (pkres.origFootprint, True),
1543 (pkres.strayFlux, False)]:
1544 if foot is None:
1545 continue
1546 pks = foot.getPeaks()
1547 pks.clear()
1548 if add:
1549 pks.append(pk)
1550 return True