Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions src/astroimred/fitsmgmt/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ def _check_mismatch(row, keys, values):
summary_table = inputs.to_pandas()
fitslist = summary_table[table_filecol].to_list()
elif isinstance(inputs, pd.DataFrame):
summary_table = inputs
summary_table = inputs.copy()
fitslist = summary_table[table_filecol].to_list()
else:
# No need to sort here because the real "sort" will be done later in fits_summary
Expand Down Expand Up @@ -788,12 +788,13 @@ def _check_mismatch(row, keys, values):
if selecting:
for k, v in zip(type_key, type_val, strict=False):
if isinstance(v, str):
match_mask = summary_table[k].str.match(v)
summary_table = summary_table[match_mask]
fitslist = np.array(fitslist)[match_mask].tolist()
# NOTE: Is there a better way to do this?
with contextlib.suppress(ValueError):
summary_table.reset_index(inplace=True, drop=True)
match_mask = summary_table[k].astype("string").str.match(v, na=False)
summary_table = summary_table[match_mask].reset_index(drop=True)
fitslist = [
item
for item, keep in zip(fitslist, match_mask, strict=False)
if keep
]
else: # not used as regex
_type_key.append(k)
_type_val.append(v)
Expand Down
15 changes: 12 additions & 3 deletions src/astroimred/imutil/_util_fits.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ def extract_stack_metadata(
hdr0 = _parse_imc_data_header(items[0], extension=extension, parse_data=False)[1]
if hdr0 is None:
raise ValueError("Could not read header from the first input image.")
ndim = hdr0["NAXIS"]
ndim = items[0].data.ndim if isinstance(items[0], CCDData) else hdr0["NAXIS"]
# N x ndim. sizes[i, :] = images[i].shape
shapes = np.ones((ncombine, ndim), dtype=int)
raw_shapes = np.ones((ncombine, ndim), dtype=int)
Expand Down Expand Up @@ -527,7 +527,8 @@ def extract_stack_metadata(
if extract_snoise:
sns[i] = float(hdr[snoise])

if hdr["NAXIS"] != ndim:
item_ndim = item.data.ndim if isinstance(item, CCDData) else hdr["NAXIS"]
if item_ndim != ndim:
raise ValueError(
"All FITS files must have the identical ndim, "
+ "though they can have different sizes."
Expand All @@ -553,7 +554,11 @@ def extract_stack_metadata(
)

# NOTE: the indexing in python is [z, y, x] order!!
raw_shape = tuple(int(hdr[f"NAXIS{i}"]) for i in range(ndim, 0, -1))
raw_shape = (
item.data.shape
if isinstance(item, CCDData)
else tuple(int(hdr[f"NAXIS{j}"]) for j in range(ndim, 0, -1))
)
raw_shapes[i,] = raw_shape
shapes[i,] = _trimmed_shape(raw_shape, trimsec)
else:
Expand All @@ -571,6 +576,10 @@ def extract_stack_metadata(
)[0]
if data is None:
raise ValueError(f"Could not read data from input {i}.") from None
if data.ndim != ndim:
raise ValueError(
"All input images must have the identical ndim."
) from None
raw_shape = data.shape
else:
_, hdr = _parse_imc_data_header(
Expand Down
31 changes: 26 additions & 5 deletions src/astroimred/imutil/imstat.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import numpy as np
import reducers.lowlevel as rdl
from astro_ndslice import slicefy
from astro_ndslice import listify, slicefy
from astropy import units as u
from astropy.io import fits
from astropy.visualization import ZScaleInterval
Expand Down Expand Up @@ -200,17 +200,38 @@ def give_stats(
num_extrema : 2-tuple of int or `None`, optional
Number of low and high extreme values to report as ``(n_lo, n_hi)``. If
`None`, extrema calculation is skipped.

Notes
-----
Sections may have different sizes. Overlapping sections count their shared
pixels once per section. Masks and non-finite pixels are excluded.

Raises
------
ValueError
If no finite unmasked pixels remain in the selected sections.
"""
data, hdr = _data_header_from_array_or_path(item, extension=extension)
if mask is not None:
data = np.array(data, copy=True)
data[mask] = np.nan
mask = np.broadcast_to(np.asarray(mask, dtype=bool), data.shape)

if statsecs is not None:
statsecs = [statsecs] if isinstance(statsecs, str) else list(statsecs)
data = np.array([data[slicefy(sec)] for sec in statsecs])
if isinstance(statsecs, tuple) and all(isinstance(s, slice) for s in statsecs):
statsecs = [statsecs]
else:
statsecs = listify(statsecs)
samples = []
for sec in statsecs:
section = slicefy(sec, ndim=data.ndim)
sample = data[section]
samples.append(sample.ravel() if mask is None else sample[~mask[section]])
data = np.concatenate(samples) if samples else np.empty(0, dtype=data.dtype)
elif mask is not None:
data = data[~mask]

data = _finite_reducer_values(data)
if data.size == 0:
raise ValueError("No finite unmasked pixels remain in the selected sections.")

std, mean = rdl.std_mean_valid(data, ddof=1)
d_min, d_max = rdl.minmax_valid(data)
Expand Down
16 changes: 9 additions & 7 deletions src/astroimred/phot/aperture.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ def cutout_from_ap(

Parameters
----------
ap : `~astroapers.Aperture`
The aperture object.
ap : astroapers aperture object
The aperture object, such as `~astroapers.CircAp` or `~astroapers.EllipAp`.
ccd : `~astropy.nddata.CCDData` or `numpy.ndarray`
The CCD data.
method : str, optional
Expand All @@ -48,10 +48,11 @@ def cutout_from_ap(
Returns
-------
`~astropy.nddata.Cutout2D` or list of `~astropy.nddata.Cutout2D`
The cutout objects.
Cutouts trimmed to the image boundary for every method, with matching
data, coordinates, and WCS when supplied by `ccd`.
"""
data = ccd.data if isinstance(ccd, CCDData) else np.asarray(ccd)
positions = np.asarray(ap.positions, dtype=np.float64).reshape(-1, 2)
wcs = getattr(ccd, "wcs", None) if isinstance(ccd, CCDData) else None
if method not in {"bbox", "center", "exact"}:
raise ValueError(f"Unsupported aperture method: {method!r}")
boxes = ap.bboxes()
Expand All @@ -61,10 +62,11 @@ def cutout_from_ap(
elif method == "exact":
cutout_data = ap.weighted_cutout(data, fill_value=fill_value)
cuts = []
for idx, (pos, box) in enumerate(zip(positions, boxes, strict=True)):
cut = Cutout2D(data, position=pos, size=box.shape)
for idx, (pos, box) in enumerate(zip(ap.positions, boxes, strict=True)):
cut = Cutout2D(data, position=pos, size=box.shape, mode="trim", wcs=wcs)
if method != "bbox":
cut.data = cutout_data[idx]
sl_cut = box.overlap_slices(data.shape)[1]
cut.data = cutout_data[idx][sl_cut]
cuts.append(cut)
return cuts[0] if len(cuts) == 1 else cuts

Expand Down
20 changes: 13 additions & 7 deletions src/astroimred/phot/center.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging

import numpy as np
import reducers as rd
import reducers.lowlevel as rdl
from astropy.modeling import Fittable2DModel, Parameter
from astropy.modeling.fitting import LevMarLSQFitter
Expand Down Expand Up @@ -717,28 +718,33 @@ def _fit_2dgaussian(data, error=None, mask=None):
)

# assign zero weight to masked pixels
if data.mask is not np.ma.nomask:
weights[data.mask] = 0.0
mask = np.ma.getmaskarray(data)
weights[mask] = 0.0

mask = data.mask
data.fill_value = 0.0
data = data.filled()
data = data.filled(0.0)

# Subtract the minimum of the data as a rough background estimate.
# This will also make the data values positive, preventing issues with
# the moment estimation in data_properties. Moments from negative data
# values can yield undefined Gaussian parameters, e.g., x/y_stddev.
data_min, data_max = rd.minmax(data)

props = sep_extract(
data - np.min(data),
data - data_min,
thresh=0.0, # Use all data points
mask=mask,
filter_kernel=None, # No convolution
deblend_cont=1.0, # No deblending
clean=False, # No cleaning
)[0]

if len(props) == 0:
raise ValueError(
"No source detected in the cutout data to initialize 2D Gaussian fit."
)

init_const = 0.0 # subtracted data minimum above
init_amplitude = np.ptp(data)
init_amplitude = data_max - data_min
g_init = GaussianConst2D(
constant=init_const,
amplitude=init_amplitude,
Expand Down
23 changes: 17 additions & 6 deletions src/astroimred/phot/radprof.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import astroapers.kernels as aapk
import numpy as np
import pandas as pd
import reducers as rd
from astropy.nddata import CCDData

from .background import sky_fit
from .center import circular_bbox_cut
from .center import circular_bbox_cut, circular_slice

__all__ = [
"moffat_r",
Expand Down Expand Up @@ -367,6 +368,9 @@ def radprof_pix(img, pos, mask=None, rmax=10, sort_dist=False, fitfunc=None, ref
The image to be profiled.
pos : array_like
The xy coordinates of the center of the object (0-indexing).
mask : array-like or bool, optional
Pixels to exclude. May match the full image or broadcast to the local
cutout. Pass ``mask=ccd.mask`` explicitly to use a CCDData mask.
rmax : int, optional
The maximum radius to be profiled.
Default is ``10``.
Expand All @@ -392,7 +396,13 @@ def radprof_pix(img, pos, mask=None, rmax=10, sort_dist=False, fitfunc=None, ref
raise TypeError(f"img must be a CCDData or ndarray (now {type(img) = })")

cut, _, _, dists = circular_bbox_cut(img, pos, radius=rmax, return_dists=True)
mask = (dists > rmax) if mask is None else ((dists > rmax) | mask)
if mask is not None:
mask = np.asarray(mask, dtype=bool)
if mask.shape == img.shape:
mask = mask[circular_slice(img.shape, pos, rmax)]
mask = (dists > rmax) | np.broadcast_to(mask, cut.shape)
else:
mask = dists > rmax

# cut = Cutout2D(img, pos, 2*rmax + 1).data
# pos_cut = cut.
Expand All @@ -411,21 +421,22 @@ def radprof_pix(img, pos, mask=None, rmax=10, sort_dist=False, fitfunc=None, ref

_r = dists[~mask]
_i = cut[~mask]
_imin, _imax = _i.min(), _i.max()
_imin, _imax = rd.minmax(_i)
_rmin = rd.min(_r)
if fitfunc == "gauss":
fitter = gauss_r
p0 = [_i[_r == _r.min()][0] - _imin, max(1, rmax / 6), _imin]
p0 = [_i[_r == _rmin][0] - _imin, max(1, rmax / 6), _imin]
bounds = np.array([[0, 0, _imin - 1], [np.inf, rmax / 2, _imax + 1]])
# assuming the user gave ~ (2-3)x FWHM, and we want sigma ~ 0.4xFWHM
elif fitfunc == "moffat":
fitter = moffat_r
p0 = [_i[_r == _r.min()][0] - _imin, 1, 2.5, _imin]
p0 = [_i[_r == _rmin][0] - _imin, 1, 2.5, _imin]
bounds = np.array(
[[0, 0.1, 1.0, _imin - 1], [np.inf, rmax, np.inf, _imax + 1]]
)
elif fitfunc == "bivt":
fitter = bivt_r
p0 = [_i[_r == _r.min()][0] - _imin, 1, max(1, rmax / 6), _imin]
p0 = [_i[_r == _rmin][0] - _imin, 1, max(1, rmax / 6), _imin]
bounds = np.array(
[[0, -2, 1.0e-10, _imin - 1], [np.inf, 15, 2 * rmax, _imax + 1]]
)
Expand Down
47 changes: 47 additions & 0 deletions tests/imutil/test_imstat.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,53 @@ def test_stats_mask_does_not_mutate_input(self):

np.testing.assert_array_equal(arr, original)

def test_integer_mask_and_unequal_sections(self) -> None:
data = np.arange(12, dtype=np.int16).reshape(3, 4)
original = data.copy()
mask = np.zeros(data.shape, dtype=bool)
mask[0, 1] = True
result = imstat.give_stats(data, mask=mask, statsecs=["[1:2,1:1]", "[3:4,2:3]"])
# Selected pixels: 0, 6, 7, 10, 11 (pixel 1 is masked).
assert result["num"] == 5
assert result["avg"] == pytest.approx(6.8)
assert result["med"] == 7
assert result["std"] == pytest.approx(np.sqrt(18.7))
np.testing.assert_array_equal(data, original)
assert mask[0, 1]
assert np.count_nonzero(mask) == 1

def test_unequal_sections(self) -> None:
result = imstat.give_stats(
np.arange(12.0).reshape(3, 4),
statsecs=["[1:2,1:1]", "[3:4,2:3]"],
)
assert result["num"] == 6
assert result["avg"] == pytest.approx(35 / 6)

@pytest.mark.parametrize("section", [slice(1, 3), (slice(1, 3), slice(0, 2))])
def test_single_slice_section(self, section: object) -> None:
data = np.arange(12.0).reshape(3, 4)
expected = (
data[section, section] if isinstance(section, slice) else data[section]
)
result = imstat.give_stats(data, statsecs=section)
assert result["num"] == expected.size
assert result["avg"] == pytest.approx(np.mean(expected))

@pytest.mark.parametrize(
"data, kwargs",
[
(np.array([], dtype=float), {}),
(np.array([np.nan, np.inf]), {}),
(np.array([1, 2]), {"mask": True}),
(np.ones((2, 2)), {"statsecs": []}),
(np.ones((2, 2)), {"statsecs": "[5:6,5:6]"}),
],
)
def test_no_finite_selected_pixels(self, data: np.ndarray, kwargs: dict) -> None:
with pytest.raises(ValueError, match="No finite unmasked pixels"):
imstat.give_stats(data, **kwargs)

def test_stats_path_input(self, temp_fits_file):
"""Test statistics on a path-like FITS input."""
result = imstat.give_stats(temp_fits_file)
Expand Down
55 changes: 55 additions & 0 deletions tests/phot/test_aperture.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,58 @@ def test_cutout_from_ap_rejects_unknown_method():

with pytest.raises(ValueError):
cutout_from_ap(ap, data, method="not-a-method")


def test_cutout_from_ap_edge_coordinates_and_shape():
"""Cutouts crossing image boundaries must maintain trimmed shape and native coordinates."""
from astropy.wcs import WCS

wcs = WCS(naxis=2)
wcs.wcs.crpix = [50, 50]
wcs.wcs.cdelt = [0.1, 0.1]
wcs.wcs.crval = [10.0, 20.0]
wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]

data = CCDData(np.ones((100, 100)), wcs=wcs, unit="adu")
ap = aap.EllipAp((5.3, 5.7), a=10.2, b=4.6, theta=0.3)
pos = ap.positions[0]
box = ap.bboxes()[0]
sl_img, _ = box.overlap_slices(data.shape)
expected_shape = (
sl_img[0].stop - sl_img[0].start,
sl_img[1].stop - sl_img[1].start,
)

for method in ("bbox", "center", "exact"):
cut = cutout_from_ap(ap, data, method=method, fill_value=np.nan)
assert cut.shape == expected_shape
assert cut.data.shape == expected_shape
cut_pos = cut.to_cutout_position(pos)
assert_allclose(cut.input_position_original, pos)
assert_allclose(cut.input_position_cutout, cut_pos)
orig_pos = cut.to_original_position(cut_pos)
assert_allclose(orig_pos, pos, atol=1e-12)
assert_allclose(cut_pos, (pos[0] - sl_img[1].start, pos[1] - sl_img[0].start))
assert_allclose(
cut.wcs.pixel_to_world_values(*cut_pos),
wcs.pixel_to_world_values(*pos),
rtol=0,
atol=1e-10,
)
if method == "bbox":
assert_allclose(cut.data, data.data[sl_img])
else:
weights = ap.weights_center() if method == "center" else ap.weights_exact()
local_weights = weights[0][box.overlap_slices(data.shape)[1]]
np.testing.assert_allclose(
cut.data[local_weights > 0], local_weights[local_weights > 0]
)


def test_cutout_from_ap_integer_preserves_dtype():
"""Integer images with bbox method should preserve their integer dtype without padding error."""
int_data = np.arange(100, dtype=np.int32).reshape(10, 10)
ap = aap.CircAp((0.5, 0.5), r=3)
cut = cutout_from_ap(ap, int_data, method="bbox")
assert cut.data.dtype == np.int32
assert cut.shape == cut.data.shape
Loading