From a292b8cbc6e0a302d0abb2756398010387062ebc Mon Sep 17 00:00:00 2001 From: Jerry Medeiros Date: Fri, 24 Apr 2026 08:47:38 -0600 Subject: [PATCH 1/2] detection: add SEP (Source Extractor) as optional star detection method Adds IndiAllSkyStarsSEP using the sep library to estimate the sky background and detect real astronomical sources, selectable via a new DETECT_STARS_METHOD config key ('template' or 'sep'). Adds DETECT_STARS_SEP_THOLD (sigma threshold, default 5.0). The Settings page shows both fields with JS show/hide between the two thresholds. --- indi_allsky/config.py | 2 + indi_allsky/flask/forms.py | 18 ++++ indi_allsky/flask/templates/config.html | 49 +++++++++- indi_allsky/flask/views.py | 4 + indi_allsky/processing.py | 6 +- indi_allsky/starsSep.py | 120 ++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 indi_allsky/starsSep.py diff --git a/indi_allsky/config.py b/indi_allsky/config.py index fb34b8563..1b537a8cb 100644 --- a/indi_allsky/config.py +++ b/indi_allsky/config.py @@ -162,6 +162,8 @@ class IndiAllSkyConfigBase(object): "ADU_FOV_DIV" : 4, "DETECT_STARS" : True, "DETECT_STARS_THOLD" : 0.6, + "DETECT_STARS_METHOD" : "template", + "DETECT_STARS_SEP_THOLD" : 5.0, "DETECT_METEORS" : False, "DETECT_METEORS_THOLD" : 125, "DETECT_MASK" : "", diff --git a/indi_allsky/flask/forms.py b/indi_allsky/flask/forms.py index c4df76e52..801ad534a 100644 --- a/indi_allsky/flask/forms.py +++ b/indi_allsky/flask/forms.py @@ -665,6 +665,17 @@ def DETECT_STARS_THOLD_validator(form, field): raise ValidationError('Threshold must be 1.0 or less') +def DETECT_STARS_SEP_THOLD_validator(form, field): + if not isinstance(field.data, (int, float)): + raise ValidationError('Please enter valid number') + + if field.data < 0.5: + raise ValidationError('Sigma must be 0.5 or greater') + + if field.data > 50.0: + raise ValidationError('Sigma must be 50.0 or less') + + def DETECT_METEORS_THOLD_validator(form, field): if not isinstance(field.data, int): raise ValidationError('Please enter valid number') @@ -4398,8 +4409,15 @@ class IndiAllskyConfigForm(FlaskForm): ADU_ROI_X2 = IntegerField('ADU ROI x2', validators=[ADU_ROI_validator]) ADU_ROI_Y2 = IntegerField('ADU ROI y2', validators=[ADU_ROI_validator]) ADU_FOV_DIV = SelectField('ADU FoV', choices=ADU_FOV_DIV_choices, validators=[ADU_FOV_DIV_validator]) + DETECT_STARS_METHOD_choices = ( + ('template', 'Template Match'), + ('sep', 'SEP (Source Extractor)'), + ) + DETECT_STARS = BooleanField('Star Detection') DETECT_STARS_THOLD = FloatField('Star Detection Threshold', validators=[DataRequired(), DETECT_STARS_THOLD_validator]) + DETECT_STARS_METHOD = SelectField('Star Detection Method', choices=DETECT_STARS_METHOD_choices, validators=[DataRequired()]) + DETECT_STARS_SEP_THOLD = FloatField('SEP Sigma Threshold', validators=[DataRequired(), DETECT_STARS_SEP_THOLD_validator], widget=NumberInput(step=0.5)) DETECT_METEORS = BooleanField('Meteor Detection') DETECT_METEORS_THOLD = IntegerField('Meteor Detection Threshold', validators=[DataRequired(), DETECT_METEORS_THOLD_validator]) DETECT_MASK = StringField('Detection Mask', validators=[DETECT_MASK_validator]) diff --git a/indi_allsky/flask/templates/config.html b/indi_allsky/flask/templates/config.html index 435056242..89e5d6c68 100644 --- a/indi_allsky/flask/templates/config.html +++ b/indi_allsky/flask/templates/config.html @@ -3863,7 +3863,29 @@

