Skip to content

Commit 8f3de7c

Browse files
committed
first pass at pixel space binning
1 parent 8e3c18d commit 8f3de7c

File tree

2 files changed

+89
-84
lines changed

2 files changed

+89
-84
lines changed

synthesizAR/instruments/base.py

+84-77
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Base class for instrument objects.
33
"""
44
import astropy.units as u
5+
import astropy.wcs
56
import copy
67
import numpy as np
78
import pathlib
@@ -45,14 +46,20 @@ class InstrumentBase:
4546
Coordinate of the observing instrument
4647
resolution : `~astropy.units.Quantity`
4748
cadence : `~astropy.units.Quantity`, optional
48-
If specified, this is used to construct the observing time
49+
If specified, this is used to construct the observing time.
4950
pad_fov : `~astropy.units.Quantity`, optional
5051
Two-dimensional array specifying the padding to apply to the field of view of the synthetic
51-
image in both directions. If None, no padding is applied and the field of view is defined
52-
by the maximal extent of the loop coordinates in each direction.
52+
image in both directions in pixel space. If None, no padding is applied and the field of
53+
view is defined by the maximal extent of the loop coordinates in each direction.
54+
Note that if ``fov_center`` and ``fov_width`` are specified, this is ignored.
5355
fov_center : `~astropy.coordinates.SkyCoord`, optional
56+
Reference coordinate coinciding with the center of the field of view.
57+
For this to have an effect, must also specify ``fov_width``.
5458
fov_width : `~astropy.units.Quantity`, optional
59+
Extent of the field of view in pixels.
60+
For this to have an effect, must also specify ``fov_center``.
5561
average_over_los : `bool`, optional
62+
Set to true for non-volumetric quantities
5663
"""
5764

5865
@u.quantity_input
@@ -61,9 +68,9 @@ def __init__(self,
6168
observer,
6269
resolution: u.Unit('arcsec/pix'),
6370
cadence: u.s = None,
64-
pad_fov: u.arcsec = None,
65-
fov_center=None,
66-
fov_width: u.arcsec = None,
71+
pad_fov: u.pixel = None,
72+
fov_center = None,
73+
fov_width: u.pixel = None,
6774
average_over_los=False):
6875
self.observer = observer
6976
self.cadence = cadence
@@ -111,13 +118,13 @@ def observer(self, value):
111118
self._observer = value
112119

113120
@property
114-
def pad_fov(self) -> u.arcsec:
121+
def pad_fov(self) -> u.pixel:
115122
return self._pad_fov
116123

117124
@pad_fov.setter
118125
def pad_fov(self, value):
119126
if value is None:
120-
value = [0, 0] * u.arcsec
127+
value = [0, 0] * u.pixel
121128
self._pad_fov = value
122129

123130
@property
@@ -144,21 +151,17 @@ def get_instrument_name(self, channel):
144151
return self.name
145152

146153
def calculate_intensity_kernel(self, *args, **kwargs):
147-
"""
148-
Converts emissivity for a particular transition to counts per detector channel. When writing
149-
a new instrument class, this method should be overridden.
150-
"""
151-
raise NotImplementedError('No detect method implemented.')
154+
raise NotImplementedError
152155

153156
@property
154157
def projected_frame(self):
155-
return Helioprojective(observer=self.observer, obstime=self.observer.obstime)
158+
return Helioprojective(observer=self.observer)
156159

