feat(ui): filter generated sampler and upscaler lists

This commit is contained in:
Yifan Chen
2026-09-07 08:29:37 -07:00
parent fe03d7b38e
commit a174e94183
14 changed files with 178 additions and 16 deletions
+1 -1
View File
@@ -444,7 +444,7 @@ def control_run(state: str = '', # pylint: disable=keyword-arg-before-vararg
if sampler_index is None:
log.warning('Sampler: invalid')
sampler_index = 0
if hr_sampler_index is None:
if hr_sampler_index is None or hr_sampler_index == 'Same as primary':
hr_sampler_index = sampler_index
if isinstance(extra, list):
extra = create_override_settings_dict(extra)
+4 -4
View File
@@ -537,12 +537,12 @@ class Detailer():
renoise_end = gr.Slider(minimum=0.0, maximum=1.0, step=0.01, label='Renoise end', value=shared.opts.detailer_sigma_adjust_max, elem_id=f"{tab}_detailer_renoise_end")
sampler_block = None
if tab == 'extras': # fold the standalone sampler settings into the detailer accordion; values applied per-job in make_processing, never global opts
from modules import sd_samplers
sd_samplers.set_samplers()
sampler_choices = [s.name for s in sd_samplers.visible_samplers() if s.name != 'Same as primary']
from modules import ui_sections
sampler_choices, default_value, filtered = ui_sections.sampler_choices()
with gr.Accordion('Sampler', open=False, elem_id=f"{tab}_detailer_sampler_accordion", elem_classes=["small-accordion"]):
with gr.Row():
d_sampler = gr.Dropdown(label='Sampling method', choices=sampler_choices, value='Default', elem_id=f"{tab}_detailer_sampler")
ui_sections.create_filter_indicator(tab, 'Sampler', filtered)
d_sampler = gr.Dropdown(label='Sampling method', choices=sampler_choices, value=default_value, type='value', elem_id=f"{tab}_detailer_sampler")
d_prediction = gr.Dropdown(label='Prediction method', choices=['default', 'epsilon', 'sample', 'v_prediction', 'flow_prediction'], value='default', elem_id=f"{tab}_detailer_prediction")
with gr.Row():
d_shift = gr.Slider(label='Flow shift', minimum=0, maximum=10, step=0.1, value=shared.opts.schedulers_shift, elem_id=f"{tab}_detailer_shift")
@@ -296,6 +296,23 @@ def connect_paste(button, local_paste_fields, input_comp, override_settings_comp
res.append(v)
applied[key] = v
else:
if key in ('Sampler', 'Hires sampler') and isinstance(v, str):
from modules import ui_sections
choices, value, _ = ui_sections.sampler_choices(selected=v, same_as_primary=key == 'Hires sampler')
res.append(gr.update(choices=choices, value=value))
applied[key] = v
continue
if getattr(output, 'elem_id', '').endswith('_resize_name') and isinstance(v, str):
from modules import modelloader, shared, ui_sections
modelloader.load_upscalers()
choices = [upscaler.name for upscaler in shared.sd_upscalers]
if output.elem_id.startswith(('control_after', 'control_mask')):
choices = [choice for choice in choices if not choice.lower().startswith('latent')]
if v in choices:
choices, _ = ui_sections.upscaler_choices(choices, selected=v)
res.append(gr.update(choices=choices, value=v))
applied[key] = v
continue
if isinstance(v, str) and v.strip() == '' and key in {'Prompt', 'Negative prompt'}:
debug(f'Paste skip empty: "{key}"')
res.append(gr.update())
+2
View File
@@ -193,6 +193,8 @@ def img2img(id_task: str, state: str, mode: int,
if sampler_index is None:
log.warning('Sampler: invalid')
sampler_index = 0
if hr_sampler_index is None or hr_sampler_index == 'Same as primary':
hr_sampler_index = sampler_index
mode = int(mode)
image = None
+4 -2
View File
@@ -159,9 +159,11 @@ def images_tensor_to_samples(image, approximation=None, model=None): # pylint: d
return x_latent
def get_sampler_name(sampler_index: int | None = None, img: bool = False) -> str:
def get_sampler_name(sampler_index: int | str | None = None, img: bool = False) -> str:
sampler_index = sampler_index or 0
if len(sd_samplers.samplers) > sampler_index:
if isinstance(sampler_index, str) and any(sampler.name == sampler_index for sampler in sd_samplers.samplers):
sampler_name = sampler_index
elif isinstance(sampler_index, int) and 0 <= sampler_index < len(sd_samplers.samplers):
sampler_name = sd_samplers.samplers[sampler_index].name
else:
sampler_name = "Default"
+1 -1
View File
@@ -41,7 +41,7 @@ def txt2img(id_task, state,
if sampler_index is None:
log.warning('Sampler: invalid')
sampler_index = 0
if hr_sampler_index is None:
if hr_sampler_index is None or hr_sampler_index == 'Same as primary':
hr_sampler_index = sampler_index
p = processing.StableDiffusionProcessingTxt2Img(
+17
View File
@@ -0,0 +1,17 @@
"""Helpers for filtering display-only UI choice lists."""
def filter_ui_choices(choices: list[str], preferences: list[str] | None = None, selected: str | None = None) -> tuple[list[str], bool]:
"""Return choices selected for display without changing the underlying catalog.
Empty or stale preferences leave the complete list visible. A current value is
always retained so loading a saved workflow cannot silently replace it.
"""
available = list(dict.fromkeys(choices))
preferred = set(preferences or [])
filtered = [choice for choice in available if choice in preferred]
if not filtered:
return available, False
if selected in available and selected not in filtered:
filtered.append(selected)
return filtered, len(filtered) < len(available)
+11 -1
View File
@@ -51,6 +51,11 @@ def list_samplers():
return modules.sd_samplers.all_samplers
def list_upscalers():
from modules import shared # pylint: disable=redefined-outer-name
return [upscaler.name for upscaler in shared.sd_upscalers]
def get_openvino_device_list():
try:
import modules.intel.openvino # pylint: disable=redefined-outer-name
@@ -769,6 +774,12 @@ def create_settings(cmd_opts):
"disable_all_extensions": OptionInfo("none", "Disable all extensions", gr.Radio, {"choices": ["none", "user", "all"]}),
}))
# --- Sampler Settings ---
options_templates.update(options_section(('sampler', "Sampler Settings"), {
"show_samplers": OptionInfo([], "Show samplers in user interface", gr.CheckboxGroup, lambda: {"choices": [x.name for x in list_samplers()]}, refresh=list_samplers),
"show_upscalers": OptionInfo([], "Show upscalers in user interface", gr.CheckboxGroup, lambda: {"choices": list_upscalers()}, refresh=refresh_upscalers),
}))
# --- Hidden Options ---
options_templates.update(
options_section(
@@ -830,7 +841,6 @@ def create_settings(cmd_opts):
"control_move_processor": OptionInfo(False, "Processor move to CPU when complete", gr.Checkbox, {"visible": False}),
"control_unload_processor": OptionInfo(False, "Processor unload after use", gr.Checkbox, {"visible": False}),
# sampler settings are handled separately
"show_samplers": OptionInfo([], "Show samplers in user interface", gr.CheckboxGroup, lambda: {"choices": [x.name for x in list_samplers()], "visible": False}),
"eta_noise_seed_delta": OptionInfo(0, "Noise seed delta (eta)", gr.Number, {"precision": 0, "visible": False}),
"scheduler_eta": OptionInfo(1.0, "Noise multiplier (eta)", gr.Slider, {"minimum": 0.0, "maximum": 1.0, "step": 0.01, "visible": False}),
"schedulers_solver_order": OptionInfo(0, "Solver order (where", gr.Slider, {"minimum": 0, "maximum": 5, "step": 1, "visible": False}),
+47 -3
View File
@@ -2,6 +2,7 @@ import gradio as gr
from modules import shared, modelloader, ui_symbols, ui_common, sd_samplers
from modules.logger import log
from modules.ui_components import ToolButton
from modules.ui_choices import filter_ui_choices
from modules.caption import caption
@@ -213,13 +214,41 @@ def create_color_inputs(tab):
return grading_brightness, grading_contrast, grading_saturation, grading_hue, grading_gamma, grading_sharpness, grading_color_temp, grading_shadows, grading_midtones, grading_highlights, grading_clahe_clip, grading_clahe_grid, grading_shadows_tint, grading_highlights_tint, grading_split_tone_balance, grading_vignette, grading_grain, grading_lut_cube_file, grading_lut_strength
def sampler_choices(choices=None, selected='Default', same_as_primary=False):
"""Build display-only sampler choices without changing the sampler catalog."""
if choices is None:
sd_samplers.set_samplers()
choices = [sampler for sampler in sd_samplers.samplers if sampler.name != 'Same as primary']
names = [choice.name if hasattr(choice, 'name') else choice for choice in choices]
visible, filtered = filter_ui_choices(names, shared.opts.show_samplers, selected)
if same_as_primary:
visible.insert(0, 'Same as primary')
value = selected if selected in visible else visible[0]
return visible, value, filtered
def upscaler_choices(choices, selected=None):
"""Build display-only upscaler choices without changing available upscalers."""
return filter_ui_choices(choices, shared.opts.show_upscalers, selected)
def create_filter_indicator(tabname, kind, filtered):
if not filtered:
return None
indicator = gr.Button(value=f'{kind} list filtered', elem_id=f'{tabname}_{kind.lower()}_filter_indicator', elem_classes=['filter-indicator'])
indicator.click(fn=None, _js="() => openSettingsSection('sampler')", inputs=[], outputs=[], show_progress='hidden')
return indicator
def create_sampler_and_steps_selection(choices, tabname, default_steps:int=20):
if choices is None:
sd_samplers.set_samplers()
choices = [x for x in sd_samplers.samplers if not x.name == 'Same as primary']
dropdown_choices, default_value, filtered = sampler_choices(choices)
with gr.Row(elem_id=f"{tabname}_sampler_row", elem_classes=['flex-break', 'flexbox']):
steps = gr.Slider(minimum=1, maximum=100, step=1, label="Steps", elem_id=f"{tabname}_steps", value=default_steps)
sampler_index = gr.Dropdown(label='Sampling method', elem_id=f"{tabname}_sampling", choices=[x.name for x in choices], value='Default', type="index")
create_filter_indicator(tabname, 'Sampler', filtered)
sampler_index = gr.Dropdown(label='Sampling method', elem_id=f"{tabname}_sampling", choices=dropdown_choices, value=default_value, type="value")
return steps, sampler_index
@@ -342,7 +371,9 @@ def create_hires_inputs(tab):
with gr.Row(elem_id=f"{tab}_hires_fix_row2"):
hr_force = gr.Checkbox(label='Force HiRes', value=False, elem_id=f"{tab}_hr_force")
with gr.Row(elem_id=f"{tab}_hires_fix_row2"):
hr_sampler_index = gr.Dropdown(label='Refine sampler', elem_id=f"{tab}_sampling_alt", choices=[x.name for x in sd_samplers.samplers], value='Same as primary', type="index")
dropdown_choices, _default_value, filtered = sampler_choices(selected='Same as primary', same_as_primary=True)
create_filter_indicator(tab, 'Sampler', filtered)
hr_sampler_index = gr.Dropdown(label='Refine sampler', elem_id=f"{tab}_sampling_alt", choices=dropdown_choices, value='Same as primary', type="value")
with gr.Row(elem_id=f"{tab}_hires_row2"):
hr_second_pass_steps = gr.Slider(minimum=0, maximum=99, step=1, label='HiRes steps', elem_id=f"{tab}_steps_alt", value=20)
denoising_strength = gr.Slider(minimum=0.0, maximum=0.99, step=0.01, label='Strength', value=0.3, elem_id=f"{tab}_denoising_strength")
@@ -365,11 +396,24 @@ def create_resize_inputs(tab, images, accordion=True, latent=False, non_zero=Tru
available_upscalers = ['None']
if not latent:
available_upscalers = [x for x in available_upscalers if not x.lower().startswith('latent')]
available_upscalers, filtered = upscaler_choices(available_upscalers, available_upscalers[0])
resize_mode = gr.Dropdown(label=f"Mode{prefix}" if non_zero else "Resize mode", elem_id=f"{tab}_resize_mode", choices=shared.resize_modes, type="index", value='Fixed')
create_filter_indicator(tab, 'Upscaler', filtered)
resize_name = gr.Dropdown(label=f"Method{prefix}" if non_zero else "Resize method", elem_id=f"{tab}_resize_name", choices=available_upscalers, value=available_upscalers[0], visible=True)
resize_context_choices = ["Add with forward", "Remove with forward", "Add with backward", "Remove with backward"]
resize_context = gr.Dropdown(label=f"Context{prefix}", elem_id=f"{tab}_resize_context", choices=resize_context_choices, value=resize_context_choices[0], visible=False)
resize_refresh_btn = ui_common.create_refresh_button(resize_name, modelloader.load_upscalers, lambda: {"choices": modelloader.load_upscalers()}, f'{tab}_upscalers_refresh')
def refresh_upscaler_choices(selected):
modelloader.load_upscalers()
refreshed = [upscaler.name for upscaler in shared.sd_upscalers]
if not latent:
refreshed = [name for name in refreshed if not name.lower().startswith('latent')]
refreshed, _ = upscaler_choices(refreshed, selected)
value = selected if selected in refreshed else refreshed[0]
return gr.update(choices=refreshed, value=value)
resize_refresh_btn = ToolButton(value=ui_symbols.refresh, elem_id=f'{tab}_upscalers_refresh')
resize_refresh_btn.click(fn=refresh_upscaler_choices, inputs=[resize_name], outputs=[resize_name], show_progress='hidden')
def resize_mode_change(mode):
if mode is None or mode == 0:
+20 -4
View File
@@ -60,8 +60,15 @@ def refresh_upscalers():
from modules import shared, modelloader
modelloader.load_upscalers() # refresh
upscalers = [u for u in shared.sd_upscalers if 'output_type' in inspect.signature(u.scaler.do_upscale).parameters.keys()]
upscaler_names = ['None'] + [u.name for u in upscalers]
return upscaler_names
return ['None'] + [u.name for u in upscalers]
def video_upscaler_choices(selected='None', refresh=False):
from modules import shared, modelloader
if refresh:
modelloader.load_upscalers()
upscalers = [upscaler for upscaler in shared.sd_upscalers if 'output_type' in inspect.signature(upscaler.scaler.do_upscale).parameters.keys()]
return ui_sections.upscaler_choices(['None'] + [upscaler.name for upscaler in upscalers], selected)
def create_ui_outputs():
@@ -96,8 +103,17 @@ def create_ui_outputs():
with gr.Row():
upscale_scale = gr.Slider(label="Video scale", minimum=1, maximum=4, value=1, step=0.1, elem_id="video_outputs_upscale_scale")
with gr.Row():
upscale_upscaler = gr.Dropdown(label="Video Upscaler", choices=['None'], value='None', type='value', elem_id="video_outputs_upscale_upscaler")
_upscale_upscaler_btn = ui_common.create_refresh_button(upscale_upscaler, refresh_upscalers)
upscaler_names, filtered = video_upscaler_choices()
ui_sections.create_filter_indicator('video', 'Upscaler', filtered)
upscale_upscaler = gr.Dropdown(label="Video Upscaler", choices=upscaler_names, value='None', type='value', elem_id="video_outputs_upscale_upscaler")
def refresh_video_upscalers(selected):
choices, _ = video_upscaler_choices(selected, refresh=True)
value = selected if selected in choices else choices[0]
return gr.update(choices=choices, value=value)
_upscale_upscaler_btn = ToolButton(value=ui_symbols.refresh, elem_id='video_upscalers_refresh')
_upscale_upscaler_btn.click(fn=refresh_video_upscalers, inputs=[upscale_upscaler], outputs=[upscale_upscaler], show_progress='hidden')
return mp4_fps, mp4_interpolate, mp4_codec, mp4_ext, mp4_opt, mp4_video, mp4_frames, mp4_sf, mp4_thumb, upscale_scale, upscale_upscaler
+37
View File
@@ -0,0 +1,37 @@
"""CPU-only regression coverage for display-only sampler and upscaler filters."""
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from modules.ui_choices import filter_ui_choices
class TestUiChoiceFilters(unittest.TestCase):
def setUp(self):
self.choices = ['Default', 'Euler', 'DPM++ 2M', 'Lanczos']
def test_empty_preferences_leave_choices_unfiltered(self):
visible, filtered = filter_ui_choices(self.choices, [])
self.assertEqual(visible, self.choices)
self.assertFalse(filtered)
def test_preferences_filter_only_current_catalog_choices(self):
visible, filtered = filter_ui_choices(self.choices, ['Euler', 'Lanczos'])
self.assertEqual(visible, ['Euler', 'Lanczos'])
self.assertTrue(filtered)
def test_stale_preferences_do_not_hide_the_catalog(self):
visible, filtered = filter_ui_choices(self.choices, ['Removed sampler'])
self.assertEqual(visible, self.choices)
self.assertFalse(filtered)
def test_saved_selection_remains_available_when_not_preferred(self):
visible, filtered = filter_ui_choices(self.choices, ['Euler'], selected='DPM++ 2M')
self.assertEqual(visible, ['Euler', 'DPM++ 2M'])
self.assertTrue(filtered)
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -141,6 +141,7 @@ declare global {
onUiUpdate?: (callback: () => void) => void; // ui/script.ts
timer?: (name: string, elapsed: number) => Promise<void>; // ui/timers.ts
markIfModified?: (setting_name: string, value: unknown) => void; // ui/settings.ts
openSettingsSection?: (sectionId: string) => void; // ui/settings.ts
appendContextMenuOption?: (targetElementSelector: string, entryName: string, entryFunction: () => void, primary?: boolean) => string; // ui/contextMenus.ts
generateForever?: (genbuttonid: string) => void; // ui/contextMenus.ts
removeContextMenuOption?: (id: string) => void; // ui/contextMenus.ts
+5
View File
@@ -1275,6 +1275,8 @@
],
"s": [
{"id":"txt2img_sampler","label":"Sampler","localized":"","hint":"Settings related to sampler and seed selection and configuration. Samplers guide the process of turning noise into an image over multiple steps.","ui":"txt2img"},
{"id":"","label":"Sampler list filtered","localized":"","hint":"This list only shows samplers selected in <b><i>Sampler Settings</i></b>. Select to open settings.","ui":"txt2img"},
{"id":"","label":"Sampler Settings","localized":"","hint":"Preferences for sampler and upscaler lists.","ui":"settings_sampler"},
{"id":"","label":"Scripts","localized":"","hint":"Enable additional features by using selected scripts during generate process","ui":"txt2img"},
{"id":"","label":"Scale","localized":"","hint":"Resize image to target scale. If resize fixed width/height are set this option is ignored","ui":"txt2img"},
{"id":"xy_grid_swap_axes_button","label":"Swap X/Y","localized":"","hint":"","ui":"script_xyz_grid_script"},
@@ -1325,6 +1327,8 @@
{"id":"","label":"Server log","localized":"","hint":""},
{"id":"","label":"Steps","localized":"","hint":"How many times to improve the generated image iteratively; higher values take longer; very low values can produce bad results","ui":"txt2img"},
{"id":"","label":"Sampling method","localized":"","hint":"Which algorithm to use to produce the image","ui":"txt2img"},
{"id":"","label":"Show samplers in user interface","localized":"","hint":"Select favorite samplers to show in generated dropdowns. Leave empty to show all samplers. Restart the UI after changing this setting.","ui":"settings_sampler"},
{"id":"","label":"Show upscalers in user interface","localized":"","hint":"Select favorite upscalers to show in generated dropdowns. Leave empty to show all upscalers. Restart the UI after changing this setting.","ui":"settings_sampler"},
{"id":"","label":"Sigma method","localized":"","hint":"Controls how noise levels (sigmas) are distributed across diffusion steps.<br><b>Default</b>: use the scheduler's built-in sigma method.<br><b>Karras</b>: smoother schedule that emphasizes later steps where fine details emerge; generally higher quality with fewer steps.<br><b>Betas</b>: derive sigmas directly from the model's beta schedule (classic <i>DDPM</i> behavior).<br><b>Exponential</b>: exponential decay of noise across steps; aggressive denoising early, slower refinement later.<br><b>Lambdas</b>: Lu's lambdas method from the <i>DPM-Solver</i> paper, specific to the <b>DPM++</b> family.<br><b>Flowmatch</b>: sigma schedule tuned for flow-matching models (<i>Flux</i>, <i>SD3</i>, video models).","ui":"txt2img"},
{"id":"","label":"Sigma adjust","localized":"","hint":"Multiplier applied to the sampler's step size during the active timestep window. (Sigma is the amount of noise the sampler removes at each step.)<br>Values below 1.0 shrink the step for smoother, more conservative denoising. Values above 1.0 enlarge it for sharper, more aggressive sampling.<br><br>Default 1.0 disables the adjustment entirely. Use Adjust start and Adjust end to define the timestep range where the multiplier takes effect.","ui":"txt2img"},
{"id":"","label":"Sampler order","localized":"","hint":"Overrides the solver order of the active sampler when set above 0.<br>Higher orders use more historical steps per update for greater stability and accuracy at the cost of extra compute. Lower orders are faster but noisier.<br><br>Default 0 leaves each sampler at its built-in order. Many samplers in the dropdown already encode their order in the name (e.g. <b>DPM++ 2M</b> is order 2, <b>DPM++ 3M</b> is order 3, <b>DPM++ 2M SDE</b> is order 2).<br><br>Within a sampler family, the named variants differ ONLY by this value, so picking <b>DPM++ 2M</b> with the slider at 3 produces a scheduler that is functionally identical to picking <b>DPM++ 3M</b> with the slider at 0. The same equivalence holds across the rest of the <b>DPM++</b> multistep family (including the SDE and Inverse variants) and across the <b>ER-SDE</b> family.<br><br>Samplers without a configurable solver order (<b>DDIM</b>, plain <b>Euler</b>, ancestrals, etc.) ignore this slider entirely.","ui":"txt2img"},
@@ -1577,6 +1581,7 @@
{"id":"component-5611","label":"Update all","localized":"","hint":"","ui":"models_metadata_tab"},
{"id":"","label":"UNet/DiT","localized":"","hint":""},
{"id":"","label":"Upscale","localized":"","hint":"Upscale image","ui":"extras"},
{"id":"","label":"Upscaler list filtered","localized":"","hint":"This list only shows upscalers selected in <b><i>Sampler Settings</i></b>. Select to open settings.","ui":"txt2img"},
{"id":"","label":"UI Tabs","localized":"","hint":"","ui":"settings_ui"},
{"id":"","label":"Upscaling","localized":"","hint":"","ui":"settings_postprocessing"},
{"id":"","label":"Use segmentation","localized":"","hint":"Use the model's pixel-precise segmentation mask as the inpaint mask instead of the rectangular bounding box.<br>Tighter mask means less unintended change around the detection (e.g., the inpaint stays on the face, not on the hair or background behind it). Better blending and smaller seams.<br><br>Requires a segmentation-capable model (filename usually contains <code>-seg</code>). Bounding-box-only models silently fall back to the rectangle.<br>Default off.","ui":"txt2img"},
+11
View File
@@ -78,6 +78,17 @@ function showAllSettings() {
});
}
function openSettingsSection(sectionId: string) {
const settingsTab = gradioApp().getElementById('tab_settings');
const settingsButton = settingsTab ? gradioApp().querySelector(`button[aria-controls="${settingsTab.id}"]`) : null;
settingsButton?.click();
const section = gradioApp().getElementById(`settings_section_tab_${sectionId}`);
const sectionButton = section ? gradioApp().querySelector(`button[aria-controls="${section.id}"]`) : null;
sectionButton?.click();
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
window.openSettingsSection = openSettingsSection;
function markIfModified(setting_name, value) {
if (!opts_metadata[setting_name]) return;
const elem = gradioApp().getElementById(`modification_indicator_${setting_name}`);