def build(self) -> widgets.Widget:
"""Build and return the training setup layout.
Returns:
The root VBox widget for the setup screen.
"""
self._out = widgets.Output(
layout=widgets.Layout(
border="1px solid white",
height="120px",
background_color=BG_COLOR,
overflow="auto",
),
)
header = HeaderWidget(self.app, self._out, show_back_button=True)
# ── Source folder chooser ─────────────────────────────────
# Resolve the source-chooser starting dir once so auto-validation
# scans the same folder the chooser displays. When persistence
# picks a different dir than cfg.data_dir, _auto_validate_from_config
# would otherwise walk cfg.data_dir (the test-data default) while
# the chooser shows the persisted dir, and the user gets
# mismatched "Source folder = q1_data / Found 19 images" reports.
self._source_initial_path = initial_dir(self._cfg.data_dir, purpose="source_chooser")
source_has_default = bool(
self._source_initial_path and os.path.isdir(self._source_initial_path)
)
self._source_chooser = AMFileChooser(
path=self._source_initial_path,
show_only_dirs=True,
select_default=source_has_default,
title="Source folder",
purpose="source_chooser",
)
self._source_chooser.register_callback(self._on_source_change)
# ── Label CSV chooser ─────────────────────────────────────
label_dir, label_name = initial_file(
self._cfg.label_file,
purpose="label_chooser",
is_shipped_default=BackendInterface.is_shipped_default_path(
"label_file", self._cfg.label_file
),
)
self._label_chooser = AMFileChooser(
path=label_dir,
filename=label_name,
select_default=bool(label_name),
filter_pattern="*.csv",
title="Labelled data CSV",
purpose="label_chooser",
)
self._label_chooser.register_callback(self._on_label_change)
# ── Metadata CSV chooser (optional) ───────────────────────
meta_dir, meta_name = initial_file(
self._cfg.metadata_file,
purpose="metadata_chooser",
is_shipped_default=BackendInterface.is_shipped_default_path(
"metadata_file", self._cfg.metadata_file
),
)
self._meta_chooser = AMFileChooser(
path=meta_dir,
filename=meta_name,
select_default=bool(meta_name),
filter_pattern="*.csv",
title="Metadata CSV (optional)",
purpose="metadata_chooser",
)
# ── Preview grid ──────────────────────────────────────────
# Keep PreviewGrid for the spinner / error-message panel — its
# status / message HTML is exactly what we want for the
# "Loading preview…" + "Select a label CSV" affordances.
# The actual cell rendering moves to GalleryWidget so cells get
# pagination + per-cell label toggles (#427).
self._preview_grid = PreviewGrid()
# Hide the static cell grid that ships with PreviewGrid — we
# render cells through GalleryWidget instead.
self._preview_grid._grid.layout.display = "none"
def _setup_cell_factory(**kwargs: object) -> LabelableThumbnailCell:
# The factory is called with on_magnify / on_star kwargs by
# GalleryWidget; override on_magnify with our own handler so
# the magnifying-glass button on a setup-screen cell opens
# the image detail view (the gallery's default on_magnify
# is None at setup time).
kwargs["on_magnify"] = self._on_setup_magnify
return LabelableThumbnailCell(on_label_change=self._on_setup_label_change, **kwargs)
self._preview_gallery = GalleryWidget(
setup_mode=True,
cell_factory=_setup_cell_factory,
)
self._preview_gallery.set_on_page_change(self._on_preview_page_change)
# Slider widens cells → fewer fit in two rows → effective page
# size shrinks → re-render the current page so paging walks the
# new chunk size and the user doesn't skip cells (#432).
self._preview_gallery.set_on_scale_change(self._on_preview_scale_change)
# Hide until we have items.
self._preview_gallery.widget.layout.display = "none"
# Wrap the three PreviewGrid status-affording methods so any
# spinner / message / clear also hides the cell gallery — the
# two panels share the same screen real estate and only one
# should be visible at a time.
_orig_show_spinner = self._preview_grid.show_spinner
_orig_show_message = self._preview_grid.show_message
_orig_clear = self._preview_grid.clear
def _show_spinner(*args: object, **kwargs: object) -> None:
self._preview_gallery.widget.layout.display = "none"
_orig_show_spinner(*args, **kwargs)
def _show_message(*args: object, **kwargs: object) -> None:
self._preview_gallery.widget.layout.display = "none"
_orig_show_message(*args, **kwargs)
def _clear() -> None:
self._preview_gallery.widget.layout.display = "none"
_orig_clear()
self._preview_grid.show_spinner = _show_spinner # type: ignore[method-assign]
self._preview_grid.show_message = _show_message # type: ignore[method-assign]
self._preview_grid.clear = _clear # type: ignore[method-assign]
# ── Normalisation config ─────────────────────────────────
norm_cfg = self._cfg.normalisation
self._norm_widget = NormalisationConfigWidget(
n_channels=norm_cfg.n_output_channels,
image_size=list(norm_cfg.image_size),
)
# Restore before registering the observer so the widget assignments
# can't spawn a preview job, and before ``on_enter`` reads
# ``cfg.normalisation`` back into the widget. Same rationale as
# ``initial_dir``: what the user last ran with is more current than a
# notebook's cfg defaults, which would otherwise clobber it on every
# fresh kernel. Nothing is reading cfg yet at build time, so pushing
# the restored values across is safe here.
if self._restore_persisted_normalisation():
self._apply_norm_overrides_to_cfg()
self._norm_widget.register_on_change(self._on_norm_change)
# Seed the dedup snapshot with the widget's initial state so the
# observer cascade from build-time widget assignments is not
# mistaken for a real user change.
self._last_norm_config = serialisable_config(self._norm_widget.get_normalisation_config())
# ── Training iterations ───────────────────────────────────
self._iter_slider = widgets.IntSlider(
value=self._cfg.num_train_iter,
min=50,
max=600,
step=10,
description="Iterations:",
style={"description_width": "initial", "handle_color": "white"},
layout=widgets.Layout(width="320px"),
)
# ── Source-size stratification (Cutana only) ──────────────
# Euclid Q1/DR1 sources skew heavily small. When the unlabelled source
# is a Cutana catalogue (which carries each source's diameter), sample
# the unlabelled training pool ~uniform in size so tiny sources don't
# dominate. Disabled for image/Zarr sources, which expose no per-source
# size — ``_refresh_validation`` enables it once a Cutana source is
# detected.
self._stratify_checkbox = widgets.Checkbox(
value=self._cfg.cutana_stratify_source_size,
description="Stratify source size per tile (Cutana)",
indent=False,
disabled=True,
style={"description_width": "initial"},
layout=widgets.Layout(width="360px"),
)
# ── Validation panel + start button ───────────────────────
self._validation_html = widgets.HTML(
value=self._render_validation(),
layout=widgets.Layout(padding="8px 0"),
)
self._start_btn = widgets.Button(
description="Start Training",
button_style="success",
disabled=True,
layout=widgets.Layout(width="200px", height="40px"),
style={"font_size": "14px"},
)
self._start_btn.on_click(lambda _: self._on_start())
# ── Layout ────────────────────────────────────────────────
# Left: file choosers (constrained so preview gets space)
left_panel = VBox(
[self._source_chooser, self._label_chooser, self._meta_chooser],
layout=widgets.Layout(
background_color=BG_COLOR,
padding="12px",
gap="4px",
flex="0 1 auto",
max_width="420px",
),
)
# Centre: image preview (flex so it grows with window width)
centre_panel = VBox(
[self._preview_grid.widget, self._preview_gallery.widget],
layout=widgets.Layout(
background_color=BG_COLOR,
padding="8px",
flex="1 1 auto",
min_width="140px",
border_left=f"1px solid {ESA_BLUE_DEEP}",
border_right=f"1px solid {ESA_BLUE_DEEP}",
),
)
# Right: normalisation config + iterations + validation + start
right_panel = VBox(
[
self._norm_widget.norm_section,
self._norm_widget.channel_section,
self._iter_slider,
self._stratify_checkbox,
self._validation_html,
self._start_btn,
],
layout=widgets.Layout(
background_color=BG_COLOR,
padding="8px 12px",
gap="8px",
width="380px",
flex="0 0 auto",
),
)
form = HBox(
[left_panel, centre_panel, right_panel],
layout=widgets.Layout(
background_color=BG_COLOR,
),
)
return VBox(
[
header.widget,
form,
self._out,
],
layout=widgets.Layout(
background_color=BG_COLOR,
border=f"1px solid {ESA_BLUE_DEEP}",
width="100%",
),
)