diff --git a/docs/source/dependencies.md b/docs/source/dependencies.md index 9378bfe00..5d0d74bbf 100644 --- a/docs/source/dependencies.md +++ b/docs/source/dependencies.md @@ -28,7 +28,6 @@ Scientific stack: | [mpi4py](https://mpi4py.readthedocs.io/en/stable/) | {cite:p}`dalcin:05,dalcin:08,dalcin:11` | | [reproject](https://reproject.readthedocs.io/) | | | [h5py](https://www.h5py.org/) | | -| [sf_tools](https://github.com/sfarrens/sf_tools) | | Data access & infrastructure (CANFAR / UNIONS): diff --git a/pyproject.toml b/pyproject.toml index 69c73f067..46e8a4b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ dependencies = [ "PyQt5", "pyqtgraph", "reproject>=0.19", - "sf_tools>=2.0.4", "skaha>=1.7", "sqlitedict>=2.0", "termcolor", diff --git a/src/shapepipe/modules/ngmix_package/ngmix.py b/src/shapepipe/modules/ngmix_package/ngmix.py index 07895be7e..5f34e4281 100644 --- a/src/shapepipe/modules/ngmix_package/ngmix.py +++ b/src/shapepipe/modules/ngmix_package/ngmix.py @@ -181,11 +181,10 @@ def __init__( self.flags = [] self.bkg_rms = [] self.jacobs = [] - # Per-epoch full WCS and the object's sky position, used only by the - # "wcs" centroid source (skipped for the default "hsm" path). - self.wcs = [] - self.ra = [] - self.dec = [] + # Per-epoch sub-pixel [row, col] coadd-centroid offset propagated from + # the stamp extractor, used only by the "wcs" centroid source (skipped + # for the default "hsm" path). + self.offsets = [] self.bkg_sub = bkg_sub self.megacam_flip = megacam_flip @@ -856,10 +855,14 @@ def prepare_postage_stamps(vignet, obj_id, i_tile, tile_cat): stamp.flags.append(flag_vign) stamp.bkg_rms.append(bkg_rms_vign_scaled) stamp.jacobs.append(jacob) - # For the "wcs" centroid source (see make_ngmix_observation). - stamp.wcs.append(epoch_wcs) - stamp.ra.append(tile_cat.ra[i_tile]) - stamp.dec.append(tile_cat.dec[i_tile]) + # Coadd-centroid offset the stamp extractor used, propagated on the + # galaxy vignette. Consumed only by the "wcs" centroid source (see + # make_ngmix_observation), which raises if it is missing; the "hsm" + # path ignores it, so read it leniently rather than coupling hsm to a + # field it never uses. + stamp.offsets.append( + vignet.gal_vign_cat[str(obj_id)][expccd_name].get('OFFSET') + ) return stamp @@ -1055,7 +1058,7 @@ def prepare_ngmix_weights(gal, weight, flag, rng, bkg_rms=None): def make_ngmix_observation( gal, weight, flag, psf, wcs, rng, - bkg_rms=None, centroid_source="hsm", wcs_full=None, ra=None, dec=None, + bkg_rms=None, centroid_source="hsm", offset=None, ): """Build an ngmix Observation for a single galaxy epoch. @@ -1067,11 +1070,15 @@ def make_ngmix_observation( measured from the stamp. Robust for **galaxies**: it follows the actual light and so the centroid prior (centered at the Jacobian origin) does not bias an object that is offset from the stamp center. - * ``"wcs"`` — place the origin at the object's catalog sky position, - projected through the WCS to a sub-pixel pixel offset from the stamp + * ``"wcs"`` — the **coadd centroid** (Axel, #767): place the origin at the + object's catalog sky position as a sub-pixel offset from the stamp center, with no shape measurement. Better for **stars**: their HSM moments are noisy, so trusting the astrometry is more stable than - re-measuring the centroid. + re-measuring the centroid. The offset is not recomputed here — it is the + value the stamp extractor already used to round the extraction pixel + (:func:`shapepipe.modules.vignetmaker_package.vignetmaker.get_stamps`), + propagated on the vignette. One projection, one rounding: the extraction + and the centroid prior cannot disagree near a rounding tie. Parameters ---------- @@ -1088,12 +1095,10 @@ def make_ngmix_observation( Per-pixel background RMS map. centroid_source : {"hsm", "wcs"}, optional How to place the galaxy Jacobian origin; the default is ``"hsm"``. - wcs_full : astropy.wcs.WCS, optional - Full exposure WCS for the object's CCD. Required for - ``centroid_source="wcs"`` (ignored for ``"hsm"``). - ra, dec : float, optional - Object sky position in degrees. Required for - ``centroid_source="wcs"`` (ignored for ``"hsm"``). + offset : array_like, optional + Sub-pixel ``[row, col]`` coadd-centroid offset propagated from the + stamp extractor. Required for ``centroid_source="wcs"`` (ignored for + ``"hsm"``). Returns ------- @@ -1137,16 +1142,20 @@ def make_ngmix_observation( except Exception: cen_row, cen_col = 0.0, 0.0 elif centroid_source == "wcs": - # Place the origin at the catalog sky position projected through the - # WCS — no shape measurement. Stars have noisy HSM moments, so trust - # the astrometry instead of re-measuring the centroid. - g_wcs = galsim.fitswcs.AstropyWCS(wcs=wcs_full) - world_pos = galsim.CelestialCoord( - ra * galsim.degrees, dec * galsim.degrees - ) - pos = g_wcs.toImage(world_pos) - cen_col = pos.x - np.round(pos.x).astype(int) - cen_row = pos.y - np.round(pos.y).astype(int) + # Coadd centroid: use the sub-pixel offset the stamp extractor already + # computed and rounded against (propagated on the vignette), rather + # than re-projecting the sky position through the WCS and re-rounding. + # This is the fix for #767 — one projection, one rounding, so a + # milli-pixel WCS disagreement can no longer flip a rounding tie and + # put the prior a whole pixel off. + if offset is None: + raise ValueError( + "centroid_source='wcs' requires the coadd-centroid offset " + + "propagated from the stamp extractor, but none was found on " + + "the vignette (were the stamps made before in-house " + + "extraction landed?)" + ) + cen_row, cen_col = float(offset[0]), float(offset[1]) else: raise ValueError( f"Unknown centroid_source '{centroid_source}'; expected" @@ -1386,9 +1395,7 @@ def do_ngmix_metacal(stamp, prior, flux_guess, rng, centroid_source="hsm"): rng, bkg_rms=bkg_rms, centroid_source=centroid_source, - wcs_full=stamp.wcs[n_e] if n_e < len(stamp.wcs) else None, - ra=stamp.ra[n_e] if n_e < len(stamp.ra) else None, - dec=stamp.dec[n_e] if n_e < len(stamp.dec) else None, + offset=stamp.offsets[n_e] if n_e < len(stamp.offsets) else None, ) gal_obs_list.append(gal_obs) diff --git a/src/shapepipe/modules/python_example_runner.py b/src/shapepipe/modules/python_example_runner.py index 39a98206c..62f8372c5 100644 --- a/src/shapepipe/modules/python_example_runner.py +++ b/src/shapepipe/modules/python_example_runner.py @@ -24,7 +24,6 @@ "pandas", "pysap", "scipy", - "sf_tools", "sqlitedict", ], run_method="parallel", diff --git a/src/shapepipe/modules/vignetmaker_package/vignetmaker.py b/src/shapepipe/modules/vignetmaker_package/vignetmaker.py index 74e26915b..ea0550e59 100644 --- a/src/shapepipe/modules/vignetmaker_package/vignetmaker.py +++ b/src/shapepipe/modules/vignetmaker_package/vignetmaker.py @@ -11,12 +11,80 @@ import numpy as np from astropy.wcs import WCS -from sf_tools.image.stamp import FetchStamps from sqlitedict import SqliteDict from shapepipe.pipeline import file_io +def get_stamps(image, positions, rad): + """Get Stamps. + + Extract postage stamps and record their sub-pixel centring. + + The image is zero-padded by ``rad`` on every side and a + ``(2 * rad + 1, 2 * rad + 1)`` stamp is sliced around the integer pixel + nearest each position (``numpy.round``). The stamp values are + bit-identical to the ``sf_tools.image.stamp.FetchStamps`` this replaces + for in-bounds positions; unlike ``FetchStamps``, an out-of-bounds centre + raises rather than being silently wrapped modulo the image shape. + + Parameters + ---------- + image : numpy.ndarray + 2D image array to extract from + positions : numpy.ndarray + ``(N, 2)`` array of 0-indexed ``[row, col]`` float positions + rad : int + Stamp radius; stamps are ``2 * rad + 1`` pixels on a side + + Returns + ------- + stamps : numpy.ndarray + ``(N, 2 * rad + 1, 2 * rad + 1)`` array of extracted stamps + int_pos : numpy.ndarray + ``(N, 2)`` integer array of the rounded ``[row, col]`` pixel each + stamp is centred on + offsets : numpy.ndarray + ``(N, 2)`` float array of the sub-pixel ``[row, col]`` offset + (``positions - int_pos``), in ``[-0.5, 0.5]``. This is the centroid + prior consumed downstream by the ngmix ``"wcs"`` (coadd) centroid + source, so the stamp and the prior share a single rounding and cannot + disagree. + + Raises + ------ + ValueError + If ``positions`` is not ``(N, 2)`` or any stamp centre rounds to a + pixel outside the image. + + """ + rad = int(rad) + positions = np.asarray(positions, dtype=float) + if positions.ndim != 2 or positions.shape[1] != 2: + raise ValueError("positions must have shape (N, 2)") + + shape = np.array(image.shape) + int_pos = np.round(positions).astype(int) + offsets = positions - int_pos + + out_of_bounds = np.any((int_pos < 0) | (int_pos >= shape), axis=1) + if np.any(out_of_bounds): + raise ValueError( + f"Stamp centre(s) fall outside the image (shape {tuple(shape)}): " + + f"{positions[out_of_bounds].tolist()}" + ) + + padded = np.pad(image, ((rad, rad), (rad, rad)), mode="constant") + stamps = np.array( + [ + padded[row : row + 2 * rad + 1, col : col + 2 * rad + 1] + for row, col in int_pos + ] + ) + + return stamps, int_pos, offsets + + class VignetMaker(object): """Vignet Maker. @@ -91,7 +159,10 @@ def process(self, image_path_list, rad, prefix): + "'SPHE' (spherical world coordinates))" ) - vign = self._get_stamp(image_path, pos - 1, rad) + # Single-epoch (tile) path: the coadd-centroid offset is not + # consumed here (only the multi-epoch path feeds the ngmix + # centroid prior), so the extraction bookkeeping is discarded. + vign, _, _ = self._get_stamp(image_path, pos - 1, rad) save_vignet( vign, @@ -186,8 +257,13 @@ def _get_stamp(self, img_path, pos, rad): Returns ------- - numpy.ndarray + stamps : numpy.ndarray Array containing the vignets + int_pos : numpy.ndarray + ``(N, 2)`` rounded ``[row, col]`` extraction pixels + offsets : numpy.ndarray + ``(N, 2)`` sub-pixel ``[row, col]`` offsets (see + :func:`get_stamps`) """ try: @@ -211,12 +287,7 @@ def _get_stamp(self, img_path, pos, rad): raise img_file.close() - fs = FetchStamps(img, int(rad)) - fs.get_pixels(np.round(pos).astype(int)) - - vign = fs.scan() - - return vign + return get_stamps(img, pos, int(rad)) def _get_stamp_me(self, image_dirs, image_pattern): """Get Stamp Multi-Epoch. @@ -266,6 +337,8 @@ def _get_stamp_me(self, image_dirs, image_pattern): array_vign = None array_id = None array_exp_name = None + array_offset = None + array_int_pos = None for ccd in ccd_list: @@ -298,13 +371,29 @@ def _get_stamp_me(self, image_dirs, image_pattern): ).T pos[:, [0, 1]] = pos[:, [1, 0]] - tmp_vign = self._get_stamp(img_path, pos - 1, self._rad) + tmp_vign, tmp_int_pos, tmp_offset = self._get_stamp( + img_path, pos - 1, self._rad + ) if array_vign is None: array_vign = np.copy(tmp_vign) else: array_vign = np.concatenate((array_vign, np.copy(tmp_vign))) + if array_offset is None: + array_offset = np.copy(tmp_offset) + else: + array_offset = np.concatenate( + (array_offset, np.copy(tmp_offset)) + ) + + if array_int_pos is None: + array_int_pos = np.copy(tmp_int_pos) + else: + array_int_pos = np.concatenate( + (array_int_pos, np.copy(tmp_int_pos)) + ) + if array_id is None: array_id = np.copy(obj_id) else: @@ -319,7 +408,15 @@ def _get_stamp_me(self, image_dirs, image_pattern): array_exp_name = np.concatenate((array_exp_name, exp_name_tmp)) if array_id is not None: - final_list.append([array_id, array_vign, array_exp_name]) + final_list.append( + [ + array_id, + array_vign, + array_exp_name, + array_offset, + array_int_pos, + ] + ) else: self._w_log.info( f"All CCDs skipped for exposure {exp_name} " @@ -344,6 +441,20 @@ def _get_stamp_me(self, image_dirs, image_pattern): output_dict[id_tmp][index]["VIGNET"] = final_list[j][1][ where_res[0] ] + # Sub-pixel [row, col] offset of the coadd centroid from + # the integer pixel this stamp was extracted around, plus + # that integer pixel itself. Both ride with the vignette + # (no sidecar file); OFFSET is consumed by the ngmix "wcs" + # (coadd) centroid source so the extraction and the + # centroid prior share one rounding (see #767), and + # INT_POS + OFFSET reconstruct the absolute exposure + # centroid for diagnostics. + output_dict[id_tmp][index]["OFFSET"] = final_list[j][3][ + where_res[0] + ] + output_dict[id_tmp][index]["INT_POS"] = final_list[j][4][ + where_res[0] + ] counter += 1 if counter == 0: diff --git a/src/shapepipe/modules/vignetmaker_runner.py b/src/shapepipe/modules/vignetmaker_runner.py index 2d9661faa..a31d818ae 100644 --- a/src/shapepipe/modules/vignetmaker_runner.py +++ b/src/shapepipe/modules/vignetmaker_runner.py @@ -18,7 +18,7 @@ input_module="sextractor_runner", file_pattern=["galaxy_selection", "image"], file_ext=[".fits", ".fits"], - depends=["numpy", "astropy", "sf_tools", "sqlitedict"], + depends=["numpy", "astropy", "sqlitedict"], ) def vignetmaker_runner( input_file_list, diff --git a/tests/module/test_ngmix.py b/tests/module/test_ngmix.py index 139c18d4c..0d96c9b16 100644 --- a/tests/module/test_ngmix.py +++ b/tests/module/test_ngmix.py @@ -5,6 +5,7 @@ from hypothesis import strategies as st import numpy as np import numpy.testing as npt +import pytest from sqlitedict import SqliteDict from shapepipe.modules.ngmix_package.ngmix import Ngmix @@ -719,3 +720,52 @@ def test_constant_stamp_fallback_yields_finite_zero_weights(): ) npt.assert_array_equal(weight_map, 0.0) + + +def _one_epoch_stamp(): + """One-epoch (gal, weight, flag, psf, galsim-jacobian) for centroid tests.""" + from shapepipe.testing.simulate import make_data + + gals, psfs, _, weights, flags, jacobs = make_data( + rng=np.random.RandomState(1), + shear=(0.0, 0.0), + noise=1e-5, + n_epochs=1, + img_size=51, + psf_shear=(0.0, 0.0), + ) + return gals[0], weights[0], flags[0], psfs[0], jacobs[0] + + +def test_wcs_centroid_uses_stored_offset(): + """``centroid_source='wcs'`` places the galaxy Jacobian origin at the stamp + center plus the propagated coadd-centroid offset — it does not re-derive + the centroid by re-projecting through the WCS (the #767 fix).""" + from shapepipe.modules.ngmix_package.ngmix import make_ngmix_observation + + gal, weight, flag, psf, jacob = _one_epoch_stamp() + offset = np.array([0.3, -0.2]) # [row, col] + + obs = make_ngmix_observation( + gal, weight, flag, psf, jacob, np.random.RandomState(7), + centroid_source="wcs", offset=offset, + ) + + row0, col0 = obs.jacobian.get_cen() + center = (gal.shape[0] - 1) / 2 + npt.assert_allclose( + [row0, col0], [center + offset[0], center + offset[1]] + ) + + +def test_wcs_centroid_requires_offset(): + """``centroid_source='wcs'`` with no propagated offset fails fast.""" + from shapepipe.modules.ngmix_package.ngmix import make_ngmix_observation + + gal, weight, flag, psf, jacob = _one_epoch_stamp() + + with pytest.raises(ValueError, match="requires the coadd-centroid offset"): + make_ngmix_observation( + gal, weight, flag, psf, jacob, np.random.RandomState(7), + centroid_source="wcs", offset=None, + ) diff --git a/tests/module/test_vignetmaker.py b/tests/module/test_vignetmaker.py index 949877185..10180fc16 100644 --- a/tests/module/test_vignetmaker.py +++ b/tests/module/test_vignetmaker.py @@ -6,9 +6,11 @@ from astropy.wcs import WCS import numpy as np import numpy.testing as npt +import pytest from shapepipe.modules.vignetmaker_package.vignetmaker import ( VignetMaker, + get_stamps, make_mask, ) @@ -83,13 +85,131 @@ def test_get_stamp_preserves_count_and_stamp_shape(tmp_path): maker = VignetMaker.__new__(VignetMaker) maker._w_log = SimpleNamespace(info=lambda message: None) - stamps = maker._get_stamp( + positions = np.array([[10.0, 10.0], [14.2, 15.8]]) + stamps, int_pos, offsets = maker._get_stamp( str(image_path), - np.array([[10.0, 10.0], [14.2, 15.8]]), + positions, rad=2, ) assert stamps.shape == (2, 5, 5) + npt.assert_array_equal(int_pos, np.round(positions).astype(int)) + npt.assert_allclose(offsets, positions - np.round(positions)) + + +def test_get_stamps_extracts_around_rounded_pixel(): + """In-bounds stamps equal a manual zero-padded slice; bookkeeping holds.""" + rng = np.random.default_rng(0) + image = rng.normal(size=(40, 40)).astype(np.float32) + rad = 3 + positions = np.array([[10.0, 12.0], [20.4, 5.6], [0.0, 39.0], [39.0, 0.0]]) + + stamps, int_pos, offsets = get_stamps(image, positions, rad) + + assert stamps.shape == (4, 2 * rad + 1, 2 * rad + 1) + npt.assert_array_equal(int_pos, np.round(positions).astype(int)) + npt.assert_allclose(offsets, positions - np.round(positions)) + + padded = np.pad(image, ((rad, rad), (rad, rad)), mode="constant") + for stamp, (row, col) in zip(stamps, int_pos): + npt.assert_array_equal( + stamp, padded[row : row + 2 * rad + 1, col : col + 2 * rad + 1] + ) + + +def test_get_stamps_zero_pads_edges(): + """A corner object is zero-padded on the out-of-frame side.""" + image = np.ones((10, 10)) + rad = 2 + + stamps, _, _ = get_stamps(image, np.array([[0.0, 0.0]]), rad) + + expected = np.ones((5, 5)) + expected[:2, :] = 0.0 # rows above the top edge + expected[:, :2] = 0.0 # cols left of the left edge + npt.assert_array_equal(stamps[0], expected) + + +def test_get_stamps_offset_is_subpixel(): + """Offset is the catalog float minus its rounded pixel, in [-0.5, 0.5].""" + image = np.zeros((20, 20)) + positions = np.array([[10.3, 10.0], [10.7, 9.4], [10.5, 10.5]]) + + _, int_pos, offsets = get_stamps(image, positions, 2) + + npt.assert_allclose(offsets, positions - int_pos) + assert np.all(np.abs(offsets) <= 0.5 + 1e-12) + + +@pytest.mark.parametrize( + "positions", + [ + np.array([[-1.0, 5.0]]), # above the top edge + np.array([[5.0, 10.0]]), # past the right edge (shape is 10) + np.array([[5.0, 5.0], [10.0, 5.0]]), # one good, one out + ], +) +def test_get_stamps_raises_out_of_bounds(positions): + """Unlike sf_tools' modulo wrap, an out-of-bounds centre raises.""" + image = np.zeros((10, 10)) + with pytest.raises(ValueError, match="outside the image"): + get_stamps(image, positions, 2) + + +def test_get_stamps_golden(): + """Pin the extraction behavior with hand-derived expected values. + + The bit-identity test against sf_tools below self-skips once sf_tools is + uninstalled (it is dropped as a dependency by this code), so this golden + test is what keeps the extraction pinned in CI: expected stamps are built + by arithmetic (``value = 10 * row + col``), independent of the pad/slice + implementation, and cover the two behaviors that matter — numpy's + round-half-to-even at a ``.5`` tie, and zero-padding past the image edge. + """ + image = np.arange(100, dtype=float).reshape(10, 10) # value = 10*r + c + rad = 2 + + # (4.5, 6.5) ties round half-to-even -> (4, 6); (3.5, 2.5) -> (4, 2). + positions = np.array([[4.5, 6.5], [3.5, 2.5], [0.4, 0.6]]) + stamps, int_pos, offsets = get_stamps(image, positions, rad) + + npt.assert_array_equal(int_pos, [[4, 6], [4, 2], [0, 1]]) + npt.assert_allclose(offsets, [[0.5, 0.5], [-0.5, 0.5], [0.4, -0.4]]) + + def expected(r0, c0): + return [ + [ + 10.0 * r + c if 0 <= r < 10 and 0 <= c < 10 else 0.0 + for c in range(c0 - rad, c0 + rad + 1) + ] + for r in range(r0 - rad, r0 + rad + 1) + ] + + for stamp, (r0, c0) in zip(stamps, int_pos): + npt.assert_array_equal(stamp, expected(r0, c0)) + + +@pytest.mark.parametrize("rad", [2, 3, 5]) +def test_get_stamps_matches_fetchstamps(rad): + """Bit-identical to sf_tools FetchStamps for in-bounds objects. + + The extraction is pure slicing of a zero-padded array, so it is + value-independent: random data with positions spanning the interior and + the padded edges exercises exactly what a real exposure would. Skipped + once sf_tools is no longer installed (it is being dropped as a dep). + """ + stamp_mod = pytest.importorskip("sf_tools.image.stamp") + rng = np.random.default_rng(42) + image = rng.normal(size=(60, 60)) + # In-bounds positions, including edges (padding) — but never out of + # bounds, where FetchStamps wraps and get_stamps deliberately raises. + positions = rng.uniform(0.0, 59.0, size=(50, 2)) + + stamps, _, _ = get_stamps(image, positions, rad) + + fetch = stamp_mod.FetchStamps(image, int(rad)) + fetch.get_pixels(np.round(positions).astype(int)) + npt.assert_array_equal(stamps, fetch.scan()) def test_make_mask_replaces_only_sentinel_pixels(tmp_path): diff --git a/uv.lock b/uv.lock index 26645f04b..51de88e5a 100644 --- a/uv.lock +++ b/uv.lock @@ -3408,17 +3408,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] -[[package]] -name = "sf-tools" -version = "2.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "future", marker = "sys_platform == 'linux'" }, - { name = "modopt", marker = "sys_platform == 'linux'" }, - { name = "numpy", marker = "sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/41/6281d12b863867fc7d7db0aeb55db7ec94fb8f35873b9940edeeb3c29d0f/sf_tools-2.0.4.tar.gz", hash = "sha256:607204b7369cb381c02ba95f49f13d2733877a625026fac340dcddcca6700d24", size = 10426, upload-time = "2019-08-22T08:41:46.961Z" } - [[package]] name = "shapepipe" version = "1.1.0" @@ -3444,7 +3433,6 @@ dependencies = [ { name = "python-dateutil", marker = "sys_platform == 'linux'" }, { name = "python-pysap", marker = "sys_platform == 'linux'" }, { name = "reproject", marker = "sys_platform == 'linux'" }, - { name = "sf-tools", marker = "sys_platform == 'linux'" }, { name = "skaha", marker = "sys_platform == 'linux'" }, { name = "sqlitedict", marker = "sys_platform == 'linux'" }, { name = "termcolor", marker = "sys_platform == 'linux'" }, @@ -3539,7 +3527,6 @@ requires-dist = [ { name = "python-dateutil" }, { name = "python-pysap", specifier = ">=0.3" }, { name = "reproject", specifier = ">=0.19" }, - { name = "sf-tools", specifier = ">=2.0.4" }, { name = "shapepipe", extras = ["doc", "jupyter", "lint", "release", "test", "fitsio"], marker = "extra == 'dev'" }, { name = "skaha", specifier = ">=1.7" }, { name = "snakemake", marker = "extra == 'jupyter'", specifier = ">=9.22.0" },