def test_mean(testdata, testvar, testmask): out_data, out_mask, out_var = NDStacker.mean(testdata) assert_allclose(out_data, 2) assert_allclose(out_var, 0.5) assert out_mask is None out_data, out_mask, out_var = NDStacker.mean(testdata, variance=testvar) assert_allclose(out_data, 2) assert_allclose(out_var, 0.3) out_data, out_mask, out_var = NDStacker.mean(testdata, mask=testmask) assert_allclose(out_data, [2., 3.]) assert_array_almost_equal(out_var, [0.5, 0.33], decimal=2) assert_allclose(out_mask, 0) out_data, out_mask, out_var = NDStacker.mean(testdata, mask=testmask, variance=testvar) assert_allclose(out_data, [2., 3.]) assert_array_almost_equal(out_var, [0.3, 0.66], decimal=2) assert_allclose(out_mask, 0)
def addOIWFSToDQ(self, adinputs=None, **params): """ Flags pixels affected by the On-Instrument Wavefront Sensor (OIWFS) on a GMOS image. It uses the header information to determine the location of the guide star, and basically "flood-fills" low-value pixels around it to give a first estimate. This map is then grown pixel-by-pixel until the values of the new pixels it covers stop increasing (indicating it's got to the sky level). Extensions to the right of the one with the guide star are handled by taking a starting point near the left-hand edge of the extension, level with the location at which the probe met the right-hand edge of the previous extension. This code assumes that data_section extends over all rows. It is, of course, very GMOS-specific. Parameters ---------- adinputs : list of :class:`~gemini_instruments.gmos.AstroDataGmos` Science data that contains the shadow of the OIWFS. contrast : float (range 0-1) Initial fractional decrease from sky level to minimum brightness where the OIWFS "edge" is defined. convergence : float Amount within which successive sky level measurements have to agree during dilation phase for this phase to finish. Returns ------- list of :class:`~gemini_instruments.gmos.AstroDataGmos` Data with updated `.DQ` plane considering the shadow of the OIWFS. """ log = self.log log.debug(gt.log_message("primitive", self.myself(), "starting")) border = 5 # Pixels in from edge where sky level is reliable boxsize = 5 contrast = params["contrast"] convergence = params["convergence"] for ad in adinputs: wfs = ad.wavefront_sensor() if wfs is None or 'OIWFS' not in wfs: log.fullinfo('OIWFS not used for image {}.'.format( ad.filename)) continue oira = ad.phu.get('OIARA') oidec = ad.phu.get('OIADEC') if oira is None or oidec is None: log.warning('Cannot determine location of OI probe for {}.' 'Continuing.'.format(ad.filename)) continue # DQ planes must exist so the unilluminated region is flagged if np.any([ext.mask is None for ext in ad]): log.warning('No DQ plane for {}. Continuing.'.format( ad.filename)) continue # OIWFS comes in from the right, so we need to have the extensions # sorted in order from left to right ampsorder = list( np.argsort([detsec.x1 for detsec in ad.detector_section()])) datasec_list = ad.data_section() gs_index = -1 for index in ampsorder: ext = ad[index] wcs = WCS(ext.hdr) x, y = wcs.all_world2pix([[oira, oidec]], 0)[0] if x < datasec_list[index].x2 + 0.5: gs_index = index log.fullinfo('Guide star location found at ({:.2f},{:.2f})' ' on EXTVER {}'.format( x, y, ext.hdr['EXTVER'])) break if gs_index == -1: log.warning( 'Could not find OI probe location on any extensions.') continue # The OIWFS extends to the left of the actual star location, which # might have it vignetting a part of an earlier extension. Also, it # may be in a chip gap, which has the same effect amp_index = ampsorder.index(gs_index) if x < 50: amp_index -= 1 x = (datasec_list[ampsorder[amp_index]].x2 - datasec_list[ampsorder[amp_index]].x1 - border) else: x -= datasec_list[ampsorder[amp_index]].x1 dilator = ndimage.morphology.generate_binary_structure(2, 1) for index in ampsorder[amp_index:]: datasec = datasec_list[index] sky, skysig, _ = gt.measure_bg_from_image(ad[index]) # To avoid hassle with whether the overscan region is present # or not and how adjacent extensions relate to each other, # just deal with the data sections data_region = ad[index].data[:, datasec.x1:datasec.x2] mask_region = ad[index].mask[:, datasec.x1:datasec.x2] x1 = max(int(x - boxsize), border) x2 = max(min(int(x + boxsize), datasec.x2 - datasec.x1), x1 + border) # Try to find the minimum closest to our estimate of the # probe location, by downhill method on a spline fit (to # smooth out the noise) data, mask, var = NDStacker.mean(ad[index].data[:, x1:x2].T, mask=ad[index].mask[:, x1:x2].T) good_rows = np.logical_and(mask == DQ.good, var > 0) if np.sum(good_rows) == 0: log.warning("No good rows in {} extension {}".format( ad.filename, index)) continue rows = np.arange(datasec.y2 - datasec.y1) spline = UnivariateSpline(rows[good_rows], data[good_rows], w=1. / np.sqrt(var[good_rows])) newy = int( optimize.minimize(spline, y, method='CG').x[0] + 0.5) y1 = max(int(newy - boxsize), 0) y2 = max(min(int(newy + boxsize), len(rows)), y1 + border) wfs_sky = np.median(data_region[y1:y2, x1:x2]) if wfs_sky > sky - convergence: log.warning('Cannot distinguish probe region from sky for ' '{}'.format(ad.filename)) break # Flood-fill region around guide-star with all pixels fainter # than this boundary value boundary = sky - contrast * (sky - wfs_sky) regions, nregions = ndimage.measurements.label( np.logical_and(data_region < boundary, mask_region == 0)) wfs_region = regions[newy, int(x + 0.5)] blocked = ndimage.morphology.binary_fill_holes( np.where(regions == wfs_region, True, False)) this_mean_sky = wfs_sky condition_met = False while not condition_met: last_mean_sky = this_mean_sky new_blocked = ndimage.morphology.binary_dilation( blocked, structure=dilator) this_mean_sky = np.median(data_region[new_blocked ^ blocked]) blocked = new_blocked if index <= gs_index or ad[index].array_section().x1 == 0: # Stop when convergence is reached on either the first # extension looked at, or the leftmost CCD3 extension condition_met = (this_mean_sky - last_mean_sky < convergence) else: # Dilate until WFS width at left of image equals width at # right of previous extension image width = np.sum(blocked[:, 0]) # Note: this will not be called before y_width is defined condition_met = (y_width - width < 2) or index > 9 # noqa # Flag DQ pixels as unilluminated only if not flagged # (to avoid problems with the edge extensions and/or saturation) datasec_mask = ad[index].mask[:, datasec.x1:datasec.x2] datasec_mask |= np.where( blocked, np.where(datasec_mask > 0, DQ.good, DQ.unilluminated), DQ.good) # Set up for next extension. If flood-fill hasn't reached # right-hand edge of detector, stop. column = blocked[:, -1] y_width = np.sum(column) if y_width == 0: break y = np.mean(np.arange(datasec.y1, datasec.y2)[column]) x = border ad.update_filename(suffix=params["suffix"], strip=True) return adinputs
def trace_lines(ext, axis, start=None, initial=None, width=5, nsum=10, step=1, initial_tolerance=1.0, max_shift=0.05, max_missed=10, func=NDStacker.mean, viewer=None): """ This function traces features along one axis of a two-dimensional image. Initial peak locations are provided and then these are matched to peaks found a small distance away along the direction of tracing. In terms of its use to map the distortion from a 2D spectral image of an arc lamp, these lists of coordinates can then be used to determine a distortion map that will remove any curvature of lines of constant wavelength. For a horizontally-dispersed spectrum like GMOS, the reference y-coords will match the input y-coords, while the reference x-coords will all be equal to the initial x-coords of the peaks. Parameters ---------- ext : single-sliced AD object The extension within which to trace features. axis : int (0 or 1) Axis along which to trace (0=y-direction, 1=x-direction). start : int/None Row/column to start trace (None => middle). initial : sequence Coordinates of peaks. width : int Width of centroid box in pixels. nsum : int Number of rows/columns to combine at each step. step : int Step size along axis in pixels. initial_tolerance : float Maximum perpendicular shift (in pixels) between provided location and first calculation of peak. max_shift: float Maximum perpendicular shift (in pixels) from pixel to pixel. max_missed: int Maximum number of interactions without finding line before line is considered lost forever. func: callable function to use when collapsing to 1D. This takes the data, mask, and variance as arguments. viewer: imexam viewer or None Viewer to draw lines on. Returns ------- refcoords, incoords: 2xN arrays (x-first) of coordinates """ log = logutils.get_logger(__name__) # We really don't care about non-linear/saturated pixels bad_bits = 65535 ^ (DQ.non_linear | DQ.saturated) halfwidth = int(0.5 * width) # Make life easier for the poor coder by transposing data if needed, # so that we're always tracing along columns if axis == 0: ext_data = ext.data ext_mask = None if ext.mask is None else ext.mask & bad_bits direction = "row" else: ext_data = ext.data.T ext_mask = None if ext.mask is None else ext.mask.T & bad_bits direction = "column" if start is None: start = int(0.5 * ext_data.shape[0]) log.stdinfo("Starting trace at {} {}".format(direction, start)) if initial is None: y1 = int(start - 0.5 * nsum + 0.5) data, mask, var = NDStacker.mean(ext_data[y1:y1 + nsum], mask=None if ext_mask is None else ext_mask[y1:y1 + nsum], variance=None) fwidth = estimate_peak_width(data.copy(), 10) widths = 0.42466 * fwidth * np.arange(0.8, 1.21, 0.05) # TODO! initial, _ = find_peaks(data, widths, mask=mask, variance=var, min_snr=5) print("Feature width", fwidth, "nlines", len(initial)) coord_lists = [[] for peak in initial] for direction in (-1, 1): ypos = start last_coords = [[ypos, peak] for peak in initial] while True: y1 = int(ypos - 0.5 * nsum + 0.5) data, mask, var = func(ext_data[y1:y1 + nsum], mask=None if ext_mask is None else ext_mask[y1:y1 + nsum], variance=None) # Variance could plausibly be zero var = np.where(var <= 0, np.inf, var) clipped_data = np.where(data / np.sqrt(var) > 0.5, data, 0) last_peaks = [c[1] for c in last_coords if not np.isnan(c[1])] peaks = pinpoint_peaks(clipped_data, mask, last_peaks) # if ypos == start: # print("Found {} peaks".format(len(peaks))) # print(peaks) for i, (last_row, old_peak) in enumerate(last_coords): if np.isnan(old_peak): continue # If we found no peaks at all, then continue through # the loop but nothing will match if peaks: j = np.argmin(abs(np.array(peaks) - old_peak)) new_peak = peaks[j] else: new_peak = np.inf # Is this close enough to the existing peak? tolerance = (initial_tolerance if ypos == start else max_shift * abs(ypos - last_row)) if (abs(new_peak - old_peak) > tolerance): # If it's gone for good, set the coord to NaN to avoid it # picking up a different line if there's significant tilt if abs(ypos - last_row) > max_missed * step: last_coords[i][1] = np.nan continue # Too close to the edge? if (new_peak < halfwidth or new_peak > ext_data.shape[1] - 0.5 * halfwidth): last_coords[i][1] = np.nan continue new_coord = [ypos, new_peak] if viewer: kwargs = dict(zip(('y1', 'x1'), last_coords[i] if axis == 0 else reversed(last_coords[i]))) kwargs.update(dict(zip(('y2', 'x2'), new_coord if axis == 0 else reversed(new_coord)))) viewer.line(origin=0, **kwargs) if not (ypos == start and direction > 1): coord_lists[i].append(new_coord) last_coords[i] = new_coord.copy() ypos += direction * step # Reached the bottom or top? if ypos < 0.5 * nsum or ypos > ext_data.shape[0] - 0.5 * nsum: break # Lost all lines! if all(np.isnan(c[1]) for c in last_coords): break # List of traced peak positions in_coords = np.array([c for coo in coord_lists for c in coo]).T # List of "reference" positions (i.e., the coordinate perpendicular to # the line remains constant at its initial value ref_coords = np.array([(ypos, ref) for coo, ref in zip(coord_lists, initial) for (ypos, xpos) in coo]).T # Return the coordinate lists, in the form (x-coords, y-coords), # regardless of the dispersion axis return (ref_coords, in_coords) if axis == 1 else (ref_coords[::-1], in_coords[::-1])