Skip to content

image_detail_screen

anomaly_match_ui.screens.image_detail_screen

Image detail screen — full-size viewer with transforms for a single prediction result.

ImageDetailScreen(app)

Bases: BaseScreen

Full-screen image viewer with transform controls.

Reads image data from app._detail_context which is set by the prediction screen before navigating here. Embeds a :class:NormalisationConfigWidget next to :class:ImageToolbar so the user can retune the upstream normalisation (method, channel combination, resolution, fitsbolt padding) and watch the cutout redraw in place — without leaving the screen and without writing back to cfg.normalisation (#443). The display-only transforms (log / zscale / percentile clip) stay on the toolbar.

Parameters:

Name Type Description Default
app AnomalyMatchApp

The parent application instance used for navigation.

required
Source code in anomaly_match_ui/screens/image_detail_screen.py
def __init__(self, app: AnomalyMatchApp) -> None:
    super().__init__(app)
    # Single-worker so a fast slider drag collapses to "decode the
    # latest"; combined with the seq-based supersede check the
    # widget stays at most one redecode behind the most recent
    # change.
    # Underscore prefix is deliberate (see gallery_screen_base threading
    # docs): the UI conftest joins every ``am-*``-named thread on teardown,
    # and this single worker idles in ``queue.get()`` between redecodes, so
    # an ``am-`` (dash) prefix would eat the full ~2s join timeout on every
    # UI test that opens the detail screen — and leak the worker into the
    # rest of the session, slowing the whole suite.
    self._redecode_executor = ThreadPoolExecutor(
        max_workers=1, thread_name_prefix="am_detail_redecode"
    )
    self._redecode_seq: int = 0
    # Normalisation-independent raw float32 cutout for the source on screen,
    # read once per (source, zoom) and re-normalised in-process on every
    # other widget change (#501).  The key (filename + cutout zoom) is what
    # the held raw was extracted for, so a stale hold is never reused — a
    # zoom change widens the extraction and must re-read.
    self._raw_cutout: np.ndarray | None = None
    self._raw_cutout_key: str | None = None
    self._raw_cutout_zoom: float | None = None

build()

Build and return the image detail layout.

Returns:

Type Description
Widget

The root VBox widget for the detail screen.

Source code in anomaly_match_ui/screens/image_detail_screen.py
def build(self) -> widgets.Widget:
    """Build and return the image detail layout.

    Returns:
        The root VBox widget for the detail screen.
    """
    self._out = widgets.Output(
        layout=widgets.Layout(
            border="1px solid white",
            height="100px",
            background_color=BG_COLOR,
            overflow="auto",
        ),
    )
    self._header = HeaderWidget(
        self.app, self._out, show_back_button=True, back_to="prediction"
    )

    self._preview = PreviewWidget()
    self._toolbar = ImageToolbar(self._preview)

    cfg = BackendInterface.get_config()
    self._norm_widget = NormalisationConfigWidget(
        n_channels=cfg.normalisation.n_output_channels,
        image_size=list(cfg.normalisation.image_size),
    )
    # First render mirrors the cfg currently in use so the initial
    # display matches what the gallery showed; subsequent edits are
    # detail-screen-local and never written back.
    self._norm_widget.update_from_config(dict(cfg.normalisation))
    # The detail view decodes Cutana cutouts at their native pixel size, so
    # the output-resolution field has no meaning here — hide it.
    self._norm_widget.show_resolution(False)
    self._norm_widget.register_on_change(self._schedule_redecode)

    self._info_html = widgets.HTML(value="")
    self._redecode_status = widgets.HTML(value="")

    # Group the display transforms (brightness/contrast sliders + RGB
    # toggles) with the normalisation controls in the right-hand column
    # instead of as a detached strip under the image — they belong with
    # the other per-view adjustments, next to the cutout they affect.
    display_section = VBox(
        [
            widgets.HTML(
                f"<b style='color:{ESA_BLUE_BRIGHT}; font-size:14px'>Display Adjustments</b>",
            ),
            self._toolbar.widget,
            self._toolbar.channel_controls,
        ],
        layout=widgets.Layout(background_color=BG_COLOR, gap="6px"),
    )
    controls_column = VBox(
        [self._norm_widget.widget, display_section],
        layout=widgets.Layout(background_color=BG_COLOR, gap="8px"),
    )

    return VBox(
        [
            self._header.widget,
            self._info_html,
            HBox(
                [
                    VBox(
                        [self._preview.filename_text, self._preview.image_widget],
                        layout=widgets.Layout(background_color=BG_COLOR),
                    ),
                    controls_column,
                ],
                layout=widgets.Layout(background_color=BG_COLOR, align_items="flex-start"),
            ),
            self._redecode_status,
            self._out,
        ],
        layout=widgets.Layout(background_color=BG_COLOR),
    )

on_enter()

Load image from detail context and display it.

Source code in anomaly_match_ui/screens/image_detail_screen.py
def on_enter(self) -> None:
    """Load image from detail context and display it."""
    ctx = self.app._detail_context
    if ctx is None:
        logger.warning("No detail context available")
        return

    filename = ctx["filename"]
    score = ctx["score"]
    image = ctx["image"]
    source_type = ctx["source_type"]
    search_dir = ctx["search_dir"]

    # Re-target the back button at whichever screen navigated here
    # — the same detail screen serves training, prediction, and the
    # training-setup gallery (#427), so a static ``back_to`` would
    # send users to the wrong one.
    self._header.set_back_to(ctx["back_screen"])

    # Sync the channel-combination matrix's columns to the source's actual
    # FITS bands BEFORE restoring cfg values (same order the training screen
    # uses).  Without this the matrix keeps its 3 default RGB columns, so a
    # 4-band Euclid cutout gets an (n_out x 3) matrix that can't combine a
    # 4-band array — the "cannot reshape" re-decode failure.
    if source_type == DataSourceType.CUTANA and search_dir:
        filter_names = detect_cutana_filter_names(search_dir, BackendInterface.get_config())
        logger.debug(
            "Detail on_enter: source={} search_dir={} detected bands={}",
            filename,
            search_dir,
            filter_names,
        )
        if filter_names:
            self._norm_widget.sync_cutana_bands(filter_names)
        else:
            logger.warning(
                "No Cutana bands detected for {} under {}; channel-combination "
                "matrix keeps its default columns and a multi-band re-decode may fail.",
                filename,
                search_dir,
            )

    # Re-sync the embedded NormalisationConfigWidget with the
    # current cfg so the controls match the active settings each
    # time the screen is opened.  Without this re-sync, navigating
    # between screens with different cfg would leave stale widget
    # values from the previous detail visit.
    cfg = BackendInterface.get_config()
    self._norm_widget.update_from_config(dict(cfg.normalisation))
    # Same reset for the toolbar's display transforms (brightness,
    # contrast, invert, unsharp, RGB visibility) — otherwise a
    # second visit would still show the previous visit's slider
    # positions and toggle states, even though the underlying image
    # has been re-decoded fresh.
    self._toolbar.restore()
    # Reset the supersede counter so a stale future from the
    # previous session can't push its result into this one.
    self._redecode_seq += 1
    self._redecode_status.value = ""

    if image is not None:
        self._preview.display_standalone(image, filename, score)
    else:
        self._info_html.value = (
            '<div style="color:white; font-size:14px; padding:16px;'
            ' text-align:center;">'
            f"<b>{filename}</b><br>"
            f'<span style="color:#aaa;">Score: {score:.4f}</span><br><br>'
            '<span style="color:#888;">Image not available on disk.<br>'
            "Streaming source cutouts are created in memory during prediction.</span>"
            "</div>"
        )

    # Upgrade the gallery thumbnail to the native-resolution cutout: Cutana
    # sources read the native cutout once and then re-normalise in-process,
    # so kick that initial read off now rather than waiting for the user's
    # first widget change.  Image-folder thumbnails are already the file on
    # disk and Zarr re-decode is unsupported, so only Cutana.
    if source_type == DataSourceType.CUTANA:
        self._schedule_redecode()

on_leave()

Clear the stored image array to free memory.

Source code in anomaly_match_ui/screens/image_detail_screen.py
def on_leave(self) -> None:
    """Clear the stored image array to free memory."""
    # Bump the seq so any in-flight re-decode won't push its result
    # into a freshly-built detail screen on the next visit.
    self._redecode_seq += 1
    # Drop the held raw cutout so a stack of visits doesn't accumulate them;
    # the next visit reads it fresh.
    self._raw_cutout = None
    self._raw_cutout_key = None
    self._raw_cutout_zoom = None
    self.app._detail_context = None