{{ form_config.DETECT_STARS_THOLD(class='form-control bg-secondary') }} -
0.5 - 0.6 is a good value to start
+
0.5 - 0.6 is a good value to start (template method only)
+ + +
+
+ {{ form_config.DETECT_STARS_METHOD.label(class='col-form-label') }} +
+
+ {{ form_config.DETECT_STARS_METHOD(class='form-select bg-secondary') }} + +
+
Template match uses a synthetic star pattern; SEP (Source Extractor) estimates the sky background and detects real sources
+
+ +
+
+ {{ form_config.DETECT_STARS_SEP_THOLD.label(class='col-form-label') }} +
+
+ {{ form_config.DETECT_STARS_SEP_THOLD(class='form-control bg-secondary') }} + +
+
Sigma above background noise: 3 (faint stars) → 10 (bright stars only). Default: 5.0 (SEP method only)

@@ -9884,6 +9906,8 @@

'SQM_ROI_Y2', 'SQM_FOV_DIV', 'DETECT_STARS_THOLD', + 'DETECT_STARS_METHOD', + 'DETECT_STARS_SEP_THOLD', 'DETECT_METEORS_THOLD', 'DETECT_MASK', 'LOGO_OVERLAY', @@ -10976,6 +11000,26 @@

}); +// Star detection method +function applyDetectStarsMethod(val) { + if (val === 'sep') { + $('#DETECT_STARS_THOLD').attr({'readonly': true, 'disabled': true}); + $('#DETECT_STARS_THOLD').addClass('bg-dark text-secondary'); + $('#DETECT_STARS_SEP_THOLD').removeAttr('readonly disabled'); + $('#DETECT_STARS_SEP_THOLD').removeClass('bg-dark text-secondary'); + } else { + $('#DETECT_STARS_THOLD').removeAttr('readonly disabled'); + $('#DETECT_STARS_THOLD').removeClass('bg-dark text-secondary'); + $('#DETECT_STARS_SEP_THOLD').attr({'readonly': true, 'disabled': true}); + $('#DETECT_STARS_SEP_THOLD').addClass('bg-dark text-secondary'); + } +} + +$('#DETECT_STARS_METHOD').on('change', function() { + applyDetectStarsMethod($(this).val()); +}); + + // Night timelapse settings $('#TIMELAPSE__USE_NIGHT_CONFIG').on('change', function() { if ($('#TIMELAPSE__USE_NIGHT_CONFIG').prop('checked')) { @@ -11327,6 +11371,9 @@

