Source code for spacr.qt.tutorial.scripts

"""Per-app tutorial Step sequences.

Each `_build_<app>_steps(window)` returns a list of engine.Step. The
engine handles narration synthesis + capture + mux; these functions
only choose the narration text, the UI actions, and the cursor
targets.

Every script exercises the same core motion: land on the app, load
a synthetic demo dataset (via the Demos menu we shipped), highlight
the interesting parts of the settings form, then click Run.
"""
from __future__ import annotations

from typing import Any, Callable, List, Optional

from .engine import Step

[docs] AVAILABLE_TUTORIALS = [ "home", "mask", "measure", "crop", "classify", "timelapse", ]
[docs] def build_steps(app_key: str, window) -> List[Step]: """Return the tutorial Step list for ``app_key`` bound to ``window``. :param app_key: one of :data:`AVAILABLE_TUTORIALS`. :param window: live MainWindow instance the steps drive. :raises ValueError: when ``app_key`` is not a known tutorial. """ if app_key == "home": return _build_home_steps(window) if app_key == "mask": return _build_mask_steps(window) if app_key == "measure": return _build_measure_steps(window) if app_key == "crop": return _build_crop_steps(window) if app_key == "classify": return _build_classify_steps(window) if app_key == "timelapse": return _build_timelapse_steps(window) raise ValueError(f"unknown tutorial: {app_key}. " f"Choose from {AVAILABLE_TUTORIALS}")
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _go_home(window): def _do(): window._on_nav_selected("__home__") return _do def _nav_to(window, app_key: str): def _do(): window._on_nav_selected(app_key) return _do def _load_demo(window, demo_key: str, tmp_root: str): """Bypass the file dialog — call the internals directly with a scratch destination. Same code path the Demos menu uses.""" from pathlib import Path def _do(): dst = str(Path(tmp_root) / demo_key) Path(dst).mkdir(parents=True, exist_ok=True) layout = window._run_demo_generator(demo_key, dst) target_app, _ = window.DEMO_TARGETS[demo_key] window._on_nav_selected(target_app) widget = window._screens.get(target_app) if widget is not None: window._apply_demo_to_screen(widget, layout) return _do def _sidebar_button(window, key: str): for btn in window._sidebar.findChildren(type(window._sidebar)): pass # not enough info without deeper introspection from PySide6.QtWidgets import QPushButton for btn in window._sidebar.findChildren(QPushButton): if btn.text().strip().lower() == key.lower(): return btn return window._sidebar def _menu_bar(window): return window.menuBar() def _find_button(screen, label: str): """Best-effort: find a QPushButton on `screen` whose text starts with `label` (case-insensitive).""" from PySide6.QtWidgets import QPushButton if screen is None: return None for b in screen.findChildren(QPushButton): if b.text().strip().lower().startswith(label.lower()): return b return None # --------------------------------------------------------------------------- # Home tour # --------------------------------------------------------------------------- def _build_home_steps(window) -> List[Step]: return [ Step( "Welcome to spaCR — a modern desktop application " "for spatial single-cell analysis of microscopy data.", action=_go_home(window), target=(window._sidebar, (100, 40)), hold_ms=400, ), Step( "The left sidebar gives you quick access to every " "pipeline in spaCR — grouped into Core, Analysis, " "Cellpose, and Sequencing.", target=(window._sidebar, (100, 200)), hold_ms=300, ), Step( "The home page shows every app as a large clickable " "tile. Hovering makes each tile pop, and clicking " "opens the module.", target=(window._stack, (960, 400)), hold_ms=500, ), Step( "Every pipeline in spaCR ships with a one-click " "synthetic demo dataset. From the Demos menu you can " "generate a working example for any module.", action=lambda: _open_demos_menu(window), target=(window.menuBar(), (170, 15)), hold_ms=800, ), Step( "Let's jump into the mask module to see it in action.", action=_nav_to(window, "mask"), target=(window._sidebar, (100, 250)), hold_ms=400, ), ] def _open_demos_menu(window): for act in window.menuBar().actions(): if act.text().replace("&", "") == "Demos": # Just show its status tip - actually opening the menu # would block. Trigger the first action so we at least # show its effect. Actually we want to just idle here. return return # --------------------------------------------------------------------------- # Mask module tutorial # --------------------------------------------------------------------------- def _build_mask_steps(window) -> List[Step]: tmp_root = _tutorial_scratch("mask") screen_ref: List[Any] = [None] def _capture_screen(): screen_ref[0] = window._screens.get("mask") return [ Step( "This is the mask module — spaCR's front door for " "segmenting cells, nuclei, and pathogens using " "Cellpose.", action=_nav_to(window, "mask"), target=(window._sidebar, (100, 250)), hold_ms=400, ), Step( "Rather than pointing you at your own data, we'll load " "a synthetic demo from the Demos menu — this generates " "a small dataset in the correct format and fills in " "every setting.", action=lambda: (_load_demo(window, "mask", tmp_root)(), _capture_screen()), hold_ms=800, ), Step( "The settings panel on the left is now populated. " "Notice the source folder, the channel layout, " "and each object's Cellpose model — cyto for cells, " "nuclei for nuclei.", target=(_settings_panel(screen_ref[0]), None), hold_ms=400, ), Step( "The console on the right will stream every log " "record — from spaCR itself, from Cellpose, and from " "any warnings raised during the run.", target=(_console_panel(screen_ref[0]), None), hold_ms=400, ), Step( "When you hit Run, spaCR converts your images to a " "Yokogawa-style stack, normalises each channel, and " "then hands each field to Cellpose to segment.", target=(_find_button(screen_ref[0], "Run"), None), highlight=_find_button(screen_ref[0], "Run"), hold_ms=600, ), Step( "Once the run finishes, the masks land in a masks " "subfolder next to your images, ready to feed into " "the measure module.", hold_ms=400, ), ] # --------------------------------------------------------------------------- # Measure module tutorial # --------------------------------------------------------------------------- def _build_measure_steps(window) -> List[Step]: tmp_root = _tutorial_scratch("measure") screen_ref: List[Any] = [None] def _capture(): screen_ref[0] = window._screens.get("measure") return [ Step( "The measure module extracts single-object features " "from your segmented images — intensity, morphology, " "co-localization, texture, and radial distribution.", action=_nav_to(window, "measure"), target=(window._sidebar, (100, 280)), hold_ms=400, ), Step( "Load the measure demo — this ships pre-built masks " "and a measurements database seeded with the correct " "schema.", action=lambda: (_load_demo(window, "measure", tmp_root)(), _capture()), hold_ms=800, ), Step( "The demo populates the source folder, the channel " "layout, and every measurement toggle. The cell, " "nucleus, and pathogen channels can be tuned " "independently.", target=(_settings_panel(screen_ref[0]), None), hold_ms=500, ), Step( "Optionally, measure will also crop each object into " "a PNG for classify — enable Save PNG and pick a size.", hold_ms=400, ), Step( "Hitting Run walks every mask, computes features, and " "appends rows to measurements.db — one row per object, " "per timepoint if you're doing timelapse.", target=(_find_button(screen_ref[0], "Run"), None), highlight=_find_button(screen_ref[0], "Run"), hold_ms=500, ), ] # --------------------------------------------------------------------------- # Crop module tutorial # --------------------------------------------------------------------------- def _build_crop_steps(window) -> List[Step]: tmp_root = _tutorial_scratch("crop") screen_ref: List[Any] = [None] def _capture(): screen_ref[0] = window._screens.get("measure") return [ Step( "The crop demo lands you in the measure module — " "in spaCR, cropping is one of the outputs of measure, " "not a standalone step.", action=_nav_to(window, "measure"), hold_ms=400, ), Step( "Load the crop demo — this pre-fills a set of " "settings that turn measure into a pure crop-and-save " "job.", action=lambda: (_load_demo(window, "crop", tmp_root)(), _capture()), hold_ms=800, ), Step( "Save PNG is on, PNG size is 64, and PNG dims picks " "which channels get baked into the crop. You'll get " "one folder of thumbnails per object type.", target=(_settings_panel(screen_ref[0]), None), hold_ms=500, ), Step( "The crops are what feed into classify — once you " "annotate them, you have a labelled training set for " "your own CNN.", hold_ms=400, ), ] # --------------------------------------------------------------------------- # Classify module tutorial — hosted in AnnotateScreen # --------------------------------------------------------------------------- def _build_classify_steps(window) -> List[Step]: tmp_root = _tutorial_scratch("classify") screen_ref: List[Any] = [None] def _capture(): screen_ref[0] = window._screens.get("annotate") return [ Step( "Classify starts in the annotate module — this is " "where you label the crops that measure produced, so " "that classify has a training set.", action=_nav_to(window, "annotate"), target=(window._sidebar, (100, 300)), hold_ms=400, ), Step( "Loading the classify demo generates a small folder " "of pre-labelled synthetic crops so we can see the " "labelling grid without needing real data.", action=lambda: (_load_demo(window, "classify", tmp_root)(), _capture()), hold_ms=1000, ), Step( "Each tile is a single-cell crop. Left-click cycles " "through class labels — none, one, two, and back to " "none — so you can label a whole plate very quickly.", hold_ms=500, ), Step( "When you're done, the Train CV and Train XG buttons " "hand your annotations off to classify — either as a " "CNN or as an XGBoost model.", target=(_find_button(screen_ref[0], "Train"), None), highlight=_find_button(screen_ref[0], "Train"), hold_ms=500, ), ] # --------------------------------------------------------------------------- # Timelapse module tutorial — hosted in the mask module with timelapse on # --------------------------------------------------------------------------- def _build_timelapse_steps(window) -> List[Step]: tmp_root = _tutorial_scratch("timelapse") screen_ref: List[Any] = [None] def _capture(): screen_ref[0] = window._screens.get("mask") return [ Step( "spaCR handles timelapse natively — every module " "understands the T dimension in the Yokogawa filename " "convention.", action=_nav_to(window, "mask"), hold_ms=400, ), Step( "Loading the timelapse demo generates eight frames " "per field and turns on the timelapse setting. Every " "downstream module then handles tracking, motion, and " "per-frame analysis automatically.", action=lambda: (_load_demo(window, "timelapse", tmp_root)(), _capture()), hold_ms=800, ), Step( "Notice the timelapse toggle in the settings panel — " "flipping this on tells every downstream module to " "treat each field as a temporal stack rather than " "independent images.", target=(_settings_panel(screen_ref[0]), None), hold_ms=500, ), Step( "Run will then generate a per-frame mask stack, and " "measure will produce a longitudinal database with " "one row per object per timepoint.", target=(_find_button(screen_ref[0], "Run"), None), hold_ms=400, ), ] # --------------------------------------------------------------------------- # Widget lookup helpers # --------------------------------------------------------------------------- def _settings_panel(screen): """Return the settings scroll area if we can find it.""" if screen is None: return None from PySide6.QtWidgets import QScrollArea for w in screen.findChildren(QScrollArea): return w return None def _console_panel(screen): if screen is None: return None return getattr(screen, "_console", None) def _tutorial_scratch(name: str) -> str: """Per-tutorial scratch dir. Kept out of tmp so demos survive inspection after render finishes.""" from pathlib import Path p = Path.home() / ".spacr" / "tutorial-scratch" / name p.mkdir(parents=True, exist_ok=True) return str(p)