diff --git a/.config b/.config index 1786e01c4..9fb9ca9b5 100644 --- a/.config +++ b/.config @@ -688,8 +688,9 @@ num_cores: -1 ; Extract stars ; ------------- -; Maximum mean intensity of an image before it is discarded as too bright -max_global_intensity: 140 +; Maximum median intensity of an image (8-bit scale, measured after flat correction) +; before it is discarded as too bright to contain stars +max_global_intensity: 230 ; Apply a mask on the detections by removing all that are too close to the ; given image border (in pixels) diff --git a/RMS/Astrometry/CheckFit.py b/RMS/Astrometry/CheckFit.py index 3538cbbfe..b4119d9ca 100644 --- a/RMS/Astrometry/CheckFit.py +++ b/RMS/Astrometry/CheckFit.py @@ -504,8 +504,18 @@ def _handleFailure(config, platepar, calstars_data, catalog_stars, _nn_refinemen calstars_dict = {ff_file: star_data for ff_file, star_data in calstars_list} - # Extract star list from CALSTARS file from FF file with most stars - max_len_ff = max(calstars_dict, key=lambda k: len(calstars_dict[k])) + # Extract star list from the FF file with the most high-quality stars. Counting + # raw entries would let a junk-flooded frame (moon glare, noise) win and seed + # the alignment badly - prefer the SNR-filtered count where the data has SNR + # (older CALSTARS files pad the column with -1, in which case fall back to the + # raw count) + def qualityStarCount(ff_name): + star_data = np.array(calstars_dict[ff_name]) + if (star_data.ndim == 2) and (star_data.shape[1] > 6) and np.any(star_data[:, 6] > 0): + return int(np.sum(star_data[:, 6] >= 5.0)) + return len(star_data) + + max_len_ff = max(calstars_dict, key=qualityStarCount) # Pass full CALSTARS data (y, x, intensity, ...) - alignPlatepar will extract what it needs # and use intensities to infer appropriate catalog limiting magnitude diff --git a/RMS/ConfigReader.py b/RMS/ConfigReader.py index bf695ddd4..1b0c70706 100644 --- a/RMS/ConfigReader.py +++ b/RMS/ConfigReader.py @@ -539,7 +539,7 @@ def __init__(self): ##### StarExtraction # Extraction parameters - self.max_global_intensity = 150 # maximum mean intensity of an image before it is discarded as too bright + self.max_global_intensity = 230 # maximum median intensity of an image (8-bit scale) before it is discarded as too bright to contain stars self.border = 10 # apply a mask on the detections by removing all that are too close to the given image border (in pixels) self.neighborhood_size = 10 # size of the neighbourhood for the maximum search (in pixels) self.intensity_threshold = 5 # a threshold for cutting the detections which are too faint (0-255) diff --git a/RMS/ExtractStars.py b/RMS/ExtractStars.py index 86f3c0297..a310cc19d 100644 --- a/RMS/ExtractStars.py +++ b/RMS/ExtractStars.py @@ -66,8 +66,9 @@ def extractStars(img, img_median=None, mask=None, gamma=1.0, max_star_candidates img_median: [float] Median value of the image. If not given, it will be computed. mask: [ndarray] Mask image. None by default. gamma: [float] Gamma correction factor for the image. - max_star_candidates: [int] Maximum number of star candidates to process. If the number of - candidates is larger than this number, the image will be skipped. + max_star_candidates: [int] Maximum number of star candidates to process. If more candidates + are found (e.g. the image is flooded with noise or glare around the Moon), only the + max_star_candidates most prominent ones are kept. border: [int] apply a mask on the detections by removing all that are too close to the given image border (in pixels) neighborhood_size: [int] size of the neighbourhood for the maximum search (in pixels) @@ -129,13 +130,55 @@ def extractStars(img, img_median=None, mask=None, gamma=1.0, max_star_candidates if extra_info is not None: extra_info['num_candidates'] = num_objects - # Skip the image if there are too many maxima to process + label_index = range(1, num_objects + 1) + + # If there are too many candidates (e.g. the image is flooded with sensor noise or glare around + # the Moon), subsample them instead of skipping the image. The PSF fit below rejects the + # non-star candidates. This bounds the PSF fitting cost to max_star_candidates. if num_objects > max_star_candidates: - log.warning('Too many candidate stars to process! {:d}/{:d}'.format(num_objects, max_star_candidates)) - return False + + log.warning('Too many candidate stars ({:d}/{:d}), keeping the {:d} most prominent ones'.format( + num_objects, max_star_candidates, max_star_candidates)) + + # Rank the candidates by their peak height above the local background (white top-hat, + # i.e. the image minus its morphological opening). Unlike the raw pixel value, this is + # insensitive to brightness gradients, so stars rank above noise on glare or clouds. + opening = filters.maximum_filter(img_min, neighborhood_size) + tophat = img_convolved - opening + prominence = np.array(ndimage.maximum(tophat, labeled, label_index)) + + # Stratify the selection on a spatial grid so that the sample stays spatially uniform. + # A purely global prominence ranking would bias the sample toward the image centre + # (vignetting dims the stars near the edges) and let noisy regions (e.g. around the + # Moon) crowd out real stars elsewhere. Each tile gets an equal share of the budget, + # filled by local prominence; any unused share goes to the most prominent leftovers. + # Size the grid so each tile's share stays meaningful (at least ~4 candidates). + n_tiles = int(np.clip(np.sqrt(max_star_candidates/4.0), 2, 8)) + positions = np.array(ndimage.maximum_position(tophat, labeled, label_index)) + tile_y = np.clip(positions[:, 0]*n_tiles//img.shape[0], 0, n_tiles - 1) + tile_x = np.clip(positions[:, 1]*n_tiles//img.shape[1], 0, n_tiles - 1) + tile_id = (tile_y*n_tiles + tile_x).astype(int) + + tile_quota = max(1, max_star_candidates//(n_tiles*n_tiles)) + tile_counts = np.zeros(n_tiles*n_tiles, dtype=int) + + selected = [] + leftover = [] + for i in np.argsort(prominence)[::-1]: + if (tile_counts[tile_id[i]] < tile_quota) and (len(selected) < max_star_candidates): + tile_counts[tile_id[i]] += 1 + selected.append(i) + else: + leftover.append(i) + + # Fill any remaining budget with the most prominent unselected candidates + selected.extend(leftover[:max_star_candidates - len(selected)]) + + # Convert positional indices to labels + label_index = (np.array(selected) + 1).tolist() # Find centres of mass of each labeled objects - xy = np.array(ndimage.center_of_mass(img_convolved, labeled, range(1, num_objects + 1))) + xy = np.array(ndimage.center_of_mass(img_convolved, labeled, label_index)) # Remove all detection on the border #xy = xy[np.where((xy[:, 1] > border) & (xy[:,1] < ff.ncols - border) & (xy[:,0] > border) & (xy[:,0] < ff.nrows - border))] @@ -259,7 +302,7 @@ def extractStarsFF( flat_struct=None, dark=None, mask=None, config=None, border=10, - max_global_intensity=150, + max_global_intensity=230, neighborhood_size=10, intensity_threshold=18, segment_radius=4, roundness_threshold=0.5, max_feature_ratio=0.8, extra_info=None @@ -274,7 +317,7 @@ def extractStarsFF( ff_dir: [str] Path to directory where FF files are. ff_name: [str] Name of the FF file. config: [config object] configuration object (loaded from the .config file) - max_global_intensity: [int] maximum mean intensity of an image before it is discarded as too bright + max_global_intensity: [int] maximum median intensity of an image before it is discarded as too bright. 230 by default. border: [int] apply a mask on the detections by removing all that are too close to the given image border (in pixels) neighborhood_size: [int] size of the neighbourhood for the maximum search (in pixels) @@ -331,7 +374,10 @@ def extractStarsFF( img_median = np.median(ff.avepixel) # Check if the image is too bright and skip the image (scale the cutoff to the image bit depth) - if img_median > max_global_intensity*(2**(config.bit_depth - 8)): + max_global_intensity_scaled = max_global_intensity*(2**(config.bit_depth - 8)) + if img_median > max_global_intensity_scaled: + log.info('{:s} is too bright, skipping star extraction (median {:.0f} > {:d})'.format( + ff_name, img_median, max_global_intensity_scaled)) return error_return # Get the image data from the average pixel image @@ -365,8 +411,8 @@ def extractStarsImgHandle(img_handle, flat_struct=None, dark=None, mask=None, config=None, border=10, - max_global_intensity=150, - neighborhood_size=10, intensity_threshold=18, + max_global_intensity=230, + neighborhood_size=10, intensity_threshold=18, segment_radius=4, roundness_threshold=0.5, max_feature_ratio=0.8 ): @@ -381,7 +427,7 @@ def extractStarsImgHandle(img_handle, dark: [ndarray] Dark frame. None by default. mask: [ndarray] Mask image. None by default. config: [config object] configuration object (loaded from the .config file) - max_global_intensity: [int] maximum mean intensity of an image before it is discarded as too bright + max_global_intensity: [int] maximum median intensity of an image before it is discarded as too bright. 230 by default. border: [int] apply a mask on the detections by removing all that are too close to the given image border (in pixels) neighborhood_size: [int] size of the neighbourhood for the maximum search (in pixels) @@ -447,9 +493,15 @@ def extractStarsImgHandle(img_handle, # Calculate image mean and stddev img_median = np.median(avepixel) - # Check if the image is too bright and skip the image (scale the cutoff to the image bit depth) - if img_median > max_global_intensity*(2**(config.bit_depth - 8)): - return error_return + # Check if the chunk is too bright and skip it (scale the cutoff to the image bit depth). + # Only this chunk is skipped - other chunks in the image handle are still processed. + max_global_intensity_scaled = max_global_intensity*(2**(config.bit_depth - 8)) + if img_median > max_global_intensity_scaled: + log.info('Chunk {:d} is too bright, skipping star extraction (median {:.0f} > {:d})'.format( + chunk_no, img_median, max_global_intensity_scaled)) + + img_handle.nextChunk() + continue # Get the image data from the average pixel image img = avepixel.astype(np.float32) @@ -464,10 +516,11 @@ def extractStarsImgHandle(img_handle, max_feature_ratio=max_feature_ratio, bit_depth=config.bit_depth ) - # If the star extraction failed, return an empty list + # If the star extraction failed, skip this chunk if status is False: - return error_return - + img_handle.nextChunk() + continue + # Unpack the star data x_arr, y_arr, amplitude, intensity, fwhm, background, snr, saturated_count = status diff --git a/RMS/Routines/CustomPyqtgraphClasses.py b/RMS/Routines/CustomPyqtgraphClasses.py index 8c3f91ac9..a7762df8f 100644 --- a/RMS/Routines/CustomPyqtgraphClasses.py +++ b/RMS/Routines/CustomPyqtgraphClasses.py @@ -1214,6 +1214,10 @@ def mousePressEvent(self, event): class HistogramLUTItem(pg.HistogramLUTItem): + + # Emitted with the new state whenever auto levels is toggled (button or Ctrl+A) + sigAutoLevelsToggled = QtCore.pyqtSignal(bool) + def __init__(self, *args, **kwargs): pg.HistogramLUTItem.__init__(self, *args, **kwargs) self.level_images = [] @@ -1249,6 +1253,7 @@ def toggleAutoLevels(self): self.setLevels(*self.saved_manual_levels) self.auto_levels = not self.auto_levels self.region.setMovable(not self.auto_levels) + self.sigAutoLevelsToggled.emit(self.auto_levels) def paint(self, p, *args): # tbh this is an improvement @@ -1300,12 +1305,40 @@ def __init__(self, gui): self.maximized = True self.setFixedWidth(self.scaledWidth(self.TAB_WIDTH_CHARS)) - self.addTab(self.hist, 'Levels') - self.addTab(self.param_manager, 'Fit Parameters') - self.addTab(self.geolocation, 'Station') - self.addTab(self.star_detection, 'Star Detection') - self.addTab(self.mask, 'Mask') - self.addTab(self.settings, 'Settings') + # Levels tab: auto-levels toggle button above the histogram. The button state stays in + # sync with the Ctrl+A shortcut through sigAutoLevelsToggled. + self.levels_tab = QtWidgets.QWidget() + levels_layout = QtWidgets.QVBoxLayout() + levels_layout.setContentsMargins(*self.scaledMargins(0.3, 0.2)) + levels_layout.setSpacing(self.scaledSpacing(0.3)) + self.auto_levels_button = QtWidgets.QPushButton('Auto Levels') + self.auto_levels_button.setCheckable(True) + self.auto_levels_button.setToolTip('Toggle automatic image levels (Ctrl+A)') + self.auto_levels_button.clicked.connect(lambda checked: self.hist.item.toggleAutoLevels()) + self.hist.item.sigAutoLevelsToggled.connect(self.auto_levels_button.setChecked) + levels_layout.addWidget(self.auto_levels_button) + levels_layout.addWidget(self.hist) + self.levels_tab.setLayout(levels_layout) + + # Wrap the form-style tabs in scroll areas so that short windows scroll the content + # instead of compressing it into unreadability. Levels stretches naturally with the + # window and Help scrolls by itself, so they stay unwrapped. + self._tab_scroll_wrappers = {} + for panel in (self.param_manager, self.geolocation, self.star_detection, + self.mask, self.settings, self.debruijn): + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.NoFrame) + scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll.setWidget(panel) + self._tab_scroll_wrappers[panel] = scroll + + self.addTab(self.levels_tab, 'Levels') + self.addTab(self.tabWidgetFor(self.param_manager), 'Fit Parameters') + self.addTab(self.tabWidgetFor(self.geolocation), 'Station') + self.addTab(self.tabWidgetFor(self.star_detection), 'Star Detection') + self.addTab(self.tabWidgetFor(self.mask), 'Mask') + self.addTab(self.tabWidgetFor(self.settings), 'Settings') self.addTab(self.help, 'ⓘ Help') self.setCurrentIndex(self.index) # redundant @@ -1353,14 +1386,24 @@ def onTabBarClicked(self, index): self.gui.view_widget.setFocus() + def tabWidgetFor(self, panel): + """ Return the widget actually inserted in the tab bar for the given panel + (its scroll wrapper if it has one, the panel itself otherwise). + """ + return self._tab_scroll_wrappers.get(panel, panel) + + def tabIndexOf(self, panel): + """ Return the tab index of the given panel, looking through scroll wrappers. """ + return self.indexOf(self.tabWidgetFor(panel)) + def onSkyFit(self): # Remove ManualReduction-specific tabs self.removeTabText('Debruijn') # Add Skyfit-specific tabs - self.insertTab(1, self.param_manager, "Fit Parameters") - self.insertTab(2, self.geolocation, "Station") + self.insertTab(1, self.tabWidgetFor(self.param_manager), "Fit Parameters") + self.insertTab(2, self.tabWidgetFor(self.geolocation), "Station") self.settings.onSkyFit() self.setCurrentIndex(self.index) @@ -1374,7 +1417,7 @@ def onManualReduction(self): # Add ManualReduction-specific tabs if self.gui.img.img_handle.input_type == 'dfn': - self.insertTab(1, self.debruijn, 'Debruijn') + self.insertTab(1, self.tabWidgetFor(self.debruijn), 'Debruijn') self.setCurrentIndex(self.index) @@ -3268,6 +3311,8 @@ class StarDetectionWidget(QtWidgets.QWidget, ScaledSizeHelper): sigIntensityThresholdChanged = QtCore.pyqtSignal(int) sigNeighborhoodSizeChanged = QtCore.pyqtSignal(int) sigMaxStarsChanged = QtCore.pyqtSignal(int) + sigConfigMaxStarsChanged = QtCore.pyqtSignal(int) + sigMaxGlobalIntensityChanged = QtCore.pyqtSignal(int) sigGammaChanged = QtCore.pyqtSignal(float) sigSegmentRadiusChanged = QtCore.pyqtSignal(int) sigMaxFeatureRatioChanged = QtCore.pyqtSignal(float) @@ -3277,6 +3322,15 @@ def __init__(self, gui): QtWidgets.QWidget.__init__(self) self.gui = gui + # While True, programmatic slider seeding bypasses the snap-to-100 handlers, so a + # config value like max_stars=150 survives loading exactly instead of being snapped + # (and then counting as an unsaved modification with zero user input) + self._seeding = False + + # The station config last loaded into the sliders - Reset to Defaults returns the + # station-bound values (gamma, config max stars) to it rather than global defaults + self._loaded_config = None + layout = QtWidgets.QVBoxLayout() layout.setContentsMargins(*self.scaledMargins(1, 0.5)) layout.setSpacing(self.scaledSpacing(0.3)) @@ -3290,29 +3344,52 @@ def __init__(self, gui): title.setStyleSheet("font-weight: bold; font-size: 11pt;") layout.addWidget(title) - # Use QGridLayout for stable slider layout - grid = QtWidgets.QGridLayout() - grid.setSpacing(self.scaledSpacing(0.3)) - grid.setColumnStretch(0, 1) # Label column stretches - grid.setColumnStretch(1, 0) # Value column fixed - layout.addLayout(grid) - - row = 0 + # Detection parameters, split into what Save Config persists to the station config + # and what only applies to this SkyFit session slider_data = [ - ('Intensity Threshold', 1, 200, 18, '18', self.onIntensityThresholdChanged), - ('Neighborhood Size', 5, 40, 10, '10', self.onNeighborhoodSizeChanged), - ('Max Stars', 50, 5000, 200, '200', self.onMaxStarsChanged), - ('Gamma', 45, 200, 100, '1.00', self.onGammaChanged), - ('Segment Radius', 2, 20, 4, '4', self.onSegmentRadiusChanged), - ('Max Feature Ratio', 50, 200, 80, '0.80', self.onMaxFeatureRatioChanged), - ('Roundness Threshold', 30, 90, 50, '0.50', self.onRoundnessThresholdChanged), + # (key, label, min, max, default, default label, callback, group) + ('intensity_threshold', 'Intensity Threshold', 1, 200, 18, '18', self.onIntensityThresholdChanged, 'config'), + ('neighborhood_size', 'Neighborhood Size', 5, 40, 10, '10', self.onNeighborhoodSizeChanged, 'config'), + ('config_max_stars', 'Max Stars', 100, 2000, 400, '400', self.onConfigMaxStarsChanged, 'config'), + ('max_global_intensity', 'Max Global Intensity', 30, 255, 230, '230', self.onMaxGlobalIntensityChanged, 'config'), + ('gamma', 'Gamma', 45, 200, 100, '1.00', self.onGammaChanged, 'config'), + ('segment_radius', 'Segment Radius', 2, 20, 4, '4', self.onSegmentRadiusChanged, 'config'), + ('max_feature_ratio', 'Max Feature Ratio', 50, 200, 80, '0.80', self.onMaxFeatureRatioChanged, 'config'), + ('roundness_threshold', 'Roundness Threshold', 30, 90, 50, '0.50', self.onRoundnessThresholdChanged, 'config'), + ('skyfit_max_stars', 'Max Stars', 100, 5000, 2000, '2000', self.onMaxStarsChanged, 'session'), ] self.sliders = {} self.slider_labels = {} - - for name, min_val, max_val, default, default_str, callback in slider_data: - key = name.lower().replace(' ', '_') + self.slider_defaults = {} + + # One group box per parameter scope + group_grids = {} + group_rows = {} + for group_key, group_title, group_tip in [ + ('config', 'Station Config', + 'Written to the station config file by Save Config - these control the nightly pipeline'), + ('session', 'SkyFit Session Only', + 'Only used by SkyFit re-detection in this session - never written to the config')]: + + box = QtWidgets.QGroupBox(group_title) + box.setToolTip(group_tip) + grid = QtWidgets.QGridLayout() + grid.setSpacing(self.scaledSpacing(0.3)) + # Keep the group box padding tight so the label column doesn't get clipped + grid.setContentsMargins(*self.scaledMargins(0.4, 0.3)) + grid.setColumnStretch(0, 1) # Label column stretches + grid.setColumnStretch(1, 0) # Value column fixed + box.setLayout(grid) + layout.addWidget(box) + + group_grids[group_key] = grid + group_rows[group_key] = 0 + + for key, name, min_val, max_val, default, default_str, callback, group in slider_data: + + grid = group_grids[group] + row = group_rows[group] # Row with label and value grid.addWidget(QtWidgets.QLabel(name), row, 0) @@ -3332,6 +3409,7 @@ def __init__(self, gui): self.sliders[key] = slider self.slider_labels[key] = val_label + self.slider_defaults[key] = default # Add gamma preset buttons right after gamma slider if key == 'gamma': @@ -3355,13 +3433,19 @@ def __init__(self, gui): grid.setRowMinimumHeight(row, self.scaledHeight(1.75)) row += 1 + group_rows[group] = row + # Create named references for compatibility self.intensity_threshold_slider = self.sliders['intensity_threshold'] self.intensity_threshold_label = self.slider_labels['intensity_threshold'] self.neighborhood_size_slider = self.sliders['neighborhood_size'] self.neighborhood_size_label = self.slider_labels['neighborhood_size'] - self.max_stars_slider = self.sliders['max_stars'] - self.max_stars_label = self.slider_labels['max_stars'] + self.max_stars_slider = self.sliders['skyfit_max_stars'] + self.max_stars_label = self.slider_labels['skyfit_max_stars'] + self.config_max_stars_slider = self.sliders['config_max_stars'] + self.config_max_stars_label = self.slider_labels['config_max_stars'] + self.max_global_intensity_slider = self.sliders['max_global_intensity'] + self.max_global_intensity_label = self.slider_labels['max_global_intensity'] self.gamma_slider = self.sliders['gamma'] self.gamma_label = self.slider_labels['gamma'] self.segment_radius_slider = self.sliders['segment_radius'] @@ -3371,6 +3455,22 @@ def __init__(self, gui): self.roundness_threshold_slider = self.sliders['roundness_threshold'] self.roundness_threshold_label = self.slider_labels['roundness_threshold'] + # Both max stars budgets move in increments of 100 + for s in (self.max_stars_slider, self.config_max_stars_slider): + s.setSingleStep(100) + s.setPageStep(500) + + # Tooltips distinguishing the two star count budgets + self.max_stars_slider.setToolTip( + 'Number of star candidates used by SkyFit re-detection in this session.\n' + 'Initial calibration benefits from a deep sample - this value is never saved.') + self.config_max_stars_slider.setToolTip( + 'max_stars value written to the station config by Save Config.\n' + 'This bounds the star extraction cost of the nightly pipeline - 400 recommended.') + self.gamma_slider.setToolTip( + 'Camera gamma used for detection and photometry.\n' + 'Written to the config [Capture] section by Save Config, and also stored in the platepar.') + layout.addSpacing(self.scaledSpacing(1)) # Buttons in their own layout with spacing @@ -3390,6 +3490,11 @@ def __init__(self, gui): self.tune_button.clicked.connect(self.sigTuneParameters.emit) btn_layout.addWidget(self.tune_button) + self.defaults_button = QtWidgets.QPushButton('Reset to Defaults') + self.defaults_button.setToolTip('Reset all star detection parameters to the recommended defaults') + self.defaults_button.clicked.connect(self.resetToDefaults) + btn_layout.addWidget(self.defaults_button) + self.save_config_button = QtWidgets.QPushButton('Save Config...') self.save_config_button.setToolTip('Open File Manager to save star detection settings') self.save_config_button.setEnabled(False) @@ -3437,10 +3542,77 @@ def onNeighborhoodSizeChanged(self, value): self.neighborhood_size_label.setText(str(value)) self.sigNeighborhoodSizeChanged.emit(value) + @staticmethod + def _snapTo100(value): + """Snap a max stars value to the nearest 100.""" + return max(100, int(round(value/100.0))*100) + def onMaxStarsChanged(self, value): + # Snap only user motion - programmatic seeding keeps the exact config value + if not self._seeding: + snapped = self._snapTo100(value) + if snapped != value: + # Re-fires this handler with the snapped value + self.max_stars_slider.setValue(snapped) + return self.max_stars_label.setText(str(value)) self.sigMaxStarsChanged.emit(value) + def onConfigMaxStarsChanged(self, value): + # Snap only user motion - programmatic seeding keeps the exact config value + if not self._seeding: + snapped = self._snapTo100(value) + if snapped != value: + # Re-fires this handler with the snapped value + self.config_max_stars_slider.setValue(snapped) + return + self.config_max_stars_label.setText(str(value)) + self.sigConfigMaxStarsChanged.emit(value) + + def resetToDefaults(self): + """Reset the tuning sliders to the recommended defaults. + + Station-bound values return to the loaded config instead: gamma is a hardware + property of the camera (resetting it to 1.0 would corrupt photometry the moment + Save Config is pressed), and the config max stars is the station's pipeline + budget, not a tuning preference. + """ + cfg = self._loaded_config + + for key, default in self.slider_defaults.items(): + + if key == 'gamma': + if cfg is not None and hasattr(cfg, 'gamma'): + self.gamma_slider.setValue(int(round(cfg.gamma*100))) + continue + + if key == 'config_max_stars': + if cfg is not None and hasattr(cfg, 'max_stars'): + self._seedSlider(self.config_max_stars_slider, cfg.max_stars) + continue + + # setValue triggers each slider's callback, so labels and override + # values in SkyFit update through the normal signal path + self.sliders[key].setValue(default) + + def _seedSlider(self, slider, value): + """ Set a slider value programmatically, bypassing the snap-to-100 handlers so the + exact value survives (the change signal still fires normally). Extends the + slider range if needed so out-of-range config values are not clamped. """ + if value < slider.minimum(): + slider.setMinimum(value) + if value > slider.maximum(): + slider.setMaximum(value) + self._seeding = True + try: + slider.setValue(value) + finally: + self._seeding = False + + def onMaxGlobalIntensityChanged(self, value): + self.max_global_intensity_label.setText(str(value)) + self.sigMaxGlobalIntensityChanged.emit(value) + def onGammaChanged(self, value): gamma = value / 100.0 self.gamma_label.setText(f'{gamma:.2f}') @@ -3494,6 +3666,9 @@ def updateStatus(self, using_override, star_count=None, candidate_count=None): def loadFromConfig(self, config): """Initialize sliders from config values.""" + + self._loaded_config = config + if hasattr(config, 'intensity_threshold'): # Give the threshold slider a bit-depth-appropriate maximum before setting the # value, otherwise a high-bit-depth config threshold is silently clamped to the @@ -3512,7 +3687,16 @@ def loadFromConfig(self, config): if hasattr(config, 'neighborhood_size'): self.neighborhood_size_slider.setValue(config.neighborhood_size) if hasattr(config, 'max_stars'): - self.max_stars_slider.setValue(config.max_stars) + # Only the CONFIG budget tracks the station config (it is what Save Config + # writes back). The session budget is a desktop compute limit for calibration + # work - seeding it from the Pi's pipeline budget would drag the deep session + # default down on every folder load. Seed without snapping so a config value + # like 150 loads exactly - otherwise the seeded value counts as an unsaved + # config modification with zero user input, and Save Config would write the + # snapped value back to the station config + self._seedSlider(self.config_max_stars_slider, config.max_stars) + if hasattr(config, 'max_global_intensity'): + self.max_global_intensity_slider.setValue(config.max_global_intensity) if hasattr(config, 'gamma'): self.gamma_slider.setValue(int(config.gamma * 100)) if hasattr(config, 'segment_radius'): diff --git a/RMS/Routines/SkyFitHelp.py b/RMS/Routines/SkyFitHelp.py index fe63a75ff..48861729e 100644 --- a/RMS/Routines/SkyFitHelp.py +++ b/RMS/Routines/SkyFitHelp.py @@ -239,8 +239,9 @@ def _topic_levels(gui): "distribution – roughly the 0.1st percentile for black and the " "99.95th percentile for white, while ignoring the brightest few percent of pixels so " "hot or saturated pixels don't blow out the stretch. It is a good starting point for almost " - "any image. Press " + _key(c + " + A") + " again to return to your manual levels; while auto " - "is on, the handles are locked.
" + "any image. Toggle it with the Auto Levels button at the top of the tab or with " + + _key(c + " + A") + "; toggling again returns to your manual levels. While auto is on, " + "the handles are locked." + _callout("Levels are display-only. Set them so you can comfortably see the stars you need " "to pick – they have no effect on the calibration result.") + _nav_links(related=[('tabs', 'Guide to the tabs')]) @@ -573,13 +574,24 @@ def _topic_stardetect(gui): "Size of the local window used to pick one peak per star. Larger merges close stars " "(fewer detections); smaller separates them but can split one bright star into " "several. Set it a little larger than your typical star spacing."), - ("Max stars (def. 200)", - "Upper limit on how many detections are kept (brightest first). Raise it for rich, " - "wide-field images; lower it to keep only the brightest."), + ("Max stars (Station Config, def. 400)", + "Themax_stars value that Save Config writes to the station config. "
+ "This bounds the star extraction cost of the nightly pipeline on the station, which only "
+ "needs a modest sample to track calibration drift – 400 is recommended."),
+ ("Max stars (detection depth) (SkyFit Session, def. 800)",
+ "Candidate budget used by Re-Detect in this session only – it is never saved "
+ "to the config. Initial plate fitting benefits from a deep, frame-wide star sample, so feel "
+ "free to raise it. When more candidates are found than the budget, they are subsampled "
+ "evenly across the frame (most prominent first within each region), not simply brightest "
+ "first."),
+ ("Max global intensity (def. 230)",
+ "Median image level (8-bit scale) above which a frame is considered too bright to contain "
+ "stars and is skipped entirely. Raise it if twilight or moonlit frames that still show "
+ "stars are being rejected; frames near saturation are never worth processing."),
("Gamma (def. 1.0)",
- "Gamma stretch applied to the image for detection only (not the camera gamma and "
- "not the display gamma). Values below 1 lift faint stars out of the background so they get "
- "detected."),
+ "Camera gamma used when measuring stars (not the display gamma). Values below 1 lift "
+ "faint stars out of the background so they get detected. Saved to the config "
+ "[Capture] section and also stored in the platepar for photometry."),
("Segment radius (def. 4 px)",
"Radius of the patch used to centroid and measure each star. Match it to the typical star "
"size (FWHM): too small clips the star and worsens the centroid; too large pulls in "
@@ -602,6 +614,11 @@ def _topic_stardetect(gui):
"use those instead – handy when the default detection misses stars or picks up "
"noise."
+ "The parameters are split into two groups: Station Config values are what " + "Save Config writes to the station config file (they control the nightly pipeline), " + "while SkyFit Session Only values are used by re-detection here and are never " + "saved.
" + "