$('#SHARPEN_AMOUNT_DAY').addClass('bg-dark text-secondary'); } + // Star detection method - apply initial state + applyDetectStarsMethod($('#DETECT_STARS_METHOD').val()); + // Denoise dependent fields - grey out on page load (night) if (!$('#IMAGE_DENOISE').val()) { $('#IMAGE_DENOISE_STRENGTH').attr({'readonly': true, 'disabled': true}); diff --git a/indi_allsky/flask/views.py b/indi_allsky/flask/views.py index 1a0f029ca..a38357f3d 100644 --- a/indi_allsky/flask/views.py +++ b/indi_allsky/flask/views.py @@ -2320,6 +2320,8 @@ def get_context(self): 'SQM_FOV_DIV' : str(self.indi_allsky_config.get('SQM_FOV_DIV', 4)), # string in form, int in config 'DETECT_STARS' : self.indi_allsky_config.get('DETECT_STARS', True), 'DETECT_STARS_THOLD' : self.indi_allsky_config.get('DETECT_STARS_THOLD', 0.6), + 'DETECT_STARS_METHOD' : self.indi_allsky_config.get('DETECT_STARS_METHOD', 'template'), + 'DETECT_STARS_SEP_THOLD' : self.indi_allsky_config.get('DETECT_STARS_SEP_THOLD', 5.0), 'DETECT_METEORS' : self.indi_allsky_config.get('DETECT_METEORS', False), 'DETECT_METEORS_THOLD' : self.indi_allsky_config.get('DETECT_METEORS_THOLD', 125), 'DETECT_MASK' : self.indi_allsky_config.get('DETECT_MASK', ''), @@ -3340,6 +3342,8 @@ def dispatch_request(self): self.indi_allsky_config['SQM_FOV_DIV'] = int(request.json['SQM_FOV_DIV']) self.indi_allsky_config['DETECT_STARS'] = bool(request.json['DETECT_STARS']) self.indi_allsky_config['DETECT_STARS_THOLD'] = float(request.json['DETECT_STARS_THOLD']) + self.indi_allsky_config['DETECT_STARS_METHOD'] = str(request.json['DETECT_STARS_METHOD']) + self.indi_allsky_config['DETECT_STARS_SEP_THOLD'] = float(request.json['DETECT_STARS_SEP_THOLD']) self.indi_allsky_config['DETECT_METEORS'] = bool(request.json['DETECT_METEORS']) self.indi_allsky_config['DETECT_METEORS_THOLD'] = int(request.json['DETECT_METEORS_THOLD']) self.indi_allsky_config['DETECT_MASK'] = str(request.json['DETECT_MASK']) diff --git a/indi_allsky/processing.py b/indi_allsky/processing.py index e64a544b3..bf9343709 100644 --- a/indi_allsky/processing.py +++ b/indi_allsky/processing.py @@ -382,7 +382,11 @@ def post_init(self): self._adu_mask_dict = self._detection_mask_dict # reuse detection mask for ADU mask (if defined) self._sqm = IndiAllskySqm(self.config, self.gain_av, mask=self._detection_mask_dict) - self._stars_detect = IndiAllSkyStars(self.config, mask=self._detection_mask_dict) + if self.config.get('DETECT_STARS_METHOD', 'template') == 'sep': + from .starsSep import IndiAllSkyStarsSEP + self._stars_detect = IndiAllSkyStarsSEP(self.config, mask=self._detection_mask_dict) + else: + self._stars_detect = IndiAllSkyStars(self.config, mask=self._detection_mask_dict) self._lineDetect = IndiAllskyDetectLines(self.config, mask=self._detection_mask_dict) self._draw = IndiAllSkyDraw(self.config, mask=self._detection_mask_dict) diff --git a/indi_allsky/starsSep.py b/indi_allsky/starsSep.py new file mode 100644 index 000000000..3cbfa79c3 --- /dev/null +++ b/indi_allsky/starsSep.py @@ -0,0 +1,120 @@ +import time +from pathlib import Path +import cv2 +import numpy +import sep +import logging + + +logger = logging.getLogger('indi_allsky') + + +class IndiAllSkyStarsSEP(object): + """Star detection using SEP (Source Extractor Python). + + Provides the same interface as IndiAllSkyStars so it can be used as a + drop-in replacement in the processing pipeline. SEP estimates and + subtracts the sky background before extracting sources, which makes it + significantly more robust on real sky images than template matching. + """ + + def __init__(self, config, mask=None): + self.config = config + + self._sqm_mask_dict = mask + + self._star_mask_dict = dict() + for binning in self._sqm_mask_dict.keys(): + self._star_mask_dict[binning] = None + + self._detectionThreshold = self.config.get('DETECT_STARS_SEP_THOLD', 5.0) + + if self.config['IMAGE_FOLDER']: + self.image_dir = Path(self.config['IMAGE_FOLDER']).absolute() + else: + self.image_dir = Path(__file__).parent.parent.joinpath('html', 'images').absolute() + + + def detectObjects(self, original_data, binning): + if isinstance(self._star_mask_dict[binning], type(None)): + self._generateStarMask(original_data, binning) + + # build SEP mask: nonzero = excluded (inverse of indi-allsky convention) + indi_mask = self._star_mask_dict[binning] + if indi_mask is not None: + sep_mask = (indi_mask == 0).astype(numpy.uint8) + else: + sep_mask = None + + if len(original_data.shape) == 2: + grey = original_data + else: + grey = cv2.cvtColor(original_data, cv2.COLOR_BGR2GRAY) + + data = numpy.ascontiguousarray(grey.astype(numpy.float32)) + + sep_start = time.time() + + bkg = sep.Background(data, mask=sep_mask) + data_sub = data - bkg + + try: + objects = sep.extract(data_sub, self._detectionThreshold, err=bkg.globalrms, mask=sep_mask) + except Exception as e: + logger.error('SEP extraction error: %s', e) + objects = [] + + sep_elapsed_s = time.time() - sep_start + logger.info('Detected %d stars in %0.4f s', len(objects), sep_elapsed_s) + + blobs = [(float(obj['x']), float(obj['y'])) for obj in objects] + + self._drawCircles(original_data, objects) + + return blobs + + + def _generateStarMask(self, img, binning): + logger.info('Generating mask based on SQM_ROI') + + if not isinstance(self._sqm_mask_dict[binning], type(None)): + self._star_mask_dict[binning] = self._sqm_mask_dict[binning] + return + + image_height, image_width = img.shape[:2] + + mask = numpy.zeros((image_height, image_width), dtype=numpy.uint8) + + sqm_roi = self.config.get('SQM_ROI', []) + + try: + x1 = int(sqm_roi[0] / binning) + y1 = int(sqm_roi[1] / binning) + x2 = int(sqm_roi[2] / binning) + y2 = int(sqm_roi[3] / binning) + except IndexError: + logger.warning('Using central ROI for star detection') + sqm_fov_div = self.config.get('SQM_FOV_DIV', 4) + x1 = int((image_width / 2) - (image_width / sqm_fov_div)) + y1 = int((image_height / 2) - (image_height / sqm_fov_div)) + x2 = int((image_width / 2) + (image_width / sqm_fov_div)) + y2 = int((image_height / 2) + (image_height / sqm_fov_div)) + + cv2.rectangle(mask, (x1, y1), (x2, y2), 255, cv2.FILLED) + + self._star_mask_dict[binning] = mask + + + def _drawCircles(self, img, objects): + if not self.config.get('DETECT_DRAW'): + return + + color_bgr = list(self.config['TEXT_PROPERTIES']['FONT_COLOR']) + color_bgr.reverse() + + logger.info('Draw circles around objects') + for obj in objects: + cx = int(obj['x']) + cy = int(obj['y']) + r = max(4, int(obj['a'] * 3)) + cv2.circle(img, (cx, cy), r, tuple(color_bgr), thickness=1) From fc2b3eb2f1f4c41aee2bf1f368d05021e3771a44 Mon Sep 17 00:00:00 2001 From: Jerry Medeiros Date: Fri, 24 Apr 2026 16:22:49 -0600 Subject: [PATCH 2/2] detection: add configurable max star radius filter for SEP method --- indi_allsky/config.py | 1 + indi_allsky/flask/forms.py | 12 ++++++++++++ indi_allsky/flask/templates/config.html | 16 ++++++++++++++++ indi_allsky/flask/views.py | 2 ++ indi_allsky/starsSep.py | 6 ++++++ 5 files changed, 37 insertions(+) diff --git a/indi_allsky/config.py b/indi_allsky/config.py index 1b537a8cb..acca5838f 100644 --- a/indi_allsky/config.py +++ b/indi_allsky/config.py @@ -164,6 +164,7 @@ class IndiAllSkyConfigBase(object): "DETECT_STARS_THOLD" : 0.6, "DETECT_STARS_METHOD" : "template", "DETECT_STARS_SEP_THOLD" : 5.0, + "DETECT_STARS_SEP_MAX_RADIUS" : 20, "DETECT_METEORS" : False, "DETECT_METEORS_THOLD" : 125, "DETECT_MASK" : "", diff --git a/indi_allsky/flask/forms.py b/indi_allsky/flask/forms.py index 801ad534a..a9d9eb6e2 100644 --- a/indi_allsky/flask/forms.py +++ b/indi_allsky/flask/forms.py @@ -676,6 +676,17 @@ def DETECT_STARS_SEP_THOLD_validator(form, field): raise ValidationError('Sigma must be 50.0 or less') +def DETECT_STARS_SEP_MAX_RADIUS_validator(form, field): + if not isinstance(field.data, int): + raise ValidationError('Please enter a valid integer') + + if field.data < 1: + raise ValidationError('Max radius must be 1 or greater') + + if field.data > 500: + raise ValidationError('Max radius must be 500 or less') + + def DETECT_METEORS_THOLD_validator(form, field): if not isinstance(field.data, int): raise ValidationError('Please enter valid number') @@ -4418,6 +4429,7 @@ class IndiAllskyConfigForm(FlaskForm): DETECT_STARS_THOLD = FloatField('Star Detection Threshold', validators=[DataRequired(), DETECT_STARS_THOLD_validator]) DETECT_STARS_METHOD = SelectField('Star Detection Method', choices=DETECT_STARS_METHOD_choices, validators=[DataRequired()]) DETECT_STARS_SEP_THOLD = FloatField('SEP Sigma Threshold', validators=[DataRequired(), DETECT_STARS_SEP_THOLD_validator], widget=NumberInput(step=0.5)) + DETECT_STARS_SEP_MAX_RADIUS = IntegerField('SEP Max Star Radius', validators=[DataRequired(), DETECT_STARS_SEP_MAX_RADIUS_validator]) DETECT_METEORS = BooleanField('Meteor Detection') DETECT_METEORS_THOLD = IntegerField('Meteor Detection Threshold', validators=[DataRequired(), DETECT_METEORS_THOLD_validator]) DETECT_MASK = StringField('Detection Mask', validators=[DETECT_MASK_validator]) diff --git a/indi_allsky/flask/templates/config.html b/indi_allsky/flask/templates/config.html index 89e5d6c68..34f9fbfe0 100644 --- a/indi_allsky/flask/templates/config.html +++ b/indi_allsky/flask/templates/config.html @@ -3888,6 +3888,17 @@

