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_NAMEshows up in the Scripts menu.SCRIPT_GROUPcreates a submenu group.SCRIPT_SHORTCUTbinds 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_idmay be any registered id or alias.preset_dictkeys 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")
imgcan be mono or RGB, any float dtype (converted to float32).metadatais optional FITS/XISF-style metadata dict.namebecomes 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:
ctxmethods 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.