Scripting - setiastro/setiastrosuitepro GitHub Wiki

SASpro Scripting (v1)

SASpro includes a lightweight Python scripting system so you can automate workflows, batch operations, build small custom tools, or prototype ideas quickly. Scripts can:

  • Operate on the active image/view
  • Target any open view by name
  • Combine multiple documents
  • Create their own PyQt6 UI (dialogs, dropdowns, sliders, etc.)
  • Call any headless SASpro command through ctx.run_command(...)

This page covers where scripts live, how they’re discovered, and the ScriptContext (ctx) API your scripts receive.


Where scripts live

Scripts are stored in a per-user folder that SASpro scans at startup (and on Reload Scripts).

Folder path (fixed root: SASpro/scripts):

  • Windows: %LOCALAPPDATA%/SASpro/scripts

  • macOS: ~/Library/Application Support/SASpro/scripts

  • Linux: ~/.local/share/SASpro/scripts (or $XDG_DATA_HOME/SASpro/scripts)

SASpro will automatically migrate your older scripts from the legacy Qt folder into this new location on first run.


Scripts menu

You’ll find a Scripts menu in SASpro with these built-ins:

  • Open Scripts Folder — opens the scripts directory in your OS file browser
  • Reload Scripts — re-scans the folder and rebuilds the menu
  • Create Sample Scripts — drops a few starter scripts into your folder
  • Script Editor — lets you edit/run scripts in-app

Every *.py file in the scripts folder is treated as a script candidate.


Script entrypoint + metadata

Each script must define one of these functions:

def run(ctx):
    ...

# or
def main(ctx):
    ...

You can optionally add metadata constants at top-level:

SCRIPT_NAME     = "My Tool Name"
SCRIPT_GROUP    = "My Group"
SCRIPT_SHORTCUT = "Ctrl+Shift+M"  # optional
  • SCRIPT_NAME shows up in the Scripts menu.
  • SCRIPT_GROUP creates a submenu group.
  • SCRIPT_SHORTCUT binds a hotkey if valid on your platform.

If you don’t set a name, SASpro uses the filename stem.


ScriptContext (ctx) concept

Your script is called with a single argument: a ScriptContext instance named ctx.

ctx is your stable scripting API. New helpers may be added over time, but existing ones are intended to stay compatible.


ctx API reference

Logging

ctx.log("hello from script")

Writes to the SASpro log and Script Editor output.


Active view / document access

sw   = ctx.active_subwindow()   # QMdiSubWindow | None
view = ctx.active_view()        # view widget | None
doc  = ctx.active_document()    # active document | None
base = ctx.base_document()      # base doc if ROI preview is active

ROI awareness: If the user is working in a Preview/ROI tab, active_document() returns the ROI wrapper document. base_document() returns the parent/base document.


Image data

img = ctx.get_image()  # returns doc.image or None

img is usually float32 in [0,1] (mono or RGB).

To commit data back through SASpro’s undo/ROI system:

ctx.set_image(new_img, step_name="My Script Step")

set_image() routes through DocManager so:

  • Undo works
  • ROI previews update correctly
  • Active masks remain honored by tools

Running headless SASpro commands

All built-in headless/scriptable tools go through:

ctx.run_command(command_id, preset_dict, **kwargs)

Example:

ctx.run_command("stat_stretch", {"target_median": 0.25})
ctx.run_command("remove_green", {"amount": 0.7})

Notes:

  • command_id may be any registered id or alias.
  • preset_dict keys must match the command’s Presets list.
  • Most commands automatically respect the active mask if one exists.

View / document helpers (v1)

These are the new multi-document helpers that let scripts reference open images by the user’s current view title (renamed window titles included).

List open image views

views = ctx.list_image_views()
for title, doc in views:
    print(title, doc)

Returns a list of:

[
  (current_window_title: str, document_object),
  ...
]

Titles reflect exactly what the user sees in the window tab/header.


Open a new document from numpy data

ctx.open_new_document(img, metadata={}, name="My New Doc")
  • img can be mono or RGB, any float dtype (converted to float32).
  • metadata is optional FITS/XISF-style metadata dict.
  • name becomes the new view title.

Environment helper

if ctx.is_frozen():
    ctx.log("Running from packaged build")

True if SASpro is running from the PyInstaller app.


Typical scripting patterns

1) Guard against no active image

def run(ctx):
    img = ctx.get_image()
    if img is None:
        ctx.log("No active image.")
        return

2) Edit pixels directly

def run(ctx):
    img = ctx.get_image()
    if img is None:
        return
    out = img ** 0.9
    ctx.set_image(out, step_name="Gamma tweak")

3) Combine two open documents by view title

def run(ctx):
    views = dict(ctx.list_image_views())
    a = views["Ha"]
    b = views["OIII"]

    img_a = a.image
    img_b = b.image

    out = 0.5 * (img_a + img_b)
    ctx.open_new_document(out, name="HaOIII Average")

(Using the UI sample below is safer if titles might not exist.)


UI scripting (PyQt6)

Scripts can create dialogs, dropdowns, sliders, previews, etc. Because SASpro bundles PyQt6, UI scripts don’t need extra installs.

UI sample: Average Two Documents