157160
@property
158161
@u.quantity_input
159162
def pixel_area(self) -> u.cm**2:
160163
"""
161-
Pixel area
164+
Cartesian area on the surface of the Sun covered by a single pixel.
162165
"""
163166
sa_equiv = solar_angle_equivalency(self.observer)
164167
res = (1*u.pix * self.resolution).to('cm', equivalencies=sa_equiv)
@@ -184,18 +187,18 @@ def observe(self, skeleton, save_directory=None, channels=None, **kwargs):
184187
skeleton : `~synthesizAR.Skeleton`
185188
save_directory : `str` or path-like
186189
"""
187-
check_visible = kwargs.pop('check_visible', False)
188190
if channels is None:
189191
channels = self.channels
190192
try:
191193
import distributed
192194
client = distributed.get_client()
193195
except (ImportError, ValueError):
194196
client = None
195-
coordinates = skeleton.all_coordinates
197+
ref_coord, n_pixels = self.get_fov(skeleton.all_coordinates)
198+
wcs = astropy.wcs.WCS(header=self.get_header(ref_coord, n_pixels))
196199
coordinates_centers = skeleton.all_coordinates_centers
197-
bins, bin_range = self.get_detector_array(coordinates)
198-
coordinates_centers_projected = coordinates_centers.transform_to(self.projected_frame)
200+
pixel_locations = wcs.world_to_pixel(coordinates_centers)
201+
visibilities = coordinates_centers.transform_to(self.projected_frame).is_visible()
199202
maps = {}
200203
for channel in channels:
201204
# Compute intensity as a function of time and field-aligned coordinate
@@ -244,20 +247,19 @@ def observe(self, skeleton, save_directory=None, channels=None, **kwargs):
244247
kernels_interp = client.gather(kernel_interp_futures)
245248
kernels = np.concatenate([u.Quantity(*k) for k in kernels_interp], axis=1)
246249

247-
header = self.get_header(channel, coordinates)
250+
header = self.get_header(ref_coord, n_pixels, channel=channel)
248251
# Build a map for each timestep
249252
maps[channel.name] = []
250253
for i, time in enumerate(self.observing_time):
251254
m = self.integrate_los(
252255
time,
253256
channel,
254257
skeleton,
255-
coordinates_centers_projected,
256-
bins,
257-
bin_range,
258+
pixel_locations,
259+
n_pixels,
260+
visibilities,
258261
header,
259-
kernels=kernels[i],
260-
check_visible=check_visible)
262+
kernels=kernels[i])
261263
m = self.convolve_with_psf(m, channel)
262264
if save_directory is None:
263265
maps[channel.name].append(m)
@@ -363,8 +365,15 @@ def interpolate_to_instrument_time(kernel, loop, observing_time, axis=0):
363365
kernel_interp = u.Quantity(f_t(observing_time.value), kernel_unit)
364366
return kernel_interp
365367

366-
def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_range, header,
367-
kernels=None, check_visible=False):
368+
def integrate_los(self,
369+
time,
370+
channel,
371+
skeleton,
372+
pixel_locations,
373+
n_pixels,
374+
visibilities,
375+
header,
376+
kernels=None):
368377
# Compute weights
369378
if kernels is None:
370379
i_time = np.where(time == self.observing_time)[0][0]
@@ -376,27 +385,24 @@ def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_
376385
# average along the LOS
377386
if not self.average_over_los:
378387
kernels *= (skeleton.all_cross_sectional_areas / self.pixel_area).decompose() * skeleton.all_widths
379-
if check_visible:
380-
visible = coordinates_centers.is_visible()
381-
else:
382-
visible = np.ones(kernels.shape)
383388
# Bin
384-
blc, trc = bin_range
389+
# NOTE: Bin order is (y,x) or (row, column)
390+
bins = n_pixels.to_value('pixel')
391+
bin_edges = (np.linspace(-0.5, bins[1]-0.5, bins[1]+1),
392+
np.linspace(-0.5, bins[0]-0.5, bins[0]+1))
385393
hist, _, _ = np.histogram2d(
386-
coordinates_centers.Tx.value,
387-
coordinates_centers.Ty.value,
388-
bins=bins,
389-
range=((blc.Tx.value, trc.Tx.value), (blc.Ty.value, trc.Ty.value)),
390-
weights=kernels.to_value(self._expected_unit) * visible,
394+
pixel_locations[1],
395+
pixel_locations[0],
396+
bins=bin_edges,
397+
weights=kernels.to_value(self._expected_unit) * visibilities,
391398
)
392399
# For some quantities, need to average over all components along a given LOS
393400
if self.average_over_los:
394401
_hist, _, _ = np.histogram2d(
395-
coordinates_centers.Tx.value,
396-
coordinates_centers.Ty.value,
397-
bins=bins,
398-
range=((blc.Tx.value, trc.Tx.value), (blc.Ty.value, trc.Ty.value)),
399-
weights=visible,
402+
pixel_locations[1],
403+
pixel_locations[0],
404+
bins=bin_edges,
405+
weights=visibilities,
400406
)
401407
hist /= np.where(_hist == 0, 1, _hist)
402408
# NOTE: Purposefully using a nonstandard key to record this time as we do not
@@ -409,58 +415,59 @@ def integrate_los(self, time, channel, skeleton, coordinates_centers, bins, bin_
409415

410416
return Map(hist.T, new_header)
411417

412-
def get_header(self, channel, coordinates):
418+
def get_header(self, ref_coord, n_pixels: u.pixel, channel=None):
413419
"""
414-
Create the FITS header for a given channel and set of loop coordinates
415-
that define the needed FOV.
420+
Create the FITS header for a given channel.
421+
422+
Parameters
423+
----------
424+
ref_coord: `~astropy.coordinates.SkyCoord`
425+
Reference coordinate coincident with the center of the field
426+
of view
427+
n_pixels: `~astropy.units.Quantity`
428+
Pixel extent in the x (horizontal) and y (vertical) direction
429+
channel: `ChannelBase`, optional
416430
"""
417-
bins, bin_range = self.get_detector_array(coordinates)
418-
center = SkyCoord(Tx=(bin_range[1].Tx + bin_range[0].Tx)/2,
419-
Ty=(bin_range[1].Ty + bin_range[0].Ty)/2,
420-
frame=bin_range[0].frame)
421-
# FIXME: reference_pixel should be center of the frame in the pixel
422-
# coordinate system of the image.
431+
# NOTE: channel is a kwarg so that a WCS can be computed without specifying
432+
# a channel as these keywords do not affect the WCS
433+
if channel is None:
434+
instrument = None
435+
wavelength = None
436+
else:
437+
instrument = self.get_instrument_name(channel)
438+
wavelength = channel.channel
423439
header = make_fitswcs_header(
424-
(bins[1], bins[0]), # swap order because it expects (row,column)
425-
center,
426-
reference_pixel=(u.Quantity(bins, 'pix') - 1*u.pix) / 2, # center of lower left pixel is (0,0)
440+
n_pixels[::-1].to_value('pixel'), # swap order because it expects (row,column)
441+
ref_coord,
442+
reference_pixel=(n_pixels - 1*u.pix) / 2, # center of lower left pixel is (0,0)
427443
scale=self.resolution,
428444
observatory=self.observatory,
429-
instrument=self.get_instrument_name(channel),
445+
instrument=instrument,
430446
telescope=self.telescope,
431447
detector=self.detector,
432-
wavelength=channel.channel,
448+
wavelength=wavelength,
433449
unit=self._expected_unit,
434450
)
435451
return header
436452

437-
def get_detector_array(self, coordinates):
453+
def get_fov(self, coordinates):
438454
"""
439-
Calculate the number of pixels in the detector FOV and the physical coordinates of the
440-
bottom left and top right corners.
455+
Return the coordinate at the center of the FOV and the width in pixels.
441456
"""
442457
if self.fov_center is not None and self.fov_width is not None:
443458
center = self.fov_center.transform_to(self.projected_frame)
444-
bins_x = int(np.ceil((self.fov_width[0] / self.resolution[0]).decompose()).value)
445-
bins_y = int(np.ceil((self.fov_width[1] / self.resolution[1]).decompose()).value)
446-
bottom_left_corner = SkyCoord(
447-
Tx=center.Tx - self.fov_width[0]/2,
448-
Ty=center.Ty - self.fov_width[1]/2,
449-
frame=center.frame,
450-
)
451-
top_right_corner = SkyCoord(
452-
Tx=bottom_left_corner.Tx + self.fov_width[0],
453-
Ty=bottom_left_corner.Ty + self.fov_width[1],
454-
frame=bottom_left_corner.frame
455-
)
459+
n_pixels = self.fov_width
456460
else:
457461
# If not specified, derive FOV from loop coordinates
458462
coordinates = coordinates.transform_to(self.projected_frame)
459-
bottom_left_corner, top_right_corner = find_minimum_fov(
460-
coordinates, padding=self.pad_fov,
461-
)
463+
bottom_left_corner, top_right_corner = find_minimum_fov(coordinates)
462464
delta_x = top_right_corner.Tx - bottom_left_corner.Tx
463465
delta_y = top_right_corner.Ty - bottom_left_corner.Ty
464-
bins_x = int(np.ceil((delta_x / self.resolution[0]).decompose()).value)
465-
bins_y = int(np.ceil((delta_y / self.resolution[1]).decompose()).value)
466-
return (bins_x, bins_y), (bottom_left_corner, top_right_corner)
466+
center = SkyCoord(Tx=bottom_left_corner.Tx+delta_x/2,
467+
Ty=bottom_left_corner.Ty+delta_y/2,
468+
frame=bottom_left_corner.frame)
469+
pixels_x = int(np.ceil((delta_x / self.resolution[0]).decompose()).value)
470+
pixels_y = int(np.ceil((delta_y / self.resolution[1]).decompose()).value)
471+
n_pixels = u.Quantity([pixels_x, pixels_y], 'pixel')
472+
n_pixels += self.pad_fov
473+
return center, n_pixels

synthesizAR/util/util.py

+5-7
Original file line numberDiff line numberDiff line change
@@ -64,22 +64,20 @@ def coord_in_fov(coord, width, height, center=None, bottom_left_corner=None):
6464
return np.logical_and(in_x, in_y)
6565

6666

67-
def find_minimum_fov(coordinates, padding=None):
67+
def find_minimum_fov(coordinates):
6868
"""
6969
Given an HPC coordinate, find the coordinates of the corners of the
7070
FOV that includes all of the coordinates.
7171
"""
72-
if padding is None:
73-
padding = [0, 0] * u.arcsec
7472
Tx = coordinates.Tx
7573
Ty = coordinates.Ty
7674
bottom_left_corner = SkyCoord(
77-
Tx=Tx.min() - padding[0],
78-
Ty=Ty.min() - padding[1],
75+
Tx=Tx.min(),
76+
Ty=Ty.min(),
7977
frame=coordinates.frame
8078
)
81-
delta_x = Tx.max() + padding[0] - bottom_left_corner.Tx
82-
delta_y = Ty.max() + padding[1] - bottom_left_corner.Ty
79+
delta_x = Tx.max() - bottom_left_corner.Tx
80+
delta_y = Ty.max() - bottom_left_corner.Ty
8381
# Compute right corner after the fact to account for rounding in bin numbers
8482
# NOTE: this is the coordinate of the top right corner of the top right corner pixel, NOT
8583
# the coordinate at the center of the pixel!

0 commit comments

Comments
 (0)