The window your image lives in is a tool in its own right — it does much more than hold a pixmap. The top-bar glyphs are shortcuts to duplicate the view, link it live to other views, copy astrometric solutions between images, apply any image as a mask (including the image as a mask of itself for an instant lightness mask), create preview regions for testing compute-heavy operations before committing to the full image, replay single steps or whole histories from a preview onto the base, overlay a WCS grid, and probe individual pixels. This page covers everything the view can do and the modifier keys that unlock each mode.
The signature idea is that every view is also a document connector. The ⧉ drag tab at the top-left looks like a window-manager grab handle, but what you drop it on, and what modifier you hold while dragging, decides what happens: no modifier opens a new window with a duplicate of the image; Alt makes the two views pan and zoom together live; Ctrl copies the astrometric solution from this image to the one you drop on; Shift turns this image into a mask of that one. And the self-mask trick — Shift+drag the handle back onto its own view — is the fastest way to get a lightness mask: no mask-creation dialog, no settings, just an instant mask from the luminance of the image itself. Combine that with the preview tabs (any rectangular ROI becomes its own tab with its own undo stack) and the ⟳ replay button (push any step from the preview onto the full image when you like what you see), and the view stops being a passive container — it's where the whole workflow is coordinated.
Across the top of every view, left to right:
| Button | Function |
|---|---|
| ⧉ | Drag tab — duplicate / link / copy WCS / apply as mask (see §2) |
| ⟂ | Create Preview — click, then drag a rectangle on the image (see §3) |
| ↶ | Undo (this view's active document — Full tab or active preview) |
| ↷ | Redo (this view's active document) |
| ⟳ | Replay last preview action on the base image (click), or pick from preview history (dropdown arrow) (see §4) |
| ⌗ | Toggle WCS grid overlay (see §6) |
| right side | Current zoom % readout; inline view title (when the window is maximised) |
The undo/redo buttons are per-view and per-tab: on the Full tab they walk the base document's history; on a preview tab they walk that preview's isolated history. See §3 for how preview history works.
The ⧉ handle at the top-left of every view is a shape-shifting drag source. The modifier key you're holding when the drag starts decides which of four payloads it carries:
| Modifier | What happens when you drop |
|---|---|
| (none) | Duplicate. Dropping onto the MDI background opens a new window with a duplicate of this image — a fully independent copy with its own undo history. Great for keeping an untouched reference while you process a second copy. |
| Alt | Link views. Dropping onto another image's view links the two for live pan and zoom sync. You can link any number of views into a group — a third drop joins all three, a fourth makes it four, and so on. Unlink from the right-click menu (see §9). |
| Ctrl | Copy astrometric solution. Dropping onto another image's view copies this image's WCS onto that one. Useful if you've plate-solved one frame and want to seed the solution onto a crop, a re-processed version, or a frame that lost its solve. |
| Shift | Apply as mask. Dropping onto another image's view sets this image as that image's active mask. A ■ badge appears in the target's title; Ctrl+K toggles the mask overlay visualisation, Ctrl+Shift+I inverts it. |
Ctrl+Shift+I to flip it: now the dim regions are selected and the brights are protected. Any subsequent operation (curves, denoise, saturation) will respect the mask. This is the fastest way to a lightness mask in SASpro, with no mask-creation dialog in the way.Preview tabs let you try out compute-heavy processing on a small part of an image before committing to the full version. Click the ⟂ button, then drag a rectangle on the image. A new tab appears beside Full labelled Preview 1 (W×H), and the view switches to showing just that cropped region at full data resolution.
Create as many previews as you like. Each one gets its own tab, its own isolated undo/redo stack, and its own ROI-synthesized WCS (so pixel readout still gives correct RA/Dec inside the preview). The ↶ and ↷ buttons on the top bar switch automatically between the Full document's history and the active preview's history as you change tabs.
What you do with a preview:
A preview's WCS is derived from the base image's WCS by shifting CRPIX1 and CRPIX2 by the ROI origin, so the preview's own pixel-to-world mapping stays accurate. If the base image was already a crop (SASpro detects this via header hints — SASKIND=ROI-CROP, CROPX/CROPY, or the ROI being plainly outside the parent's NAXIS), the shift is skipped so the preview's WCS doesn't get corrected twice.
The ⟳ button has two parts:
Replay doesn't just re-run the operation — it replays the exact preset that was used. SASpro keeps a headless-command history in the main window; the replay logic counts command-id occurrences in the preview's own step list to find the Nth matching entry in that history, pulls the preset from that entry, and routes it through the main window's base-image replay path. If a step was “Cosmic Clarity Denoise at strength 0.7, luminance only,” the replay runs with strength 0.7, luminance only — not with defaults.
Hold Space and click or drag anywhere on the image to probe individual pixels. Two things happen:
x, y), the pixel value (RGB for colour images, K for mono), and if the image has a valid WCS, the RA and Dec in HMS/DMS format.α HMS, δ DMS).Drag with Space still held to probe continuously. Release the mouse to stop; the loupe hides. Space can be released without ending the probe — it's the mouse release that ends it.
Loupe sizing and the readout strip are configurable from Preferences → Pixel Readout (patch size, zoom size, info font point size, show/hide the readout strip). Changes apply to views opened after the setting is changed.
If the image has a valid astrometric solution (plate-solved, or inherited via a Ctrl+drag copy), click ⌗ to overlay a celestial coordinate grid directly on the image. RA lines are labelled in HMS at the top edge, Dec lines in DMS at the left edge, with a thin white grid in between.
Grid spacing is automatic by default — the step is chosen to keep roughly eight grid lines across the field of view. You can switch to a fixed step from Preferences → WCS Grid (choose degrees or arcminutes, set the value). The grid is baked into the display pixmap only when the view is settled (not actively zooming or panning), so interaction stays snappy.
The toggle is per-view and resets to off on every new view. If the image has no WCS, clicking the button turns the state on but nothing visible changes (there's nothing to overlay).
Standard interactions:
The scale range is 2% to 500%. There's no fit-to-window button on the view itself (the main window has Zoom → Fit), but the mouse wheel plus a click to centre is usually faster.
Toggle autostretch for the view: Ctrl+Space or Alt+Shift+A. Autostretch is a display-only non-destructive transformation — the stored pixel data is untouched; only what's drawn changes. Turn it off and you're back to looking at the raw linear data.
Two profiles, switchable from the main-window toolbar or Preferences:
Autostretch can be linked across channels (one LUT applied to all three RGB channels equally — preserves colour balance) or unlinked (each channel gets its own LUT — neutralises colour cast but can shift hue). The linked / unlinked choice is per-view and lives in Preferences.
Alt+drag the ⧉ handle from one view onto another to link them. Once linked:
Linked views don't need to be the same size. If you link a 400×300 preview window to a 1920×1200 main image, they still track together — each view works out from its own viewport which image coordinate should be centred and nudges its own scrollbars accordingly. The sync is live during the drag, not just at drag end.
To unlink: right-click → Unlink from Linked Views. This removes the current view from the group entirely; the other members stay linked to each other.
Any image can be a mask of any other. The mechanics:
Masks are shape-matched to the target's dimensions at apply time (if the mask image is a different size, it's resampled). Operations run on the target image are modulated by the mask: fully-selected pixels (mask value 1.0) receive the full effect; fully-protected pixels (mask value 0.0) are untouched; intermediate values blend proportionally.
For richer mask creation (shape masks, content masks, star masks, Gaia-calibrated star radii), use the dedicated Mask Creation tool — the shift-drag path here is the quick route for the common case of using an existing image as a mask.
Right-click anywhere in the view for the full menu. The items (grouped by section):
Implementation notes, numbered continuing from the usage sections.
Each of the drag tab's four modes produces a different MIME type on the drag payload, so drop targets can unambiguously identify what's being offered:
MIME_VIEWSTATE — plain drag. Payload includes the source view's id, scale, scrollbar values, autostretch state, and document identity hints (doc pointer, UID, file path, base doc UID). Dropping on the MDI background makes a duplicate; dropping on another view applies the view transform.MIME_LINKVIEW — Alt+drag. Minimal payload (just the source view id); the drop handler looks up the source in the subwindow registry and establishes the link both ways.MIME_ASTROMETRY — Ctrl+drag. Payload is the source document pointer and name. The main window's drop handler resolves it and copies the WCS.MIME_MASK — Shift+drag. Payload is a mask-doc pointer plus mode (replace), invert flag, and feather radius (reserved for future). The main window's mask-drop handler applies it to the target document.Modifier state is read at the moment the drag threshold is crossed (Manhattan distance > 6px from the press point), not at drag-start, so the user can decide what mode they want after they grab.
When a view's pan or zoom changes, it emits a viewTransformChanged(scale, h, v) signal. Instead of relaying raw scrollbar values (which would require all linked views to have identical pixmap sizes), the relay computes the image fraction currently under the sender's viewport centre:
Each linked peer then applies scale and places the fraction (fx, fy) at its own viewport centre, computing its own scrollbar values from its own label dimensions. Windows of different sizes stay in sync without one dictating the other's layout.
The relay is guarded by _suppress_link_emit during the apply so echoes don't bounce back and create infinite recursion. During live panning, the normal 100ms debounce is bypassed and every mouse-move emits immediately, so linked views track the drag in real time rather than catching up after it ends.
Each preview tab keeps a list of committed operations on its backing document: doc._preview_commands, each entry a dict with command_id and the step name. The main window keeps a parallel _headless_history that records every command and its full preset.
When the user clicks a step in the ⟳ dropdown, the replay logic:
command_id appears in _preview_commands up to and including the clicked index → occurrence = N._headless_history, counting entries whose normalised cid matches, and selects the Nth one → this is the entry that corresponds to the clicked step.{command_id, preset} from that history entry, swaps it into the main window's _last_headless_command, calls replay_last_action_on_base(target_sw), then restores the previous _last_headless_command so a subsequent Ctrl+Shift+R still means “the actual last action.”If a matching history entry can't be found, the fallback is to use whatever's in _last_headless_command if its cid matches; otherwise the replay is skipped with a console warning.
Holding Space sets _space_down = True. The next left-click press (anywhere on the viewport or label) is intercepted before the normal pan handler: the viewport position is mapped to image coordinates, the pixel is sampled from the source document (not the display buffer, so values are raw and uncorrupted by autostretch or mask overlay), the loupe is positioned to the right / below the cursor (or above / left if that would go off-screen), and the status bar is updated. Mouse-move events while _readout_dragging is set continue this.
Releasing the mouse ends the readout drag and hides the loupe. Releasing Space does not end the drag — the user can start probing with Space held, then release Space while continuing to drag. Only the mouse release ends it. This matches how PixInsight-style readout tools work and avoids the frustration of accidentally releasing the probe key.
The loupe is a frameless always-on-top Qt.Tool window with WA_ShowWithoutActivating and WindowTransparentForInput, so it floats above the view without stealing focus or intercepting mouse events. Patch sampling takes the configured number of source pixels around the probe location (default 17, forced to odd so the crosshair bisects the centre pixel), with edge-aware padding when the probe is near the image boundary — the padded canvas stays the full patch size, with the real pixels inset by however much the probe was away from the edge, so the crosshair still points at the correct source pixel.
The patch is scaled to the display size (default 161, also forced odd) with Qt.FastTransformation (nearest-neighbour) so individual source pixels render as crisp uniform-colour blocks rather than a smeared gradient. The optional readout strip below picks the largest monospace point size (down to 7pt) at which all lines fit within the loupe width, so RGB with 4-decimal precision and shortened RA/Dec strings all fit without truncation on any configured size.
When a preview tab is active, the view's _get_celestial_wcs() prefers (in order):
_compute_cropped_wcs(hdr, x, y, w, h): deep-copy the base header, subtract the ROI origin from CRPIX1 / CRPIX2, update NAXIS1 / NAXIS2 to the ROI size, and tag it SASKIND=ROI-CROP.Before shifting CRPIX, the function checks whether the base header looks like it's already a crop:
NAXIS1/NAXIS2 exactly equal the ROI size but the ROI origin is non-zero → this is a crop-of-a-crop; skip the shift.NAXIS dimensions → also already cropped; skip the shift.Both cases return a copy of the parent header marked as ROI-CROP but without a double CRPIX shift. The synthesized WCS is cached on the base document's metadata keyed by the preview id, so repeated reads don't re-synthesize.
The view never builds a full-resolution 8-bit copy of the source image. The render pipeline rasterises the display buffer at the current zoom scale, capped at 8192 pixels on each side:
src × scale.src (zoom-past-100% is done by Qt upscaling the pixmap, not by numpy upsampling, since replicating pixels in numpy would just waste memory for no visual gain).When the cap engages, the effective scale the buffer represents is below what the user asked for. The view tracks this honestly as _pm_src_scale, which is used by the present-scaled call to size the label widget correctly and by all coordinate conversions (loupe, pixel readout, view-state drops, linked views) so they stay in sync with what's actually on screen. The zoom-% readout likewise reports the honest effective scale, not the requested scale.
When the user zooms in and the cached buffer becomes under-resolved for the current zoom, a 500ms debounced settle re-rasterises the buffer at the newer scale (_zoom_settle_needs_rebuild detects when the current buffer is below what a rebuild could produce and when a rebuild would improve matters, i.e. the current scale isn't already at the 8192 cap). A small translucent overlay in the viewport's bottom-left reads “Recalculating display stretch…” while the settle runs.
Autostretch builds a lookup table indexed by input value and reads output through it. The cache key is (is_mono, linked, target_median, sigma, no_black_clip, input_shape); when all six match, the previously-built LUT is reused and only the per-pixel apply runs. When any changes (profile switch, linked/unlinked toggle, no-black-clip change, image replaced), the key mismatches and a fresh LUT is built from a stride-subsampled slice of the input (step chosen so the sample is around 65k pixels — sufficient for the median/sigma statistics, orders of magnitude faster than sampling every pixel).
LUT size is 4096 entries (12-bit) by default, or 16,777,216 entries (24-bit) if the user has enabled 24-bit stretch in Preferences for maximum tonal precision. The apply function auto-detects which size was built from the LUT's length and dispatches to the matching lookup path, so changing the preference after a LUT was built doesn't crash the apply.
Edit application always targets the specific document passed in — never “the active document” by position. This matters for the headless and scripted paths, which may apply an edit to a document that isn't currently in focus.
The refresh walks the MDI subwindow list looking for the one whose widget().document is identically the target doc, and calls that view's refresh_pixmap_from_document / refresh_view / update_from_doc hook if any exists, falling back to plain update() on both the view and its subwindow. The currently-active subwindow isn't changed, so the user's focus stays where it was — even if an edit was just applied to a different view.
effective_scale so the zoom-% readout and all coord math stay truthful even when the cap engages, caches the autostretch LUT keyed on (is_mono, linked, target, sigma, no-black-clip, shape), and settles to a full-resolution re-rasterise after the zoom-in gesture stops; the ROWORDER-aware pixel readout that honours the orientation flip done by the mosaic debayer upstream so (x, y) coordinates reported to the user match the file-on-disk coordinates no matter which way the data was stored; the double-drop MIME system (MIME_VIEWSTATE / MIME_LINKVIEW / MIME_MASK / MIME_ASTROMETRY / MIME_CMD) that lets the drag tab produce typed drag payloads and lets any view accept them without ambiguity; the MDI subwindow registry keyed by id() through a WeakValueDictionary so cross-view operations (link-to, copy-wcs-to, apply-as-mask-to) resolve the target view by identity without lifetime leaks; the per-doc apply with view refresh that always targets the specific document and walks the MDI list to repaint only the subwindow bound to it, without stealing the active view's focus; the context-menu mask control cluster that exposes show-overlay / invert / remove inline when a mask is applied, in addition to Ctrl+K to toggle overlay visibility and Ctrl+Shift+I to invert; the MDI-maximised inline title that moves the view's name into the top bar when the subwindow is maximised so the user doesn't lose track of which document is open; and the send-to-shelf action that cleanly hides a view without closing its document, activates a neighbouring view so focus flows naturally, and leaves the entry retrievable from the window shelf.
astropy.io.fits.Header) and WCS pixel-to-world conversion (astropy.wcs.WCS, proj_plane_pixel_scales) used for the WCS grid overlay and the pixel-readout RA/Dec. Astropy Collaboration (2013, 2018, 2022).