This script builds a simple dialog with two dropdowns listing all open views by their current window titles, averages them, and opens a new document:

# Sample SASpro script
# UI with two dropdowns listing open views by their CURRENT window titles.
# Averages the two selected documents and opens a new document.

from __future__ import annotations

SCRIPT_NAME  = "Average Two Documents (UI Sample)"
SCRIPT_GROUP = "Samples"

import numpy as np

from PyQt6.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
    QPushButton, QMessageBox
)


class AverageTwoDocsDialog(QDialog):
    def __init__(self, ctx):
        super().__init__(parent=ctx.app)
        self.ctx = ctx
        self.setWindowTitle("Average Two Documents")
        self.resize(520, 180)

        self._title_to_doc = {}

        root = QVBoxLayout(self)

        # Row A
        row_a = QHBoxLayout()
        row_a.addWidget(QLabel("Document A:"))
        self.combo_a = QComboBox()
        row_a.addWidget(self.combo_a, 1)
        root.addLayout(row_a)

        # Row B
        row_b = QHBoxLayout()
        row_b.addWidget(QLabel("Document B:"))
        self.combo_b = QComboBox()
        row_b.addWidget(self.combo_b, 1)
        root.addLayout(row_b)

        # Buttons
        brow = QHBoxLayout()
        self.btn_refresh = QPushButton("Refresh List")
        self.btn_avg = QPushButton("Average → New Doc")
        self.btn_close = QPushButton("Close")
        brow.addStretch(1)
        brow.addWidget(self.btn_refresh)
        brow.addWidget(self.btn_avg)
        brow.addWidget(self.btn_close)
        root.addLayout(brow)

        self.btn_refresh.clicked.connect(self._populate)
        self.btn_avg.clicked.connect(self._do_average)
        self.btn_close.clicked.connect(self.reject)

        self._populate()

    def _populate(self):
        self.combo_a.clear()
        self.combo_b.clear()
        self._title_to_doc.clear()

        try:
            views = self.ctx.list_image_views()
        except Exception:
            views = []

        for title, doc in views:
            key = title
            if key in self._title_to_doc:
                try:
                    uid = getattr(doc, "uid", "")[:6]
                    key = f"{title} [{uid}]"
                except Exception:
                    n = 2
                    while f"{title} ({n})" in self._title_to_doc:
                        n += 1
                    key = f"{title} ({n})"

            self._title_to_doc[key] = doc
            self.combo_a.addItem(key)
            self.combo_b.addItem(key)

        if self.combo_a.count() == 0:
            self.combo_a.addItem("<no image views>")
            self.combo_b.addItem("<no image views>")
            self.btn_avg.setEnabled(False)
        else:
            self.btn_avg.setEnabled(True)

    def _do_average(self):
        key_a = self.combo_a.currentText()
        key_b = self.combo_b.currentText()

        doc_a = self._title_to_doc.get(key_a)
        doc_b = self._title_to_doc.get(key_b)

        if doc_a is None or doc_b is None:
            QMessageBox.warning(self, "Average", "Please select two valid documents.")
            return

        img_a = getattr(doc_a, "image", None)
        img_b = getattr(doc_b, "image", None)

        if img_a is None or img_b is None:
            QMessageBox.warning(self, "Average", "One of the selected documents has no image.")
            return

        a = np.asarray(img_a, dtype=np.float32)
        b = np.asarray(img_b, dtype=np.float32)

        # reconcile mono/color
        if a.ndim == 2:
            a = a[..., None]
        if b.ndim == 2:
            b = b[..., None]
        if a.shape[2] == 1 and b.shape[2] == 3:
            a = np.repeat(a, 3, axis=2)
        if b.shape[2] == 1 and a.shape[2] == 3:
            b = np.repeat(b, 3, axis=2)

        if a.shape != b.shape:
            QMessageBox.warning(
                self, "Average",
                f"Shape mismatch:\nA: {a.shape}\nB: {b.shape}\n\n"
                "For this sample, images must match exactly."
            )
            return

        out = 0.5 * (a + b)

        new_name = f"Average({key_a}, {key_b})"

        try:
            self.ctx.open_new_document(out, metadata={}, name=new_name)
            QMessageBox.information(self, "Average", f"Created new document:\n{new_name}")
        except Exception as e:
            QMessageBox.critical(self, "Average", f"Failed to create new doc:\n{e}")


def run(ctx):
    dlg = AverageTwoDocsDialog(ctx)
    dlg.exec()

Available libraries

SASpro bundles a large set of importable libraries for scripts:

  • numpy, scipy, numba, pandas, tifffile, PIL
  • astropy, photutils, sep, astroalign, reproject, lightkurve, astroquery
  • opencv (cv2)
  • onnx / onnxruntime / onnxruntime-directml (Windows)

See Command Help → Docs → Available Libraries for the full list.


Version notes / stability

Scripting v1 is intentionally minimal and stable:

  • ctx methods are part of a growing API surface.
  • We’ll add helpers over time (multi-doc tools, selection helpers, preview widgets).
  • Existing calls are intended to remain compatible.

If you build a script you think others will love, share it in the Seti Astro community — we may include it as an official sample.