Sigma above background noise: 3 (faint stars) → 10 (bright stars only). Default: 5.0 (SEP method only)
+
+
+ {{ form_config.DETECT_STARS_SEP_MAX_RADIUS.label(class='col-form-label') }} +
+
+ {{ form_config.DETECT_STARS_SEP_MAX_RADIUS(class='form-control bg-secondary') }} + +
+
Maximum semi-major axis (pixels) for a valid star detection — larger sources are rejected as bloomed stars or artifacts. Default: 20 (SEP method only)
+
+
@@ -9908,6 +9919,7 @@

'DETECT_STARS_THOLD', 'DETECT_STARS_METHOD', 'DETECT_STARS_SEP_THOLD', + 'DETECT_STARS_SEP_MAX_RADIUS', 'DETECT_METEORS_THOLD', 'DETECT_MASK', 'LOGO_OVERLAY', @@ -11007,11 +11019,15 @@

$('#DETECT_STARS_THOLD').addClass('bg-dark text-secondary'); $('#DETECT_STARS_SEP_THOLD').removeAttr('readonly disabled'); $('#DETECT_STARS_SEP_THOLD').removeClass('bg-dark text-secondary'); + $('#DETECT_STARS_SEP_MAX_RADIUS').removeAttr('readonly disabled'); + $('#DETECT_STARS_SEP_MAX_RADIUS').removeClass('bg-dark text-secondary'); } else { $('#DETECT_STARS_THOLD').removeAttr('readonly disabled'); $('#DETECT_STARS_THOLD').removeClass('bg-dark text-secondary'); $('#DETECT_STARS_SEP_THOLD').attr({'readonly': true, 'disabled': true}); $('#DETECT_STARS_SEP_THOLD').addClass('bg-dark text-secondary'); + $('#DETECT_STARS_SEP_MAX_RADIUS').attr({'readonly': true, 'disabled': true}); + $('#DETECT_STARS_SEP_MAX_RADIUS').addClass('bg-dark text-secondary'); } } diff --git a/indi_allsky/flask/views.py b/indi_allsky/flask/views.py index a38357f3d..9c8e2bde7 100644 --- a/indi_allsky/flask/views.py +++ b/indi_allsky/flask/views.py @@ -2322,6 +2322,7 @@ def get_context(self): 'DETECT_STARS_THOLD' : self.indi_allsky_config.get('DETECT_STARS_THOLD', 0.6), 'DETECT_STARS_METHOD' : self.indi_allsky_config.get('DETECT_STARS_METHOD', 'template'), 'DETECT_STARS_SEP_THOLD' : self.indi_allsky_config.get('DETECT_STARS_SEP_THOLD', 5.0), + 'DETECT_STARS_SEP_MAX_RADIUS' : self.indi_allsky_config.get('DETECT_STARS_SEP_MAX_RADIUS', 20), 'DETECT_METEORS' : self.indi_allsky_config.get('DETECT_METEORS', False), 'DETECT_METEORS_THOLD' : self.indi_allsky_config.get('DETECT_METEORS_THOLD', 125), 'DETECT_MASK' : self.indi_allsky_config.get('DETECT_MASK', ''), @@ -3344,6 +3345,7 @@ def dispatch_request(self): self.indi_allsky_config['DETECT_STARS_THOLD'] = float(request.json['DETECT_STARS_THOLD']) self.indi_allsky_config['DETECT_STARS_METHOD'] = str(request.json['DETECT_STARS_METHOD']) self.indi_allsky_config['DETECT_STARS_SEP_THOLD'] = float(request.json['DETECT_STARS_SEP_THOLD']) + self.indi_allsky_config['DETECT_STARS_SEP_MAX_RADIUS'] = int(request.json['DETECT_STARS_SEP_MAX_RADIUS']) self.indi_allsky_config['DETECT_METEORS'] = bool(request.json['DETECT_METEORS']) self.indi_allsky_config['DETECT_METEORS_THOLD'] = int(request.json['DETECT_METEORS_THOLD']) self.indi_allsky_config['DETECT_MASK'] = str(request.json['DETECT_MASK']) diff --git a/indi_allsky/starsSep.py b/indi_allsky/starsSep.py index 3cbfa79c3..dba7aa961 100644 --- a/indi_allsky/starsSep.py +++ b/indi_allsky/starsSep.py @@ -65,6 +65,12 @@ def detectObjects(self, original_data, binning): objects = [] sep_elapsed_s = time.time() - sep_start + + # reject bloomed/saturated sources — their semi-major axis is much + # larger than a real point-source star on this sensor + max_star_radius = self.config.get('DETECT_STARS_SEP_MAX_RADIUS', 20) + objects = [obj for obj in objects if obj['a'] <= max_star_radius] + logger.info('Detected %d stars in %0.4f s', len(objects), sep_elapsed_s) blobs = [(float(obj['x']), float(obj['y'])) for obj in objects]