/
blend.py
343 lines (279 loc) · 13.8 KB
/
blend.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# Copyright (c) 2012-2023 by the GalSim developers team on GitHub
# https://github.com/GalSim-developers
#
# This file is part of GalSim: The modular galaxy image simulation toolkit.
# https://github.com/GalSim-developers/GalSim
#
# GalSim is free software: redistribution and use in source and binary forms,
# with or without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions, and the disclaimer given in the accompanying LICENSE
# file.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions, and the disclaimer given in the documentation
# and/or other materials provided with the distribution.
#
# This file defines a few custom types related to making stamps of blended galaxies.
import galsim
import galsim.des
import os
import math
import numpy
#
# Define the Blend stamp type
#
def BuildBlendProfiles(self, config, base, psf, gsparams, logger):
"""
Build a list of galaxy profiles, each convolved with the psf, to use for the blend image.
This is used by both BlendBuilder and BlendSetBuilder.
"""
# Build the neighbors first, so the final "current" value for gal is the main galaxy.
n_neighbors = galsim.config.ParseValue(config, 'n_neighbors', base, int)[0]
min_sep = galsim.config.ParseValue(config, 'min_sep', base, float)[0]
max_sep = galsim.config.ParseValue(config, 'max_sep', base, float)[0]
self.neighbor_gals = []
for i in range(n_neighbors):
gal = galsim.config.BuildGSObject(base, 'gal', gsparams=gsparams, logger=logger)[0]
self.neighbor_gals.append(gal)
# Remove the current stuff from base['gal'] so we don't get the same galaxy
# each time.
galsim.config.RemoveCurrent(base['gal'], keep_safe=True)
# GalSim has a RandomCircle position type that we can use to generate random positions
# in an annulus. Of course, we could do this be hand here, but this is easier.
self.neighbor_pos = []
for i in range(n_neighbors):
config_pos = { 'pos' : { 'type' : 'RandomCircle',
'radius' : max_sep,
'inner_radius' : min_sep }
}
pos = galsim.config.ParseValue(config_pos, 'pos', base, galsim.PositionD)[0]
self.neighbor_pos.append(pos)
self.main_gal = galsim.config.BuildGSObject(base, 'gal', gsparams=gsparams,
logger=logger)[0]
profiles = [ self.main_gal ]
profiles += [ gal.shift(pos) for gal, pos in zip(self.neighbor_gals, self.neighbor_pos) ]
if psf:
profiles = [ galsim.Convolve(gal, psf) for gal in profiles ]
return profiles
class BlendBuilder(galsim.config.StampBuilder):
def setup(self, config, base, xsize, ysize, ignore, logger):
"""Do the appropriate setup for a Blend stamp.
"""
self.first = None # Mark that we don't have anything stored yet.
# Parse the necessary parameters and make sure they are the right type.
req = { 'n_neighbors' : int, 'min_sep' : float, 'max_sep' : float }
galsim.config.CheckAllParams(config, req=req, ignore=ignore)
# Now farm off to the regular stamp setup function the rest of the work of parsing
# the size and position of the stamp.
ignore = ignore + ['n_neighbors', 'min_sep', 'max_sep']
return super(BlendBuilder,self).setup(config, base, xsize, ysize, ignore, logger)
def buildProfile(self, config, base, psf, gsparams, logger):
return BuildBlendProfiles(self, config, base, psf, gsparams, logger)
def draw(self, profiles, image, method, offset, config, base, logger):
"""
Draw the profiles onto the stamp.
"""
n_neighbors = len(profiles)-1
# Draw the central galaxy using the basic draw function.
image = galsim.config.DrawBasic(profiles[0], image, method, offset, config, base, logger)
# We'll want a copy of just the neighbors for the deblend image.
# Otherwise we could have just drawn these on the main image with add_to_image = True
self.neighbor_image = image.copy()
self.neighbor_image.setZero()
# Draw all the neighbor stamps
for p in profiles[1:]:
galsim.config.DrawBasic(p, self.neighbor_image, method, offset, config, base, logger,
add_to_image=True)
# Save this in base for the deblend output
base['blend_neighbor_image'] = self.neighbor_image
image += self.neighbor_image
return image
def whiten(self, profiles, image, config, base, logger):
"""
Whiten the noise on the stamp according to the existing noise in all the profiles.
"""
total = galsim.Add(profiles)
return super(BlendBuilder,self).whiten(total, image, config, base, logger)
galsim.config.RegisterStampType('Blend', BlendBuilder())
#
# Define the BlendSet stamp type
# This currently doesn't work with image.nproc != 1.
#
class BlendSetBuilder(galsim.config.StampBuilder):
# This is the same as the setup function for Blend, so there is a bit of duplicated code
# here, but it was simpler to have BlendSetBuilder derive directly from StampBuilder
# so the super calls go directly to that.
def setup(self, config, base, xsize, ysize, ignore, logger):
"""Do the appropriate setup for a Blend stamp.
"""
# Make sure that we start over on the start of a new file.
if base['obj_num'] == base['start_obj_num']:
self.first = None
# Parse the necessary parameters and make sure they are the right type.
req = { 'n_neighbors' : int, 'min_sep' : float, 'max_sep' : float }
galsim.config.CheckAllParams(config, req=req, ignore=ignore)
# Now farm off to the regular stamp setup function the rest of the work of parsing
# the size and position of the stamp.
ignore = ignore + ['n_neighbors', 'min_sep', 'max_sep']
return super(BlendSetBuilder, self).setup(config, base, xsize, ysize, ignore, logger)
def buildProfile(self, config, base, psf, gsparams, logger):
"""
Build a list of galaxy profiles, each convolved with the psf, to use for the blend image.
"""
if (self.first is not None and base['obj_num'] < self.first + len(self.profiles)):
# Then the full image is already drawn. Return None to indicate this to
# the downstream functions.
return None
else:
# Run the above BuildBlendProfiles function to build the profiles.
self.profiles = BuildBlendProfiles(self, config, base, psf, gsparams, logger)
# And mark this as the first object in the set
self.first = base['obj_num']
return self.profiles
def draw(self, profiles, image, method, offset, config, base, logger):
"""
Draw the profiles onto the stamp.
"""
if 'stamp_xsize' not in base or 'stamp_ysize' not in base:
raise RuntimeError("stamp size must be given for stamp type=BlendSet")
nx = base['stamp_xsize']
ny = base['stamp_ysize']
wcs = base['wcs']
if profiles is not None:
# Then we haven't drawn the full image yet.
# We need to draw an image large enough to contain each of the cutouts
bounds = galsim.BoundsI(galsim.PositionI(0,0))
for pos in self.neighbor_pos:
image_pos = wcs.toImage(pos)
# Convert to nearest integer position
image_pos = galsim.PositionI( int(image_pos.x+0.5), int(image_pos.y+0.5) )
bounds += image_pos
bounds = bounds.withBorder(max(nx,ny)//2 + 1)
self.full_images = []
for prof in profiles:
im = galsim.ImageF(bounds=bounds, wcs=wcs)
galsim.config.DrawBasic(prof, im, method, offset-im.true_center, config, base,
logger)
self.full_images.append(im)
# Figure out what bounds to use for the cutouts.
k = base['obj_num'] - self.first
if k == 0:
center_pos = galsim.PositionI(0,0)
else:
center_pos = self.neighbor_pos[k-1]
center_image_pos = wcs.toImage(center_pos)
xmin = int(center_image_pos.x) - nx//2 + 1
ymin = int(center_image_pos.y) - ny//2 + 1
self.bounds = galsim.BoundsI(xmin, xmin+nx-1, ymin, ymin+ny-1)
# Add up the cutouts from the profile images
image.setZero()
image.wcs = wcs
for full_im in self.full_images:
assert full_im.bounds.includes(self.bounds)
image += full_im[self.bounds]
# And also build the neighbor image for the deblend image
self.neighbor_image = image.copy()
self.neighbor_image -= self.full_images[k][self.bounds]
# Save this in base for the deblend output
base['blend_neighbor_image'] = self.neighbor_image
return image
def whiten(self, profiles, image, config, base, logger):
"""
Whiten the noise on the stamp according to the existing noise in all the profiles.
"""
k = base['obj_num'] - self.first
# Only whiten the images once.
if k == 0:
self.current_var = 0
for prof, full_im in zip(self.profiles, self.full_images):
self.current_var += super(BlendSetBuilder,self).whiten(
prof, full_im, config, base, logger)
if self.current_var != 0:
# Then we whitened the noise somewhere. Rebuild the stamp
image.setZero()
for full_im in self.full_images:
assert full_im.bounds.includes(self.bounds)
image += full_im[self.bounds]
return self.current_var
def addNoise(self, config, base, image, current_var, logger):
"""Add the sky and noise"""
# We want the noise realization to be the same for all galaxies in the set,
# so we only generate the noise the first time and save it, pulling out the right cutout
# for the subsequent stamps in this set.
k = base['obj_num'] - self.first
if k == 0:
# If we are on the first galaxy, draw the noise.
self.full_noise_image = self.full_images[0].copy()
self.full_noise_image.setZero()
self.full_noise_image, self.current_var = super(BlendSetBuilder,self).addNoise(
config, base, self.full_noise_image, current_var, logger)
image += self.full_noise_image[self.bounds]
return image, self.current_var
# Use the regular Blend functions for Setup and Whiten.
galsim.config.RegisterStampType('BlendSet', BlendSetBuilder())
#
# Define the deblend extra output type.
# This is a custom extra output field to write a second file that is identical
# except for not including the flux of the neigbors. We do this by grabbing the
# pre-noise postage stamp of the neighbor and then subtracting this off from the
# final postage stamp that includes all the noise. So the Poisson noise of the
# neighbors is still there as is the noise from whitening. Just not the actual
# neighbor flux. The represents the result of a perfect deblender.
#
class DeblendBuilder(galsim.config.ExtraOutputBuilder):
def processStamp(self, obj_num, config, base, logger):
"""Save the stamps of just the neighbor fluxes. We'll subtract them from the full image
at the end.
"""
im = base['blend_neighbor_image']
# When we made neighbor image, we didn't have the right bounds yet, so set it now the
# same way the main stamp's bounds are set.
if im is None:
pass
elif base['stamp_center'] is not None:
im.setCenter(base['stamp_center'])
else:
im.setOrigin(base['image_origin'])
# Save it in the scratch dict using this obj_num as the key.
self.scratch[obj_num] = im
def processImage(self, index, obj_nums, config, base, logger):
"""Copy the full final image over and then subtract off the neighbor-only fluxes.
"""
# Start with a copy of the regular final image.
image = base['current_image'].copy()
for obj_num in obj_nums:
# Subtract off the images we made of the (noise-free) neighbors
neighbor_image = self.scratch[obj_num]
b = neighbor_image.bounds & image.bounds
if b.isDefined():
image[b] -= neighbor_image[b]
# Save this in the list of images to write out.
self.data[index] = image
galsim.config.RegisterExtraOutput('deblend', DeblendBuilder())
#
# When used with MEDS files, we modify it slightly so that it can write out as a meds file,
# rather than a simple Fits file.
#
class DeblendMedsBuilder(DeblendBuilder):
def finalize(self, config, base, main_data, logger):
"""Convert from the list of images we've been making into a list of MultiExposureObjects
we can use to write the MEDS file.
"""
# cf. code to make obj_list in BuildMEDS function in galsim/des/des_meds.py
obj_list = []
k1 = 0
# Copy over everything but the images. Replace the data images with the deblend images.
for obj in main_data:
k2 = k1 + obj.n_cutouts
new_images = self.data[k1:k2]
weight = obj.weight
psf = obj.psf
new_obj = galsim.des.MultiExposureObject(images=new_images, weight=weight, psf=psf)
obj_list.append(new_obj)
k1 = k2
return obj_list
def writeFile(self, file_name, config, base, logger):
galsim.des.WriteMEDS(self.final_data, file_name)
galsim.config.RegisterExtraOutput('deblend_meds', DeblendMedsBuilder())