stdpipe 0.2.5__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {stdpipe-0.2.5/stdpipe.egg-info → stdpipe-0.4.0}/PKG-INFO +21 -8
  2. {stdpipe-0.2.5 → stdpipe-0.4.0}/README.md +11 -3
  3. {stdpipe-0.2.5 → stdpipe-0.4.0}/pyproject.toml +40 -7
  4. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe/__init__.py +1 -1
  5. stdpipe-0.4.0/stdpipe/artefacts.py +370 -0
  6. stdpipe-0.4.0/stdpipe/astrometry.py +2001 -0
  7. stdpipe-0.4.0/stdpipe/astrometry_quad.py +1643 -0
  8. stdpipe-0.4.0/stdpipe/astrometry_wcs.py +1141 -0
  9. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe/catalogs.py +442 -248
  10. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe/cutouts.py +176 -117
  11. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe/db.py +20 -9
  12. stdpipe-0.4.0/stdpipe/fringe_removal.py +1086 -0
  13. stdpipe-0.4.0/stdpipe/lcs.py +440 -0
  14. stdpipe-0.4.0/stdpipe/photometry.py +2340 -0
  15. stdpipe-0.4.0/stdpipe/photometry_background.py +998 -0
  16. stdpipe-0.4.0/stdpipe/photometry_iraf.py +913 -0
  17. stdpipe-0.4.0/stdpipe/photometry_measure.py +2268 -0
  18. stdpipe-0.4.0/stdpipe/photometry_model.py +931 -0
  19. stdpipe-0.4.0/stdpipe/photometry_psf.py +1124 -0
  20. stdpipe-0.4.0/stdpipe/photometry_quality.py +185 -0
  21. stdpipe-0.4.0/stdpipe/pipeline.py +1303 -0
  22. stdpipe-0.4.0/stdpipe/plots.py +1582 -0
  23. stdpipe-0.4.0/stdpipe/psf.py +1542 -0
  24. stdpipe-0.4.0/stdpipe/realbogus.py +971 -0
  25. stdpipe-0.4.0/stdpipe/realbogus_features.py +1657 -0
  26. stdpipe-0.4.0/stdpipe/reproject.py +943 -0
  27. stdpipe-0.4.0/stdpipe/resolve.py +267 -0
  28. stdpipe-0.4.0/stdpipe/sfft.py +1077 -0
  29. stdpipe-0.4.0/stdpipe/simulation.py +2131 -0
  30. stdpipe-0.4.0/stdpipe/smoothing.py +488 -0
  31. stdpipe-0.4.0/stdpipe/subtraction.py +1324 -0
  32. stdpipe-0.4.0/stdpipe/templates.py +929 -0
  33. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe/utils.py +33 -42
  34. {stdpipe-0.2.5 → stdpipe-0.4.0/stdpipe.egg-info}/PKG-INFO +21 -8
  35. stdpipe-0.4.0/stdpipe.egg-info/SOURCES.txt +77 -0
  36. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe.egg-info/requires.txt +11 -4
  37. stdpipe-0.4.0/tests/test_artefacts.py +160 -0
  38. stdpipe-0.4.0/tests/test_astrometry.py +371 -0
  39. stdpipe-0.4.0/tests/test_astrometry_quad.py +1881 -0
  40. stdpipe-0.4.0/tests/test_astrometry_wcs.py +453 -0
  41. stdpipe-0.4.0/tests/test_background_estimation.py +700 -0
  42. stdpipe-0.4.0/tests/test_background_fringes.py +547 -0
  43. stdpipe-0.4.0/tests/test_background_gradients.py +633 -0
  44. stdpipe-0.4.0/tests/test_background_nongrid.py +1184 -0
  45. stdpipe-0.4.0/tests/test_background_polynomial.py +216 -0
  46. stdpipe-0.4.0/tests/test_catalogs.py +373 -0
  47. stdpipe-0.4.0/tests/test_fringe_removal.py +334 -0
  48. stdpipe-0.4.0/tests/test_fringe_removal_fourier.py +595 -0
  49. stdpipe-0.4.0/tests/test_lcs.py +281 -0
  50. stdpipe-0.4.0/tests/test_moffat_photometry_limits.py +1617 -0
  51. stdpipe-0.4.0/tests/test_photometry.py +1627 -0
  52. stdpipe-0.4.0/tests/test_photometry_biases.py +729 -0
  53. stdpipe-0.4.0/tests/test_photometry_iraf.py +752 -0
  54. stdpipe-0.4.0/tests/test_photometry_measure.py +2851 -0
  55. stdpipe-0.4.0/tests/test_photometry_model.py +291 -0
  56. stdpipe-0.4.0/tests/test_photometry_psf.py +1497 -0
  57. stdpipe-0.4.0/tests/test_photometry_quality.py +244 -0
  58. stdpipe-0.4.0/tests/test_pipeline.py +1967 -0
  59. stdpipe-0.4.0/tests/test_plots.py +231 -0
  60. stdpipe-0.4.0/tests/test_psf.py +196 -0
  61. stdpipe-0.4.0/tests/test_realbogus.py +717 -0
  62. stdpipe-0.4.0/tests/test_realbogus_features.py +700 -0
  63. stdpipe-0.4.0/tests/test_reproject.py +703 -0
  64. stdpipe-0.4.0/tests/test_resolve.py +563 -0
  65. stdpipe-0.4.0/tests/test_sep_psf_bias.py +450 -0
  66. stdpipe-0.4.0/tests/test_sfft.py +1134 -0
  67. stdpipe-0.4.0/tests/test_simulation.py +1062 -0
  68. stdpipe-0.4.0/tests/test_smoothing.py +209 -0
  69. stdpipe-0.4.0/tests/test_templates.py +734 -0
  70. stdpipe-0.2.5/.gitignore +0 -85
  71. stdpipe-0.2.5/stdpipe/astrometry.py +0 -1084
  72. stdpipe-0.2.5/stdpipe/data/legacysurvey.py +0 -17
  73. stdpipe-0.2.5/stdpipe/data/ps1skycells.txt.orig +0 -199707
  74. stdpipe-0.2.5/stdpipe/lcs.py +0 -180
  75. stdpipe-0.2.5/stdpipe/photometry.py +0 -1363
  76. stdpipe-0.2.5/stdpipe/pipeline.py +0 -1090
  77. stdpipe-0.2.5/stdpipe/plots.py +0 -773
  78. stdpipe-0.2.5/stdpipe/psf.py +0 -459
  79. stdpipe-0.2.5/stdpipe/resolve.py +0 -142
  80. stdpipe-0.2.5/stdpipe/subtraction.py +0 -866
  81. stdpipe-0.2.5/stdpipe/templates.py +0 -972
  82. stdpipe-0.2.5/stdpipe.egg-info/SOURCES.txt +0 -35
  83. stdpipe-0.2.5/tests/test_astrometry.py +0 -184
  84. stdpipe-0.2.5/tests/test_catalogs.py +0 -144
  85. stdpipe-0.2.5/tests/test_photometry.py +0 -275
  86. stdpipe-0.2.5/tests/test_pipeline.py +0 -193
  87. {stdpipe-0.2.5 → stdpipe-0.4.0}/LICENSE.md +0 -0
  88. {stdpipe-0.2.5 → stdpipe-0.4.0}/MANIFEST.in +0 -0
  89. {stdpipe-0.2.5 → stdpipe-0.4.0}/setup.cfg +0 -0
  90. {stdpipe-0.2.5 → stdpipe-0.4.0}/setup.py +0 -0
  91. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
  92. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe/data/ps1skycells.txt +0 -0
  93. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe.egg-info/dependency_links.txt +0 -0
  94. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe.egg-info/not-zip-safe +0 -0
  95. {stdpipe-0.2.5 → stdpipe-0.4.0}/stdpipe.egg-info/top_level.txt +0 -0
  96. {stdpipe-0.2.5 → stdpipe-0.4.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stdpipe
3
- Version: 0.2.5
3
+ Version: 0.4.0
4
4
  Summary: Simple Transient Detection Pipeline
5
5
  Author-email: Sergey Karpov <karpov.sv@gmail.com>
6
6
  Maintainer-email: Sergey Karpov <karpov.sv@gmail.com>
@@ -53,16 +53,17 @@ Requires-Dist: scipy
53
53
  Requires-Dist: astropy>=4.1
54
54
  Requires-Dist: matplotlib
55
55
  Requires-Dist: astroquery>0.4.1
56
- Requires-Dist: sep
56
+ Requires-Dist: sep-x
57
57
  Requires-Dist: astroscrappy
58
- Requires-Dist: photutils>1.9
58
+ Requires-Dist: photutils>=2.2
59
+ Requires-Dist: scikit-image>=0.22.0
59
60
  Requires-Dist: statsmodels
60
61
  Requires-Dist: tqdm
61
62
  Requires-Dist: regions
62
63
  Requires-Dist: python-dateutil
63
64
  Requires-Dist: requests
64
- Requires-Dist: sip_tpv
65
65
  Requires-Dist: pyfftw
66
+ Requires-Dist: scikit-learn
66
67
  Provides-Extra: dev
67
68
  Requires-Dist: pytest>=6.0; extra == "dev"
68
69
  Requires-Dist: pytest-cov>=2.10; extra == "dev"
@@ -79,8 +80,12 @@ Requires-Dist: numpydoc; extra == "docs"
79
80
  Requires-Dist: myst-parser; extra == "docs"
80
81
  Requires-Dist: sphinxcontrib-apidoc; extra == "docs"
81
82
  Requires-Dist: sphinx-prompt; extra == "docs"
83
+ Provides-Extra: adaptive
84
+ Requires-Dist: powerbin; extra == "adaptive"
85
+ Provides-Extra: ml
86
+ Requires-Dist: tensorflow<2.20,>=2.10; extra == "ml"
82
87
  Provides-Extra: all
83
- Requires-Dist: stdpipe[dev,docs]; extra == "all"
88
+ Requires-Dist: stdpipe[adaptive,dev,docs,ml]; extra == "all"
84
89
  Dynamic: license-file
85
90
 
86
91
  # STDPipe - Simple Transient Detection Pipeline
@@ -100,20 +105,28 @@ Dynamic: license-file
100
105
  - ~~pre-processing~~ - should be handled before in an instrument-specific way
101
106
  - bias/dark subtraction, flatfielding, masking
102
107
  - object detection and photometry
103
- - SExtractor or SEP for detection, photutils for photometry
108
+ - SExtractor, SEP, or photutils (pure Python) for detection
109
+ - aperture, optimal extraction (Naylor 1998), and PSF fitting photometry
110
+ - grouped fitting for crowded fields
104
111
  - astrometric calibration
105
112
  - Astrometry.Net for blind WCS solving
106
- - SCAMP or Astropy-based code for refinement
113
+ - quad-hash pattern matching for refinement (pure Python, no external deps, default method)
114
+ - SCAMP or Astropy-based code for refinement (alternative)
107
115
  - photometric calibration
108
116
  - Vizier catalogues, passband conversion (PS1 to Johnson, Gaia to Johnson, ...)
109
117
  - spatial polynomial + color term + intrinsic scatter
118
+ - image reprojection and stacking
119
+ - Lanczos interpolation with automatic oversampling and flux conservation (pure Python, default)
120
+ - SWarp wrapper (alternative)
110
121
  - image subtraction
111
122
  - HiPS templates
112
123
  - PanSTARRS DR1 or Legacy Survey templates
113
124
  - HOTPANTS + custom noise model
114
125
  - ZOGY
115
- - transient detection and photometry
126
+ - transient detection and classification
116
127
  - noise-weighted detection, cutout adjustment, ...
128
+ - feature-based real/bogus classification (no TensorFlow required)
129
+ - CNN-based real/bogus classification (optional, requires TensorFlow)
117
130
  - auxiliary functions
118
131
  - PSF estimation, simulated stars, FITS header utilities, plotting, ...
119
132
  - light curve creation (soon)
@@ -15,20 +15,28 @@
15
15
  - ~~pre-processing~~ - should be handled before in an instrument-specific way
16
16
  - bias/dark subtraction, flatfielding, masking
17
17
  - object detection and photometry
18
- - SExtractor or SEP for detection, photutils for photometry
18
+ - SExtractor, SEP, or photutils (pure Python) for detection
19
+ - aperture, optimal extraction (Naylor 1998), and PSF fitting photometry
20
+ - grouped fitting for crowded fields
19
21
  - astrometric calibration
20
22
  - Astrometry.Net for blind WCS solving
21
- - SCAMP or Astropy-based code for refinement
23
+ - quad-hash pattern matching for refinement (pure Python, no external deps, default method)
24
+ - SCAMP or Astropy-based code for refinement (alternative)
22
25
  - photometric calibration
23
26
  - Vizier catalogues, passband conversion (PS1 to Johnson, Gaia to Johnson, ...)
24
27
  - spatial polynomial + color term + intrinsic scatter
28
+ - image reprojection and stacking
29
+ - Lanczos interpolation with automatic oversampling and flux conservation (pure Python, default)
30
+ - SWarp wrapper (alternative)
25
31
  - image subtraction
26
32
  - HiPS templates
27
33
  - PanSTARRS DR1 or Legacy Survey templates
28
34
  - HOTPANTS + custom noise model
29
35
  - ZOGY
30
- - transient detection and photometry
36
+ - transient detection and classification
31
37
  - noise-weighted detection, cutout adjustment, ...
38
+ - feature-based real/bogus classification (no TensorFlow required)
39
+ - CNN-based real/bogus classification (optional, requires TensorFlow)
32
40
  - auxiliary functions
33
41
  - PSF estimation, simulated stars, FITS header utilities, plotting, ...
34
42
  - light curve creation (soon)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stdpipe"
7
- version = "0.2.5"
7
+ dynamic = ["version"]
8
8
  description = "Simple Transient Detection Pipeline"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE.md"}
@@ -47,17 +47,17 @@ dependencies = [
47
47
  "astropy>=4.1",
48
48
  "matplotlib",
49
49
  "astroquery>0.4.1",
50
- "sep",
50
+ "sep-x",
51
51
  "astroscrappy",
52
- "photutils>1.9",
52
+ "photutils>=2.2",
53
+ "scikit-image>=0.22.0", # Requires >=0.22.0 for NumPy 2.0 compatibility
53
54
  "statsmodels",
54
55
  "tqdm",
55
56
  "regions",
56
57
  "python-dateutil",
57
58
  "requests",
58
- # "sip_tpv @ git+https://github.com/pmvreeswijk/sip_tpv.git",
59
- "sip_tpv",
60
59
  "pyfftw",
60
+ "scikit-learn",
61
61
  ]
62
62
 
63
63
  [project.optional-dependencies]
@@ -83,9 +83,19 @@ docs = [
83
83
  "sphinx-prompt",
84
84
  ]
85
85
 
86
+ # Adaptive binning (Voronoi tessellation)
87
+ adaptive = [
88
+ "powerbin",
89
+ ]
90
+
91
+ # Machine learning for real-bogus classifier
92
+ ml = [
93
+ "tensorflow>=2.10,<2.20",
94
+ ]
95
+
86
96
  # All optional dependencies
87
97
  all = [
88
- "stdpipe[dev,docs]",
98
+ "stdpipe[dev,docs,adaptive,ml]",
89
99
  ]
90
100
 
91
101
  [project.urls]
@@ -103,13 +113,31 @@ Changelog = "https://github.com/karpov-sv/stdpipe/commits/master"
103
113
  zip-safe = false
104
114
  include-package-data = true
105
115
 
116
+ # Single source of truth for the version - parsed statically from stdpipe/__init__.py
117
+ [tool.setuptools.dynamic]
118
+ version = {attr = "stdpipe.__version__"}
119
+
106
120
  [tool.setuptools.packages.find]
107
121
  where = ["."]
108
122
  include = ["stdpipe*"]
109
123
  exclude = ["tests*", "docs*", "notebooks*", "build*", "dist*"]
124
+ # stdpipe/data/ has no __init__.py and is not meant to be importable - without
125
+ # this it would be picked up as a namespace package, shipping the scripts in it
126
+ namespaces = false
110
127
 
128
+ # Listed explicitly rather than globbed, so that stray files in the working
129
+ # directory (editor backups etc) can never end up in a release. Note that
130
+ # data/legacysurvey.py is a one-off regeneration script, not shipped on purpose.
111
131
  [tool.setuptools.package-data]
112
- stdpipe = ["data/*"]
132
+ stdpipe = [
133
+ "data/legacysurvey_bricks.fits.gz",
134
+ "data/ps1skycells.txt",
135
+ ]
136
+
137
+ # data/legacysurvey.py is a one-off script for regenerating the bricks list, and
138
+ # is picked up by the generic "all .py files" sdist rule - keep it out of the wheel
139
+ [tool.setuptools.exclude-package-data]
140
+ stdpipe = ["data/legacysurvey.py"]
113
141
 
114
142
  # ============================================================================
115
143
  # pytest configuration
@@ -126,6 +154,7 @@ addopts = [
126
154
  "--strict-markers",
127
155
  "--showlocals",
128
156
  "-ra",
157
+ "-m", "not slow",
129
158
  ]
130
159
  markers = [
131
160
  "unit: Unit tests that don't require external dependencies",
@@ -136,6 +165,7 @@ markers = [
136
165
  "requires_psfex: Tests requiring PSFEx to be installed",
137
166
  "requires_hotpants: Tests requiring HOTPANTS to be installed",
138
167
  "requires_astrometry_net: Tests requiring Astrometry.Net to be installed",
168
+ "requires_pyraf: Tests requiring PyRAF/IRAF to be installed",
139
169
  "requires_network: Tests that require network access",
140
170
  ]
141
171
  filterwarnings = [
@@ -226,6 +256,9 @@ extend-exclude = [
226
256
  "dist",
227
257
  ]
228
258
 
259
+ [tool.ruff.format]
260
+ quote-style = "preserve"
261
+
229
262
  [tool.ruff.lint]
230
263
  select = [
231
264
  "E", # pycodestyle errors
@@ -6,4 +6,4 @@ detection related tasks, intended for quick and easy implementation of custom
6
6
  pipelines, as well as for interactive data analysis.
7
7
  """
8
8
 
9
- __version__ = "0.2.5"
9
+ __version__ = "0.4.0"
@@ -0,0 +1,370 @@
1
+ # Various artefact filtering routines
2
+ #
3
+ # For more advanced real-bogus classification with cutout-based morphological
4
+ # features, see the realbogus_features module:
5
+ #
6
+ # from stdpipe import realbogus_features as rbf
7
+ # obj = rbf.classify(obj, image, method='hybrid', classifier='scoring')
8
+ #
9
+ # The realbogus_features module provides:
10
+ # - Catalog-only, cutout-only, or hybrid feature extraction
11
+ # - Multiple classifiers: scoring (no training), IsolationForest, RandomForest
12
+ # - Generalized trend removal for all features
13
+ # - Training utilities for custom classifiers
14
+
15
+ import numpy as np
16
+ from sklearn.ensemble import IsolationForest
17
+
18
+ from . import smoothing
19
+ from .realbogus_features import FLAG_BOGUS, FLAGS_UNRELIABLE_MASK
20
+
21
+
22
+ def _has_column(obj, name):
23
+ """Check whether a table-like or dict-like catalog has a column."""
24
+ colnames = getattr(obj, 'colnames', None)
25
+ if colnames is not None:
26
+ return name in colnames
27
+ if hasattr(obj, 'keys'):
28
+ return name in obj.keys()
29
+ names = getattr(getattr(obj, 'dtype', None), 'names', None)
30
+ return names is not None and name in names
31
+
32
+
33
+ def _get_catalog_features(obj):
34
+ """
35
+ Build the feature list from available catalog columns.
36
+
37
+ Prefers SExtractor-style columns (FLUX_RADIUS, FLUX_MAX/FLUX_AUTO) and
38
+ falls back to SEP-style ones (peak/flux) where possible, so the same
39
+ filter works on both `get_objects_sextractor()` and `get_objects_sep()`
40
+ catalogs.
41
+ """
42
+ features = []
43
+
44
+ if _has_column(obj, 'FLUX_RADIUS'):
45
+ features.append([np.asarray(obj['FLUX_RADIUS'], dtype=float), 'FLUX_RADIUS'])
46
+
47
+ for name in ('fwhm', 'FWHM_IMAGE'):
48
+ if _has_column(obj, name):
49
+ features.append([np.asarray(obj[name], dtype=float), 'FWHM'])
50
+ break
51
+
52
+ for num, den in (('FLUX_MAX', 'FLUX_AUTO'), ('peak', 'flux')):
53
+ if _has_column(obj, num) and _has_column(obj, den):
54
+ with np.errstate(divide='ignore', invalid='ignore'):
55
+ ratio = np.asarray(obj[num], dtype=float) / np.asarray(obj[den], dtype=float)
56
+ features.append([ratio, f'{num} / {den}'])
57
+ break
58
+
59
+ return features
60
+
61
+
62
+ def filter_sextractor_detections(
63
+ obj,
64
+ trend_cols=['x', 'y', 'MAG_AUTO'],
65
+ trend_scales=None,
66
+ contamination='auto',
67
+ return_features=False,
68
+ return_classifier=False,
69
+ random_state=0,
70
+ verbose=True,
71
+ **kwargs,
72
+ ):
73
+ """
74
+ Flag detections likely to be artefacts using IsolationForest.
75
+
76
+ Builds feature vectors from FLUX_RADIUS, FWHM, and peakiness
77
+ (FLUX_MAX/FLUX_AUTO, or peak/flux for SEP catalogs), using whichever
78
+ columns are present. Optionally removes smooth spatial trends (e.g.,
79
+ across x/y/MAG_AUTO) via an approximate LOESS regressor before fitting
80
+ the outlier model.
81
+
82
+ Expected columns in `obj`
83
+ -------------------------
84
+ Required:
85
+ - flags
86
+ - at least one of the feature columns: FLUX_RADIUS; fwhm or FWHM_IMAGE;
87
+ FLUX_MAX + FLUX_AUTO or peak + flux
88
+ Trend columns (when `trend_cols` is set):
89
+ - columns named in `trend_cols` (default: x, y, MAG_AUTO); missing ones
90
+ are dropped with a log message
91
+
92
+ Parameters
93
+ ----------
94
+ obj : array-like / table
95
+ Detection catalog with required columns.
96
+ trend_cols : list[str] or None
97
+ Columns used to model smooth trends; set to None/[] to skip detrending.
98
+ trend_scales : list[float] or None
99
+ Per-dimension scaling for LOESS distances; must match trend_cols
100
+ length. If None (default), scales are auto-computed as ~3x the
101
+ standard deviation of each trend column.
102
+ contamination : float or 'auto'
103
+ Expected fraction of artefacts, passed to IsolationForest. With
104
+ 'auto' (default) the threshold follows the original paper and a
105
+ data-dependent fraction is flagged even on clean catalogs; set an
106
+ explicit value (e.g. 0.02) to control the rejection rate.
107
+ return_features : bool
108
+ If True, return the feature list (arrays + labels) without fitting.
109
+ return_classifier : bool
110
+ If True, return a callable that classifies new catalogs.
111
+ random_state : int
112
+ Random seed for IsolationForest.
113
+ verbose : bool or callable
114
+ Logging control; can be a print-like function.
115
+ **kwargs :
116
+ Extra arguments passed to `ApproxLoessRegressor` (e.g., k, robust_iters).
117
+
118
+ Returns
119
+ -------
120
+ good : ndarray[bool] or callable
121
+ Boolean mask of “good” detections, or a classifier callable if
122
+ return_classifier is True.
123
+ """
124
+
125
+ # Simple wrapper around print for logging in verbose mode only
126
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
127
+
128
+ features = _get_catalog_features(obj)
129
+
130
+ if not features:
131
+ raise ValueError(
132
+ "No usable feature columns found in the catalog "
133
+ "(need FLUX_RADIUS, fwhm/FWHM_IMAGE, FLUX_MAX+FLUX_AUTO or peak+flux)"
134
+ )
135
+
136
+ if return_features:
137
+ return features
138
+
139
+ log(
140
+ "Using isolation forest outlier detection over columns ({})".format(
141
+ ", ".join([_[1] for _ in features])
142
+ )
143
+ )
144
+
145
+ # LOESS neighbor count; popped here so it does not leak into other kwargs
146
+ k = kwargs.pop('k', 20)
147
+
148
+ # Exclude blends etc from the fit, as well as broken measurements;
149
+ # FLAG_BOGUS itself is ignored so re-running the filter is idempotent
150
+ idx = (obj['flags'] & FLAGS_UNRELIABLE_MASK) == 0
151
+ for f in features:
152
+ idx &= np.isfinite(f[0]) & (f[0] > 0)
153
+
154
+ pos = None
155
+ if trend_cols:
156
+ # Drop trend columns missing from the catalog (keeping user-provided
157
+ # scales aligned with the surviving columns)
158
+ available = [_ for _ in trend_cols if _has_column(obj, _)]
159
+ if trend_scales is not None and len(trend_scales) != len(trend_cols):
160
+ raise ValueError("trend_scales length is inconsistent with trend_cols length")
161
+ if len(available) < len(trend_cols):
162
+ missing = [_ for _ in trend_cols if _ not in available]
163
+ log("Trend columns not found in catalog: {}".format(", ".join(missing)))
164
+ if trend_scales is not None:
165
+ trend_scales = [s for c, s in zip(trend_cols, trend_scales) if c in available]
166
+ trend_cols = available
167
+
168
+ if trend_cols:
169
+ log("Removing smooth trends in {} using approximate LOESS".format(", ".join(trend_cols)))
170
+
171
+ if trend_scales is None:
172
+ # Auto-compute per-dimension scales as ~3 sigma of each column
173
+ trend_scales = []
174
+ for col in trend_cols:
175
+ scale = 3.0 * np.nanstd(np.asarray(obj[col], dtype=float))
176
+ if not np.isfinite(scale) or scale <= 0:
177
+ scale = 1.0
178
+ trend_scales.append(scale)
179
+ log(
180
+ "Auto trend scales: {}".format(
181
+ ", ".join([f"{c}={s:.3g}" for c, s in zip(trend_cols, trend_scales)])
182
+ )
183
+ )
184
+
185
+ pos = np.column_stack([np.array(obj[_]) for _ in trend_cols])
186
+ # Rows with non-finite trend columns cannot be detrended and would
187
+ # get NaN features anyway; keep them out of the fits below
188
+ idx &= np.all(np.isfinite(pos), axis=1)
189
+
190
+ if np.sum(idx) < 3:
191
+ raise ValueError(
192
+ f"Too few clean detections ({np.sum(idx)}) to fit the outlier model"
193
+ )
194
+
195
+ if trend_cols:
196
+ trend_models = []
197
+ X = []
198
+
199
+ for f in features:
200
+ model = smoothing.ApproxLoessRegressor(k=k, scales=trend_scales, **kwargs)
201
+ model.fit(pos[idx], f[0][idx])
202
+ trend_models.append(model)
203
+ X.append(np.array(f[0]) - model.predict(pos))
204
+
205
+ else:
206
+ trend_models = None
207
+ X = [np.array(_[0]) for _ in features]
208
+
209
+ X = np.column_stack(X)
210
+ X[~np.isfinite(X)] = -100000 # Definitely outside of the good locus
211
+
212
+ clf = IsolationForest(contamination=contamination, random_state=random_state).fit(X[idx])
213
+
214
+ res = clf.predict(X)
215
+
216
+ log(f"{np.sum(res > 0)} good, {np.sum(res < 0)} outliers")
217
+
218
+ if return_classifier:
219
+
220
+ def classifier(obj):
221
+ features = _get_catalog_features(obj)
222
+
223
+ if trend_cols:
224
+ pos = np.column_stack([np.array(obj[_]) for _ in trend_cols])
225
+ X = []
226
+
227
+ for f, model in zip(features, trend_models):
228
+ X.append(np.array(f[0]) - model.predict(pos))
229
+ else:
230
+ X = [np.array(_[0]) for _ in features]
231
+
232
+ X = np.column_stack(X)
233
+ X[~np.isfinite(X)] = -100000 # Definitely outside of the good locus
234
+
235
+ return clf.predict(X) > 0
236
+
237
+ return classifier
238
+
239
+ return res > 0
240
+
241
+
242
+ def filter_detections(
243
+ obj,
244
+ image=None,
245
+ bg=None,
246
+ err=None,
247
+ mask=None,
248
+ fwhm=None,
249
+ method='auto',
250
+ classifier='isolation',
251
+ threshold=0.5,
252
+ remove_trend=True,
253
+ trend_cols=None,
254
+ trend_scales=None,
255
+ add_score=False,
256
+ flag_bogus=False,
257
+ verbose=True,
258
+ **kwargs,
259
+ ):
260
+ """
261
+ Filter detections using feature-based real-bogus classification.
262
+
263
+ This is a convenience wrapper around realbogus_features.classify() that
264
+ provides a simpler interface similar to filter_sextractor_detections().
265
+
266
+ For full control over feature extraction and classification, use
267
+ realbogus_features.classify() directly.
268
+
269
+ Parameters
270
+ ----------
271
+ obj : astropy.table.Table
272
+ Object catalog with 'x', 'y' columns. Modified in place when
273
+ `add_score` or `flag_bogus` is set.
274
+ image : ndarray, optional
275
+ Science image. If provided, cutout features will be extracted.
276
+ bg : ndarray or float, optional
277
+ Background map or scalar.
278
+ err : ndarray or float, optional
279
+ Error/noise map or scalar.
280
+ mask : ndarray, optional
281
+ Boolean mask (True = masked).
282
+ fwhm : float, optional
283
+ Image FWHM. If None, estimated from catalog.
284
+ method : str, optional
285
+ Feature extraction method:
286
+ - 'catalog': Catalog features only (no image needed)
287
+ - 'cutout': Cutout features only
288
+ - 'hybrid': Both catalog and cutout features
289
+ - 'auto': 'hybrid' if image provided, else 'catalog'
290
+ classifier : str, optional
291
+ Classifier to use:
292
+ - 'scoring': Rule-based scoring (no training needed)
293
+ - 'isolation': IsolationForest (unsupervised, default)
294
+ threshold : float, optional
295
+ Score threshold for classification. Default: 0.5.
296
+ remove_trend : bool, optional
297
+ Remove spatial trends from features. Default: True.
298
+ trend_cols : list of str, optional
299
+ Columns for trend removal. Default: ['x', 'y'].
300
+ trend_scales : list of float, optional
301
+ Scales for trend removal. Default: auto-computed.
302
+ add_score : bool, optional
303
+ Add 'rb_score' column to the input catalog. Default: False.
304
+ flag_bogus : bool, optional
305
+ Set FLAG_BOGUS (0x4000) on bogus objects in the input catalog.
306
+ Default: False.
307
+ verbose : bool, optional
308
+ Print progress.
309
+ **kwargs
310
+ Additional arguments passed to realbogus_features.classify().
311
+
312
+ Returns
313
+ -------
314
+ good : ndarray[bool]
315
+ Boolean mask of "good" (real) detections.
316
+
317
+ See Also
318
+ --------
319
+ realbogus_features.classify : Full-featured classification function.
320
+ filter_sextractor_detections : Original SExtractor-specific filter.
321
+
322
+ Examples
323
+ --------
324
+ >>> # Simple catalog-only filtering (like original function)
325
+ >>> good = filter_detections(obj, classifier='isolation')
326
+
327
+ >>> # Cutout-based filtering with scoring (no training)
328
+ >>> good = filter_detections(obj, image, classifier='scoring')
329
+
330
+ >>> # Hybrid with trend removal
331
+ >>> good = filter_detections(obj, image, method='hybrid',
332
+ ... remove_trend=True, trend_cols=['x', 'y'])
333
+ """
334
+ from . import realbogus_features as rbf
335
+
336
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
337
+
338
+ # Call the full classify function; the score is always needed to build
339
+ # the output mask
340
+ result = rbf.classify(
341
+ obj,
342
+ image=image,
343
+ bg=bg,
344
+ err=err,
345
+ mask=mask,
346
+ fwhm=fwhm,
347
+ method=method,
348
+ classifier=classifier,
349
+ threshold=threshold,
350
+ add_score=True,
351
+ flag_bogus=flag_bogus,
352
+ remove_trend=remove_trend,
353
+ trend_cols=trend_cols,
354
+ trend_scales=trend_scales,
355
+ verbose=verbose,
356
+ **kwargs,
357
+ )
358
+
359
+ good = np.asarray(result['rb_score'] >= threshold)
360
+
361
+ # classify() works on a copy; propagate the requested columns back to
362
+ # the caller's catalog so add_score/flag_bogus have a visible effect
363
+ if add_score:
364
+ obj['rb_score'] = result['rb_score']
365
+ if flag_bogus:
366
+ obj['flags'] = result['flags']
367
+
368
+ log(f"{np.sum(good)} good, {np.sum(~good)} outliers")
369
+
370
+ return good