def __init__(self, image, xy_start, xy_end, foreground_radius, gap, background_width, source_id=None, obs_id=None): """ Main and probably sole constructor for this class. :param image: the parent image array [numpy ndarray]. :param xy_start: x,y pixel position in parent image of the beginning of the MP's motion. [XY object, 2-tuple, 2-list, or 2-array of floats] :param xy_end:x,y pixel position in parent image of the beginning of the MP's motion. [XY object, 2-tuple, 2-list, or 2-array of floats] :param foreground_radius: radius of the aperture source end-caps, in pixels. Does not include effect of MP motion. [float] :param gap: Gap in pixels between foreground mask and background mask. [float] :param background_width: Width in pixels of background mask. [float] :param source_id: optional string describing the source (e.g., comp star ID, MP number) that this aperture is intended to measure. A tag only, not used in calculations. [string] :param obs_id: optional observation ID string, will typically be unique among all observations (aperture objects) in one image. A tag only, not used in calculations. [string] """ self.xy_start = xy_start if isinstance(xy_start, XY) else XY(xy_start[0], xy_start[1]) self.xy_end = xy_end if isinstance(xy_end, XY) else XY(xy_end[0], xy_end[1]) self.foreground_radius = foreground_radius self.gap = gap self.background_width = background_width self.background_inner_radius = self.foreground_radius + self.gap self.background_outer_radius = self.foreground_radius + self.gap + self.background_width xy_center = self.xy_start + (self.xy_end - self.xy_start) / 2 corner_x_values = (self.xy_start.x + self.background_outer_radius, self.xy_start.x - self.background_outer_radius, self.xy_end.x + self.background_outer_radius, self.xy_end.x - self.background_outer_radius) x_min = min(corner_x_values) x_max = max(corner_x_values) corner_y_values = (self.xy_start.y + self.background_outer_radius, self.xy_start.y - self.background_outer_radius, self.xy_end.y + self.background_outer_radius, self.xy_end.y - self.background_outer_radius) y_min = min(corner_y_values) y_max = max(corner_y_values) dxy_cutout_size = DXY(int(round(x_max - x_min + 4)), int(round(y_max - y_min + 4))) dxy_offset = DXY(int(round(xy_center.x) - dxy_cutout_size.dx / 2.0), int(round(xy_center.y) - dxy_cutout_size.dy / 2.0)) xy_start_cutout = self.xy_start - dxy_offset xy_end_cutout = self.xy_end - dxy_offset foreground_mask = make_pill_mask(tuple(dxy_cutout_size), tuple(xy_start_cutout), tuple(xy_end_cutout), self.foreground_radius) background_inner_mask = make_pill_mask(tuple(dxy_cutout_size), tuple(xy_start_cutout), tuple(xy_end_cutout), self.background_inner_radius) background_outer_mask = make_pill_mask(tuple(dxy_cutout_size), tuple(xy_start_cutout), tuple(xy_end_cutout), self.background_outer_radius) background_mask = np.logical_or(background_outer_mask, np.logical_not(background_inner_mask)) super().__init__(image, xy_center, dxy_offset, foreground_mask, background_mask, source_id, obs_id) self.motion = (self.xy_end - self.xy_start).length sigma2_motion = (self.motion ** 2) / 12.0 # sigma2 of uniform segment distribution. sigma2 = (1 * (self.stats.semimajor_sigma.value ** 2 - sigma2_motion) + 2 * (self.stats.semiminor_sigma.value ** 2)) / 3 self.sigma = sqrt(sigma2) self.fwhm = self.sigma * FWHM_PER_SIGMA
def make_pill_mask(mask_shape_xy, xya, xyb, radius): """ Construct a mask array for MP in motion: unmask only those pixels within radius pixels of any point in line segment from xya to xyb. Convention: pixel True -> VALID (opposite of numpy). :param mask_shape_xy: (x,y) size of array to generate. [2-tuple of ints] :param xya: (xa, ya) pixel coordinates of start-motion point. [XY object, or 2-tuple of floats] :param xyb: (xb, yb) pixel coordinates of end-motion point. [XY object, or 2-tuple of floats] :param radius: radius of ends and half-width of center region. [float] :return: mask array, True -> MASKED out/invalid (numpy boolean convention), and indexed as (x,y) (indexing convention is x,y-image, not numpy). [np.ndarray of booleans] """ xya = xya if isinstance(xya, XY) else XY(xya[0], xya[1]) xyb = xyb if isinstance(xyb, XY) else XY(xyb[0], xyb[1]) if xya == xyb: return make_circular_mask(max(mask_shape_xy), xya, radius) # Make circle and rectangle objects: circle_a = Circle_in_2D(xya, radius) circle_b = Circle_in_2D(xyb, radius) dxy_ab = xyb - xya length_ab = dxy_ab.length dxy_a_corner1 = (radius / length_ab) * DXY(dxy_ab.dy, -dxy_ab.dx) # perpendicular to ab vector. dxy_a_corner2 = (radius / length_ab) * DXY(-dxy_ab.dy, dxy_ab.dx) # " xy_corner1 = xya + dxy_a_corner1 xy_corner2 = xya + dxy_a_corner2 xy_corner3 = xyb + dxy_a_corner2 rectangle = Rectangle_in_2D(xy_corner1, xy_corner2, xy_corner3) # Make mask, including edges so no gaps can appear at rectangle corners: circle_a_contains = circle_a.contains_points_unitgrid(0, mask_shape_xy[0] - 1, 0, mask_shape_xy[1] - 1, True) circle_b_contains = circle_b.contains_points_unitgrid(0, mask_shape_xy[0] - 1, 0, mask_shape_xy[1] - 1, True) rectangle_contains = rectangle.contains_points_unitgrid(0, mask_shape_xy[0] - 1, 0, mask_shape_xy[1] - 1, True) # Render each in numpy mask-boolean and index conventions: # circle_a_mask = np.transpose(np.logical_not(circle_a_contains)) # circle_b_mask = np.transpose(np.logical_not(circle_b_contains)) # rectangle_mask = np.transpose(np.logical_not(rectangle_contains)) circle_a_mask = np.logical_not(circle_a_contains) circle_b_mask = np.logical_not(circle_b_contains) rectangle_mask = np.logical_not(rectangle_contains) mask = np.logical_and(np.logical_and(circle_a_mask, circle_b_mask), rectangle_mask) # any_contains = np.logical_or(np.logical_or(circle_a_contains, circle_b_contains), rectangle_contains) # mask = np.transpose(np.logical_not(any_contains)) return mask
def test_constructor_masks_inside_image(self, get_image): hdr, im = get_image ap = image.MovingSourceAp(im, xy_start=(2354, 1505), xy_end=(2361, 1510), foreground_radius=9, gap=6, background_width=5) # Quality criteria: assert ap.is_valid is True assert ap.all_inside_image is True assert ap.all_outside_image is False assert ap.any_foreground_outside_image is False assert ap.mask_overlap_pixel_count == 0 # Values as input: assert (ap.foreground_radius, ap.gap, ap.background_width) == (9, 6, 5) assert np.array_equal(ap.image, im) assert ap.xy_start == XY(2354, 1505) assert ap.xy_end == XY(2361, 1510) # Shapes and pixel counts: assert ap.cutout.shape == ap.foreground_mask.shape == ap.background_mask.shape == ( 49, 51) # (y,x) assert ap.foreground_pixel_count == 408 assert ap.foreground_pixel_count == np.sum( ap.input_foreground_mask == False) assert ap.background_pixel_count == 633 assert ap.background_pixel_count == np.sum( ap.input_background_mask == False) # ADUs and fluxes: assert ap.background_level == pytest.approx(254, abs=1) assert ap.background_std == pytest.approx(15.1, abs=0.2) assert ap.foreground_max == 1817 assert ap.foreground_min == 224 assert ap.raw_flux == pytest.approx(210000, abs=100) assert ap.net_flux == pytest.approx(106368, abs=100) assert ap.flux_stddev(gain=1.57) == pytest.approx(366, abs=1) # Source flux position & shape: assert ap.xy_centroid[0] == pytest.approx(2357.56, abs=0.05) assert ap.xy_centroid[1] == pytest.approx(1507.14, abs=0.05) assert ap.sigma == pytest.approx(3.14, abs=0.05) assert ap.fwhm == pytest.approx(7.40, abs=0.05)
def make_new_object(self, new_xy_center): """ Make new object using new xy_center. Overrides parent-class method. Masks will be recreated by the constructor, using new xy_center. """ if not isinstance(new_xy_center, XY): new_xy_center = XY(new_xy_center[0], new_xy_center[1]) current_xy_center = self.xy_start + (self.xy_end - self.xy_start) / 2 dxy_shift = new_xy_center - current_xy_center new_xy_start = self.xy_start + dxy_shift new_xy_end = self.xy_end + dxy_shift return MovingSourceAp(self.image, new_xy_start, new_xy_end, self.foreground_radius, self.gap, self.background_width, self.source_id, self.obs_id)
def make_circular_mask(mask_size, xy_origin, radius): """ Construct a traditional mask array for small, stationary object, esp. for a star. Unmask only those pixels *within* radius pixels of a given point. Invert the mask separately to mask the interior. Numpy mask bookean convention: pixel True -> MASKED OUT. Numpy indexing convention: (y,x) :param mask_size: edge size of new mask array, which will be a square. [int] :param xy_origin: (x, y) pixel coordinates of circle origin, rel. to mask origin. [2-tuple of floats] :param radius: radius of ends and half-width of center region. [float] :return: mask array, True -> MASKED out/invalid (numpy boolean convention), and indexed as (y,x) (numpy indexing convention). [np.ndarray of booleans] """ xy_origin = xy_origin if isinstance(xy_origin, XY) else XY(xy_origin[0], xy_origin[1]) circle = Circle_in_2D(xy_origin=xy_origin, radius=radius) is_inside = circle.contains_points_unitgrid(0, mask_size - 1, 0, mask_size - 1, include_edges=True) mask = np.transpose(np.logical_not(is_inside)) # render in numpy mask-boolean and index conventions. return mask
def test_constructor_masks_inside_image(self, get_image): hdr, im = get_image ap = image.PointSourceAp(im, xy_center=(1476.3, 1243.7), foreground_radius=9, gap=6, background_width=5) # Quality criteria: assert ap.is_valid is True assert ap.all_inside_image is True assert ap.all_outside_image is False assert ap.any_foreground_outside_image is False assert ap.mask_overlap_pixel_count == 0 # Values as input: assert (ap.foreground_radius, ap.gap, ap.background_width) == (9, 6, 5) assert ap.annulus_inner_radius == ap.foreground_radius + ap.gap assert ap.annulus_outer_radius == ap.foreground_radius + ap.gap + ap.background_width assert np.array_equal(ap.image, im) assert ap.xy_center == XY(1476.3, 1243.7) assert ap.input_foreground_mask.shape == (44, 44) assert ap.input_background_mask.shape == ap.input_foreground_mask.shape # Shapes and pixel counts: assert ap.cutout.shape == ap.foreground_mask.shape == ap.background_mask.shape == ( 44, 44) assert ap.foreground_pixel_count == np.sum( ap.input_foreground_mask == False) == 255 assert ap.background_pixel_count == np.sum( ap.input_background_mask == False) == 549 # ADUs and fluxes: assert ap.background_level == pytest.approx(257, abs=1) assert ap.background_std == pytest.approx(13.84, abs=0.1) assert ap.foreground_max == 1065 assert ap.foreground_min == 234 assert ap.raw_flux == pytest.approx(96364, abs=10) assert ap.net_flux == pytest.approx(30829, abs=10) assert ap.flux_stddev(gain=1.57) == pytest.approx(247.95, abs=0.1) # Source flux position & shape: assert ap.xy_centroid[0] == pytest.approx(1476.23, abs=0.01) assert ap.xy_centroid[1] == pytest.approx(1243.39, abs=0.01) assert ap.sigma == pytest.approx(2.81, abs=0.1) assert ap.fwhm == pytest.approx(6.37, abs=0.1) assert ap.elongation == pytest.approx(1.086, abs=0.02)
def __init__(self, image, xy_center, foreground_radius, gap, background_width, source_id=None, obs_id=None): """ Main and probably sole constructor for this class. :param image: the parent image array [numpy ndarray]. :param xy_center: center pixel position in parent. This should be the best prior estimate of the light source's centroid at mid-exposure, as (x,y) (not as numpy [y, x] array). [XY object, 2-tuple, 2-list, or 2-array of floats] :param foreground_radius: radial size of foreground around point source, in pixels. [float] :param gap: width of gap, difference between radius of foreground and inside radius of background annulus, in pixels. [float] :param background_width: width of annulus, difference between inside and outside radii of background annulus, in pixels. :param source_id: string describing the source (e.g., comp star ID, MP number) that this aperture is intended to measure. A tag only, not used in calculations. [string] :param obs_id: observation ID string, will typically be unique among all observations (aperture objects) in one image. A tag only, not used in calculations. [string] """ xy_center = xy_center if isinstance(xy_center, XY) else XY(xy_center[0], xy_center[1]) self.foreground_radius = foreground_radius self.gap = gap self.background_width = background_width self.annulus_inner_radius = self.foreground_radius + self.gap self.annulus_outer_radius = self.annulus_inner_radius + self.background_width cutout_size = int(ceil(2 * self.annulus_outer_radius)) + 4 # generous, for safety. dxy_origin = DXY(int(round(xy_center.x - cutout_size / 2)), int(round(xy_center.y - cutout_size / 2))) xy_center_in_cutout = xy_center - dxy_origin foreground_mask = make_circular_mask(mask_size=cutout_size, xy_origin=tuple(xy_center_in_cutout), radius=self.foreground_radius) background_mask_center_disc = np.logical_not(make_circular_mask(cutout_size, tuple(xy_center_in_cutout), self.annulus_inner_radius)) background_mask_outer_disc = make_circular_mask(cutout_size, tuple(xy_center_in_cutout), self.annulus_outer_radius) background_mask = np.logical_or(background_mask_center_disc, background_mask_outer_disc) super().__init__(image, xy_center, dxy_origin, foreground_mask, background_mask, source_id, obs_id)
def __init__(self, image, xy_center, xy_offset, foreground_mask, background_mask=None, source_id='', obs_id=''): """ General constructor, from explicitly passed-in parent data array and 2 mask arrays. :param image: the parent image array [numpy ndarray; to pass in CCDData or numpy masked array, please see separate, specific constructors, below]. :param xy_center: center pixel position in parent. This should be the best prior estimate of the light source's centroid at mid-exposure, as (x,y) (not as numpy [y, x] array). [XY object, 2-tuple, 2-list, or 2-array of floats] :param xy_offset: lowest index of cutout (upper-left corner of image), that is, the offset of cutout's incides from parent image's indices. [XY object, 2-tuple, 2-list, or 2-array of floats] :param foreground_mask: mask array for pixels to be counted in flux, centroid, etc. Required boolean array. Numpy mask convention (True -> pixel masked out, unused). Mask shape defines shape of cutout to be used. [numpy ndarray of booleans] :param background_mask: mask array for pixels to be counted in background flux, centroid, etc. If None, will be set to inverse of foreground_mask. If zero [int or float], will be set to zero (that is, background is not used). Otherwise (normal case), must be boolean array in same shape as foreground_mask. Numpy mask convention (True -> pixel masked out, unused). :param source_id: optional string describing the source (e.g., comp star ID, MP number) that this aperture is intended to measure. [string] :param obs_id: optional observation ID string, will typically be unique among all observations (aperture objects) in one image. [string] """ # Save inputs: self.image = image self.xy_center = xy_center if isinstance(xy_center, XY) else XY(xy_center[0], xy_center[1]) xy_input_offset = xy_offset if isinstance(xy_offset, XY) else XY(xy_offset[0], xy_offset[1]) self.input_foreground_mask = foreground_mask self.input_background_mask = background_mask self.source_id = str(source_id) self.obs_id = str(obs_id) # Default values: self.is_valid = None self.all_inside_image = None self.all_outside_image = None self.any_foreground_outside_image = None self.mask_overlap_pixel_count = None # Ensure background mask is boolean array of same shape as foreground mask, whatever was input: if self.input_background_mask is None: self.background_mask = np.logical_not(self.input_foreground_mask) elif type(self.input_background_mask) in (int, float) and self.input_background_mask == 0: self.background_mask = np.full_like(self.input_foreground_mask, fill_value=True, dtype=np.bool) elif isinstance(self.input_background_mask, np.ndarray): if self.input_background_mask.shape != self.input_foreground_mask.shape: raise MaskError('Foreground and background masks differ in shape.') self.background_mask = self.input_background_mask else: raise MaskError('Background mask type ' + str(type(self.input_background_mask)) + ' is not valid.') # Determine whether any foreground mask pixels fall outside of parent image: x_low_raw = xy_input_offset.x y_low_raw = xy_input_offset.y x_high_raw = xy_input_offset.x + self.input_foreground_mask.shape[1] - 1 y_high_raw = xy_input_offset.y + self.input_foreground_mask.shape[0] - 1 x_low_final = max(x_low_raw, 0) y_low_final = max(y_low_raw, 0) x_high_final = min(x_high_raw, image.shape[1] - 1) y_high_final = min(y_high_raw, image.shape[0] - 1) self.all_inside_image = ((x_low_final, y_low_final, x_high_final, y_high_final) == (x_low_raw, y_low_raw, x_high_raw, y_high_raw)) # Make the cutout array. Crop masks if any pixels fall outside of parent image: # (NB: we must crop and not merely mask, to ensure that no indices fall outside parent image.) self.cutout = image[y_low_final: y_high_final + 1, x_low_final: x_high_final + 1] self.foreground_mask = self.input_foreground_mask[y_low_final-y_low_raw:y_high_final-y_low_raw + 1, x_low_final-x_low_raw:x_high_final-x_low_raw + 1] self.background_mask = self.background_mask[y_low_final-y_low_raw:y_high_final-y_low_raw + 1, x_low_final-x_low_raw:x_high_final-x_low_raw + 1] self.xy_offset = x_low_final, y_low_final # Invalidate aperture if entirely outside image (not an error, but aperture cannot be used): self.all_outside_image = (self.cutout.size <= 0) if self.all_outside_image: self.any_foreground_outside_image = True self.is_valid = False return # Compute pixels in both masks (should always be zero): self.mask_overlap_pixel_count = np.sum(np.logical_and((self.foreground_mask == False), (self.background_mask == False))) # Invalidate aperture if any foreground pixels were lost in cropping: self.foreground_pixel_count = np.sum(self.foreground_mask == False) input_foreground_pixel_count = np.sum(self.input_foreground_mask == False) self.any_foreground_outside_image = \ bool(self.foreground_pixel_count != input_foreground_pixel_count) if self.any_foreground_outside_image: self.is_valid = False return # Compute background mask statistics: self.background_pixel_count = np.sum(self.background_mask == False) if self.background_pixel_count >= 1: self.background_level, self.background_std = calc_background_value(self.cutout, self.background_mask) else: self.background_level, self.background_std = 0.0, 0.0 # Compute aperture statistics: foreground_ma = np.ma.array(data=self.cutout, mask=self.foreground_mask) self.foreground_max = np.ma.max(foreground_ma) self.foreground_min = np.ma.min(foreground_ma) self.raw_flux = np.ma.sum(foreground_ma) cutout_net_ma = np.ma.array(self.cutout - self.background_level, mask=self.foreground_mask) self.net_flux = np.sum(cutout_net_ma) # self.stats = data_properties(data=cutout_net_ma.data, mask=self.foreground_mask, # background=self.background_level) # scalar background fails photutils v.1.1.0, must have array matching shape of data & mask: self.stats = data_properties(data=cutout_net_ma.data, mask=self.foreground_mask, background=np.full_like(cutout_net_ma.data, fill_value=self.background_level)) self.xy_centroid = (self.stats.xcentroid + self.xy_offset[0], self.stats.ycentroid + self.xy_offset[1]) # Sigma and FWHM apply to spreading *other than* motion (i.e., approx. perpendicular to motion). self.sigma = self.stats.semimajor_sigma.value # self.fwhm = self.sigma * FWHM_PER_SIGMA # obsolete. self.fwhm = self.stats.fwhm.value # given by photutils v.1.1.0 self.elongation = self.stats.elongation.value self.is_valid = True