Surface Mosaic

Texture-based mosaic builder for lunar, solar and planetary surface panels — the kind of mosaic PTGui is traditionally used for, now native to SASpro. Unlike Mosaic Master (which uses WCS and stars), there's nothing to plate-solve here, so tiles are registered by their surface texture directly. Includes a four-tier alignment ladder (ORB → SIFT → patch-slide correlation → shared-disk geometry), a ghost-proof overlap NCC gate, matching-only multiscale structure enhancement for soft stacks, a global shared-disk solution that links limb-bearing tiles even when they don't overlap, a radial limb-darkening photometric model that couples brightness equalisation across topology, per-tile quadratic gradient-surface fitting, and confined local seam warping with double-gated AP shifts.

Original work for SetiAstro Suite Pro. The entire texture mosaic pipeline is new work by Franklin Marek, with edge-case robustness shaped by extensive test data contributed by the SetiAstro Discord community. SASpro-exclusive content: the four-tier alignment ladder (ORB corner features → SIFT blob features for soft texture → multi-scale patch-slide correlation search with grid dispatch → global shared-disk geometry) with a ghost-proof overlap NCC gate applied at every tier; the matching-only multiscale structure boost (reuses SASpro's multiscale decomposition to amplify mid-band detail layers 2–3 at ~2–4 px structure that sits above the per-pixel noise which dominates layer 1 on soft stacks, so even unsharpened panels register, never touching output pixels); the shared-disk solution for solar and lunar panels (percentile-threshold limb extraction robust to 98%-disk tiles where Otsu silently splits inside the disk, Kasa best-conditioned hub fit for the shared radius R, Gauss-Newton fixed-radius centre refit from a geometric seed “arc midpoint + R toward disk-mask centroid” so a sliver arc nearly tangent to the disk can't put the centre on the wrong side); the global limb edges that link limb-bearing tiles through the shared disk centre regardless of mutual overlap; the gain-only sky-safe photometry (no bias term anywhere — g·0 = 0 so the sky can never be lifted, with bright-threshold masking so glint and background pedestal never enter the fit); the radial limb-darkening photometric model (per-bin ratio-of-medians against a global μ(r/R) profile which couples brightness equalisation across tiles with zero mutual overlap, iterated weighted-median solve robust to glint-corrupted bins); the per-tile log-domain quadratic gradient-surface fit (6-coefficient polynomial per tile solved jointly from overlapping bright pixel samples via block-diagonal normal equations, ridge-pinned at the gauge, limb-masked so no sky or near-limb pixel enters, multiplicative so the sky still cannot be lifted); the spanning-tree pose seeding for the robust similarity solve (composes pairwise transforms as proper 2D similarities with t_j = t_i + R(θ_i)d, so rotated tiles land near their final pose before the robust-loss refine); the confined local seam warp (structure gate on local HF std, response gate on phase-corr strength, median-shift guard, seam-band confinement so one edge's correction cannot reach another, boundary feathering so non-overlap pixels are never displaced); the multiband winner-take-all compositing with no-data fill (prevents Laplacian-pyramid dark-cliff bleed at tile rectangles); the pre-warp parallel + sequential blend pipeline architecture; the pose-aware per-tile bounding-box warping with 2-pixel pad against INTER_LINEAR edge darkening; and the connected-component unplaced-tile reporting are all original work.
Workflow
Add views — stack each panel of your lunar/solar/planetary surface mosaic to completion in SASpro first, then open the Surface Mosaic dialog and check off the finished stacks you want to mosaic. Pick options — usually just leave the defaults; the ladder self-selects which tier solves each pair. Build Mosaic. The result opens in a new SASpro view.

The signature property of the entire photometric pipeline: everywhere a brightness correction is applied, it's a pure gain, no bias term, so the sky can never be lifted:

I_{\mathrm{out}} = g_i \cdot I_i,\quad g_i \cdot 0 = 0

Lunar and solar panels sit on near-black sky even after stacking; a photometric model with a bias term can equalise the surface brightness perfectly and leave each tile with a different sky level, which bleeds visible rectangles when the tiles get composited. Gain-only means a corrected sky pixel is still a sky pixel. The same property holds for the radial model (§9.14), the per-tile gradient surface (§9.15), and compositing: §9.14 explains why that was more work than it sounds like.

Contents
1. What it's for
2. Dialog layout
3. Picking tiles from open views
4. Main options
5. Advanced panel
6. Build Mosaic
7. Tile layout overview
8. Unplaced tiles
9. Technical details
9.1 Pipeline overview
9.2 Matching enhancement
9.3 ORB feature matching
9.4 SIFT fallback
9.5 Patch-slide correlation search
9.6 Overlap NCC ghost gate
9.7 Pair subpixel refine
9.8 Limb extraction
9.9 Shared-disk solution
9.10 Fixed-radius centre fit
9.11 Limb edges
9.12 Translation-only bundle
9.13 Similarity bundle
9.14 Overlap photometry (gain-only)
9.15 Radial limb-darkening model
9.16 Per-tile gradient surface
9.17 Compose pipeline
9.18 Local seam warp
9.19 Blend modes
9.20 Multiband no-data fill
9.21 BBox warping
9.22 Parallelisation

1. What it's for

Lunar, solar and planetary surface mosaics — the use case for which astrophotographers typically reach for PTGui or similar panoramic stitchers. Each panel is already a finished high-resolution stack (from SASpro's Planetary Stacker or anywhere else), and the mosaic job is to lay those panels down into one seamless surface image. Nothing to plate-solve against: there are no stars, no WCS, just texture.

Typical input: 4–20 lunar panels covering a terminator region, or a full-disk solar mosaic from an etalon rig at native resolution, or a Jupiter/Saturn mosaic merging separate rotation-compensated stacks of different disk regions. The tool is designed to handle all three without the user telling it which case they're in — the shared-disk / radial-model path activates automatically when a disk edge is found, otherwise the pipeline runs in texture-only mode.

2. Dialog layout

Two columns. The left column holds the view-selection list, four option checkboxes, the blend-type dropdown, progress bar, status label, and Build Mosaic / Advanced… buttons. The right column shows a schematic Tile layout overview that updates after each run — translucent rectangles for every tile, match-graph edges between overlapping pairs with thickness scaled by confidence, per-tile labels, and a star marker on the anchor tile. A dashed outline frames the whole mosaic canvas.

3. Picking tiles from open views

Tiles are always open SASpro views — never files. The view list shows every open view with image data; checkboxes let you tick each panel to include. Buttons:

At least two tiles must be checked for the Build button to do anything.

4. Main options

Solve rotation

Off by default. Lets the global bundle adjustment solve each tile's rotation in addition to its translation. Needed when your panels came from different sessions or different rotator angles. Requires SciPy for the robust nonlinear solve (§9.13); without SciPy, SASpro falls back to the spanning-tree seed, which already places rotated tiles but can't close long loops as cleanly. Leave off for the common case of a single-session mosaic where all panels share orientation.

Equalise brightness

On by default. Runs the gain-only photometric equalisation (§9.14), with the radial limb-darkening model (§9.15) taking over automatically when a disk is detected. Also enables the per-tile gradient-surface fit (§9.16) when solving in translation-only mode. Leave on — the gain-only property means it can only help.

Local seam warp

On by default. Measures a non-rigid seam correction between each new tile and the already-composited neighbours, confined to a band around each seam (§9.18). This fixes residual mis-registration from imperfect stacks or atmospheric seeing differences between panels without warping the panel interiors. Guard-gated so a soft or structureless seam keeps the global placement rather than getting a confident-but-wrong local correction.

Blend Type

Dropdown with two choices:

5. Advanced panel

Click Advanced… to open the parameter editor. Groups:

Matching enhancement

Registration

Seam / local warp

Brightness / Blending

Don't touch unless you know what you're doing

6. Build Mosaic

Launches the alignment ladder, global bundle solve, photometric equalisation, and compositing in a background worker thread. Progress bar + status text update per phase (“Matching tiles”, “Solving global placement”, “Equalising brightness”, “Warping tiles”, “Compositing”). The dialog stays responsive; you can watch the Tile layout panel update at the end.

The finished mosaic opens as a brand-new SASpro view, exactly as if you'd loaded a file — it has no attached WCS header (there is nothing to attach), and goes into the view manager with the title “Surface Mosaic”.

7. Tile layout overview

After every successful build, the right-hand panel draws a schematic of the solved layout:

8. Unplaced tiles

If one or more tiles couldn't be matched to the connected set rooted at the anchor, SASpro excludes them from the mosaic entirely rather than drop them at some arbitrary offset. A warning dialog then lists them by name and suggests remedies (more overlap, sharper stacks, matching-boost in Advanced).

This is a deliberate design choice: a “mosaic” with a floating stray tile shoved into a corner is worse than one with the problem panel honestly left out. The ladder has already given up on every tier for that pair; a fifth tier of guessed placement wouldn't be reliable.

9. Technical details

The four-tier alignment ladder, bundle adjustment, photometric equalisation, and compositing pipeline, in order.

9.1 Pipeline overview

End-to-end flow:

  1. Pre-compute tile caches — sharpened match mono (§9.2), ORB keypoints, SIFT keypoints, correlation-search proxy. One job per tile, so each cache has a single writer and the subsequent matching phase is thread-safe.
  2. Pairwise alignment ladder — for every tile pair, try ORB (§9.3) → SIFT (§9.4) → patch-slide correlation (§9.5), stopping at the first tier that produces a candidate passing the overlap NCC gate (§9.6). Pairwise is O(n²) but every pair is independent, so matching runs across threads.
  3. Shared-disk global stage (if the limb fallback is on and rotation is off): extract limb points from every tile (§9.8), solve the shared disk (§9.9), emit hub-to-tile edges for every limb-bearing tile not already connected by a texture match (§9.11).
  4. Global bundle adjustment — translation-only weighted LS (§9.12), or robust similarity solve with scipy (§9.13). Unplaced tiles (not reachable from the anchor through the overlap graph) are set aside.
  5. Photometric equalisation — radial limb-darkening model if a disk was found (§9.15), otherwise overlap gain fit (§9.14).
  6. Gradient surface fit (translation-only solves) — per-tile quadratic log-domain correction (§9.16).
  7. Compose — parallel pre-warp into per-tile bounding boxes, sequential seam measurement and blend (§9.17–§9.21).

9.2 Matching enhancement

Soft or lightly-processed stacks (common from solar etalons, or lunar panels straight out of AutoStakkert without the usual wavelet pass) have most of their structure buried below the per-pixel noise. ORB and SIFT both detect on the finest-scale content, which on soft data is noise — they return zero matches not because there's nothing to match but because the matcher is looking at the wrong scale.

SASpro boosts mid-scale structure using its shared multiscale decomposition (the same code behind SASpro's Multiscale Decomposition tool). The image is decomposed into detail bands plus a residual:

I = \sum_{\ell=1}^{N} D_{\ell} + R_{N}

and reconstructed with per-layer gains:

I_{\mathrm{match}} = \sum_{\ell=1}^{N} g_{\ell} \cdot D_{\ell} + R_{N}

Defaults: 4 layers, gains (1.0, 6.0, 6.0, 1.0). Layer 1 (~1 px) is where per-pixel noise lives — left alone. Layers 2 and 3 (~2 px and ~4 px structure) sit above the noise floor on soft data but are too faint to drive feature detection unaided — boosted at 6×. Layer 4 (~8 px and coarser) is already strong enough naturally and would just add haze if boosted.

Crucial property: this is matching-only. The mosaic is always composited from the original tile pixels, so the enhancement can be as aggressive as needed to make the ladder work without compromising output fidelity. The sharpened proxy is cached on every tile under _match and reused by every alignment measurement (ORB, SIFT, correlation search, subpixel refine, local seam warp).

9.3 ORB feature matching

Tier 1a of the ladder, for crisp texture (craters with sharp rims, prominent granulation). ORB (Rublee et al. 2011) detects FAST corner keypoints and describes them with rotation-invariant binary BRIEF descriptors.

  1. Downscale the sharpened match mono to feature_max_dim (default 1600 px) and contrast-normalise (1st to 99.5th percentile stretch to [0, 255] uint8).
  2. Detect ORB keypoints and descriptors (cv2.ORB_create(nfeatures=4000)).
  3. Scale keypoints back to full-resolution tile coordinates for the subsequent subpixel refinement.

Pair matching: brute-force Hamming between tile j's descriptors and tile i's descriptors with Lowe's ratio test. For each query descriptor, keep its two nearest matches in the training set; accept the better match only if:

\frac{\|d_1\|}{\|d_2\|} < \tau_{\mathrm{Lowe}}

with default τ = 0.75. The accepted matches feed cv2.estimateAffinePartial2D(method=RANSAC) for a similarity transform (rotation + uniform scale + translation) with inlier threshold ransac_thresh (default 3 px at detection scale). The transform's translation is (dx, dy) for the pair; the rotation is extracted via atan2(M[1,0], M[0,0]).

9.4 SIFT fallback

Tier 1b: ORB can't see what isn't a corner. On smooth lunar mottling (lava-plain lowlands with gentle albedo variation) or on soft granulation, SIFT's DoG blob detector (Lowe 2004) finds plenty of keypoints where ORB finds none.

Same pipeline as ORB: detect on the enhanced match mono downscale, scale keypoints back, Lowe-ratio match (now against cv2.NORM_L2 because SIFT descriptors are floats, not binary), RANSAC similarity fit.

Only tried if ORB returned no validated pair. The matcher pays the SIFT cost (slower than ORB by a few×) only on the pairs that actually need it.

9.5 Patch-slide correlation search

Tier 2: when neither feature detector returns anything, SASpro falls back to a feature-free exhaustive translation search. The naive version — full-frame phase correlation between the two tiles — assumes both frames image the same scene, which is wrong for mosaic panels that only partially overlap.

SASpro's approach: slide small patches extracted from tile j over tile i using normalised cross-correlation template matching (cv2.matchTemplate(TM_CCOEFF_NORMED)). Partial overlap is handled by construction — a patch cut from the edge of j only needs to find its sibling patch within i, wherever it lives.

Grid: a 3×3 dispatch of patch anchor positions (0%, 39%, 78% in each axis) at two patch sizes:

Patches with standard deviation below 25% of the global proxy std are discarded as too flat to localise reliably. Surviving patches contribute their peak position to a candidate list. Full-frame phase correlation is also tried (sometimes it works). All candidates are dedup'd (3-px bins at proxy scale) and scored by coarse overlap NCC; the winner proceeds to the subpixel refine, then the overlap gate.

The correlation proxy itself: tile mono blurred by σ = 1 (denoise) then high-passed by subtracting a 16σ blur (so the smooth illumination profile — disk falloff, limb-darkening — is removed but structure at every finer scale survives). A narrower bandpass would sit below some soft data's natural scale and leave only noise; 16σ is wide enough to preserve 8 px structure while killing the slow sweep across the disk.

9.6 Overlap NCC ghost gate

Every candidate from every tier must pass one final test before SASpro accepts it: measure the normalised cross-correlation of the actual overlap pixels after applying the candidate transform, and require:

\mathrm{NCC}(A, B) = \frac{\sum (A - \bar{A})(B - \bar{B})}{\sqrt{\sum (A - \bar{A})^2 \cdot \sum (B - \bar{B})^2}}
\mathrm{NCC}(A, B) \geq \tau_{\mathrm{NCC}}

with default τ_NCC = 0.15. Measurement is on bandpassed match images (narrow Gaussian minus wide Gaussian, source-content bandpass rather than Hann-windowed, so the warp's zero border doesn't taint the blur) — critical for solar/lunar data, because a smooth bright disk would correlate at almost any offset and would validate a ghost on raw pixel values. The true overlap only stands out in high-frequency structure.

Why it matters: all three alignment tiers can produce confident-looking wrong answers on textureless input. RANSAC with few inliers can lock onto a lucky coincidence of descriptor matches; patch-slide correlation on a nearly-flat tile can peak on noise. The overlap NCC gate rejects every such ghost by measuring the actual thing you care about: does the content agree where you placed it?

9.7 Pair subpixel refine

Candidates from any tier are refined using the SASpro planetary stacker's primitives on the pair's integer overlap crop:

  1. Bandpass each overlap crop (Hann-windowed, illumination-robust) via _bandpass.
  2. Phase correlation for subpixel translation residual (_phase_corr_shift):
P(u, v) = \mathcal{F}^{-1}\!\left[\frac{F_A \cdot \overline{F_B}}{|F_A \cdot \overline{F_B}|}\right]

Normalising to unit magnitude in the Fourier domain makes the peak location (which is the translation) independent of the overlap's local brightness, following Kuglin & Hines (1975).

3. Gradient-SSD polish (_refine_shift_ssd) — a few Gauss-Newton steps against image gradients to drive sub-sub-pixel residuals to zero. 4. Fourier-Mellin rotation (_estimate_rotation_fm) when rotation refinement is enabled: log-polar transform the two bandpassed overlaps and phase-correlate the result to recover the rotation angle, following De Castro & Morandi (1987):

(x, y) \to (\rho, \phi) = (\log r, \arctan(y / x))

Pairs with coarse rotation > 3° skip the integer-overlap refinement entirely — the overlap crop would compare rotated against unrotated content, confusing phase correlation; SASpro trusts the ORB similarity fit (which used the whole overlap rotation-invariantly) and lets the local seam warp clean up residual at compose time.

9.8 Limb extraction

For solar and lunar mosaics, extracting the disk edge turns the mosaic into a global problem: every limb-bearing tile lies on the same disk, so the disk centre is a shared reference. The extraction, per tile:

  1. Threshold the mono tile at a percentile midpoint: thr = p_{0.2} + 0.35 · (p_{99.5} - p_{0.2}). Percentile, not Otsu — and this matters: Otsu assumes a bimodal histogram, which fails on tiles that are 98% disk with a sliver of sky in one corner, where the histogram is unimodal and Otsu silently splits within the disk. The 0.2th percentile samples the sky whenever it covers at least 0.2% of the frame, so even a corner sliver yields a clean disk / sky split.
  2. Morphology: open-close with a 5×5 kernel to clean small artefacts.
  3. Gradient of the disk mask gives the inner edge of the disk; dilate the sky mask and AND with the gradient to keep only pixels on the disk edge adjacent to sky (not internal mask artefacts).
  4. Edge-exclude a 3-pixel border so the frame cut doesn't contribute false boundary points.
  5. Return the surviving pixels (xs, ys) as the limb arc, plus the disk-mask centroid (c_x^{disk}, c_y^{disk}).

The disk-mask centroid is critical for the fixed-radius centre fit (§9.10): it tells SASpro which side of the arc the disk centre physically lies on, even when the arc itself is nearly straight.

9.9 Shared-disk solution

With limb arcs extracted from every tile that showed one, SASpro solves the shared disk globally:

  1. Hub selection: for each tile with a successful free-arc fit (Kasa algebraic least-squares), score as len(arc) / (1 + rms). Pick the tile with the highest score as the hub.
  2. Shared radius R: the hub's free-fit radius.
  3. Sanity: reject if R < 0.2 · min(tile_dim) or R > 200 · max(tile_dim).
  4. Per-tile centre refit at fixed R (§9.10).

Kasa's algebraic circle fit:

(x - c_x)^2 + (y - c_y)^2 = r^2

linearises to:

2 c_x \cdot x + 2 c_y \cdot y + c = x^2 + y^2

with c = R² - c_x² - c_y², solvable by one LS solve. The hub needs this because its radius seeds every other tile; the other tiles use fixed-R Gauss-Newton instead because a free Kasa fit on a short arc is unstable.

9.10 Fixed-radius centre fit

Each non-hub tile's limb arc is refit at the shared radius R fixed. The arc might be a tiny crescent — nearly a straight line — and an unconstrained fit on nearly-collinear points puts the centre at a chord perpendicular bisector, which is only loosely constrained along the line of the chord and can land on the wrong side of the arc (making the disk centre look like it's outside the tile when it should be inside, or vice versa).

SASpro seeds the fit geometrically, using the disk-mask centroid to pick the correct side:

\mathbf{c}_{\mathrm{seed}} = \mathbf{m}_{\mathrm{arc}} + R \cdot \frac{\mathbf{c}_{\mathrm{disk}} - \mathbf{m}_{\mathrm{arc}}}{\|\mathbf{c}_{\mathrm{disk}} - \mathbf{m}_{\mathrm{arc}}\|}

where m_arc is the arc midpoint and c_disk is the disk-mask centroid. The seed starts R away from the arc midpoint in the direction of the disk interior — which is where the centre has to be. Gauss-Newton then minimises:

r_i = \sqrt{(x_i - c_x)^2 + (y_i - c_y)^2} - R

on this seed and lands in the right basin. 30-iteration cap with early stop at update < 10&sup{-3} px. Final RMS gated at max(3, 0.05·R); a tile whose points don't lie on a circle of radius R is dropped from the solution.

9.11 Limb edges

The shared disk gives every limb-bearing tile a position — the tile centre in mosaic coordinates is (hub_centre - tile_centre_in_tile_coords). SASpro emits hub-to-tile edges from this, but only after validating each:

This is what lets the lunar mosaic tool stitch panels that cover opposite sides of the disk without sharing any sky between them — a case where every other technique (feature matching, correlation search, WCS reprojection) has nothing to work with.

9.12 Translation-only bundle

With pairs collected, SASpro solves tile poses globally by weighted least squares. Each pair contributes two constraints:

t_{x,j} - t_{x,i} = d_{x,ij}

(symmetric for y) with weight w_ij. The joint objective:

\min_{\mathbf{t}} \sum_{(i, j) \in \mathcal{P}} w_{ij} \cdot \|(\mathbf{t}_j - \mathbf{t}_i) - \mathbf{d}_{ij}\|^2

has independent closed-form solutions for x and y (both are linear). The anchor tile is pinned at (0, 0) by zeroing its row and setting A[anchor, anchor] = 1, b[anchor] = 0 — the gauge-fixing step.

The normal equations are n×n for n tiles, which is small (dozens to a few hundred for even very large mosaics), so direct np.linalg.solve is fine; the lstsq path is a safety net for singular configurations.

9.13 Similarity bundle

When rotation is on and SciPy is available, SASpro solves the joint (t_x, t_y, θ) system robustly. Two parts:

Spanning-tree pose seeding

Propagate pairwise transforms out from the anchor along a spanning tree of the overlap graph, composing the transforms as real 2D similarities:

\mathbf{t}_j = \mathbf{t}_i + R(\theta_i) \cdot \mathbf{d}_{ij}
\theta_j = \theta_i + \Delta\theta_{ij}

with

R(\theta) \cdot \mathbf{d} = (\cos\theta \cdot d_x - \sin\theta \cdot d_y,\;\sin\theta \cdot d_x + \cos\theta \cdot d_y)

Rotating the pairwise translation d_ij through θ_i is the step a naive translation sum would skip — without it a rotated tile seeded off a rotated anchor lands at the wrong position. BFS from the anchor; tiles not reachable through the graph get (0, 0, 0).

Robust nonlinear refine

Pack each tile's free pose (every tile except the anchor) into a 3-vector (t_x, t_y, θ), build pairwise residuals:

r_{ij} = w_{ij} \cdot \left[ (\mathbf{t}_j - \mathbf{t}_i) - R(\theta_i)\,\mathbf{d}_{ij} \right]
r_{\theta} = w_{ij} \cdot k \cdot \mathrm{wrap}(\theta_j - \theta_i - \Delta\theta_{ij})

and solve via scipy.optimize.least_squares with soft_l1 loss:

\rho(r) = 2 \left( \sqrt{1 + r^2} - 1 \right)

which grows roughly linearly for large r instead of quadratically. A single pair with a bad match therefore can't drag the solution the way plain LS would. The scale factor k on the angular residual puts rotation in pixel-equivalent units (k = (image_diag) / 2, so a 1-rad angular error produces about one tile-radius of edge misalignment) — otherwise mixing radians and pixels in one cost function gives whatever units dominated the raw numbers all the weight.

If SciPy isn't installed, the spanning-tree seed is kept as-is. It can't close long loops the way the robust refine does, but it's dramatically better than translation-only (which ignores rotation entirely).

9.14 Overlap photometry (gain-only)

With poses solved, every tile gets a single scalar gain g_i. Equal-sky property first:

g \cdot I_{\mathrm{sky}} \approx g \cdot 0 = 0

No bias term anywhere. For each overlapping pair:

  1. Measure median bright content on both sides of the overlap. “Bright” is I > 0.3 · p_{95}(I), so sky and glint are excluded:
\mathcal{M}_{\mathrm{bright}} = \{(x, y) : I_i > 0.3 \cdot p_{95}(I_i) \;\wedge\; I_j > 0.3 \cdot p_{95}(I_j)\}

2. Constrain the log-gain ratio by the median bright levels: g_i · med_a = g_j · med_b, i.e.:

\log g_i - \log g_j = \log\!\left(\frac{\tilde{I}_j}{\tilde{I}_i}\right)

3. Collect all pair constraints into a weighted LS system in log g, with a prior toward log g = 0 (gain of 1) controlled by σ_g. Solve. Centre the solution around its median (remove the overall brightness drift, keep only the relative corrections). Exponentiate and clamp each gain to [0.2, 5.0].

9.15 Radial limb-darkening model

For solar and lunar mosaics, overlap-pair equalisation has an obvious limitation: tiles that don't overlap each other get no direct brightness constraint, and any brightness drift propagates along the overlap graph only.

The physical truth is that limb-darkening is a radial profile shared by every tile on the disk. SASpro leverages the shared-disk solution (§9.9) to equalise against this global profile instead of against neighbours. The procedure:

  1. Downsample each tile to 384 px (fast binning) and compute r/R for every pixel using the shared disk centre.
  2. Bin pixels by r/R into 96 bins over [0, 1], taking per-tile per-bin medians I_k(ρ_b).
  3. Initialise g_k = 1 for every tile.
  4. Build the global profile as a count-weighted mean of gain-corrected per-tile medians:
\mu(\rho) = \mathrm{median}\{g_k \cdot I_k(x, y) : r_k(x, y) / R \in [\rho, \rho + \Delta\rho]\}

5. Re-solve each tile's gain as the count-weighted median of per-bin ratios between the profile and the tile's own bins:

g_k = \mathrm{wmedian}\!\left(\frac{\mu(\rho_b)}{\tilde{I}_k(\rho_b)}\right)

6. Iterate twice. Centre by median. Clamp to [0.2, 5.0].

Weighted median (not weighted mean) is deliberate: a few bins contaminated by glint, residual stacking artefacts or near-limb saturation would drag a least-squares fit; the median shrugs them off. Only pixels inside the disk and above I > 0.05 are considered, so sky never enters the profile.

The decoupling is the point: the model couples brightness across tiles with zero mutual overlap, because the physical radial profile is the same for both. A nine-tile Sun mosaic laid out as a 3×3 grid gets every tile equalised correctly even when only the centre tile overlaps all eight neighbours.

9.16 Per-tile gradient surface

Solar and lunar panels often carry smooth per-panel brightness gradients from etalon sweet-spots, vignetting, or imperfect flats. A single scalar gain per tile can't compensate — neighbouring tiles' gradients disagree across the seam, and the mosaic shows seams where the gradients differ.

SASpro fits a per-tile quadratic correction surface in the log domain, over normalised tile coordinates (u, v) ∈ [-1, 1]:

\mathrm{poly}(u, v) = c_0 + c_1 u + c_2 v + c_3 u^2 + c_4 uv + c_5 v^2

applied multiplicatively:

s(x, y) = \exp(\mathrm{poly}(u, v)),\quad I_{\mathrm{out}} = s(x, y) \cdot I_{\mathrm{in}}

Still multiplicative, so the sky stays at zero. The six coefficients per tile are solved jointly across all tiles via block-diagonal normal equations. Each overlap contributes a linear constraint:

\mathrm{poly}_i(u_i, v_i) - \mathrm{poly}_j(u_j, v_j) = \log\!\left(\frac{I_j}{I_i}\right)

at every sampled bright pixel in the overlap. Why quadratic and not plane: an interior tile with four neighbours needs to agree with all four at once, which a single linear tilt cannot do — curvature is required to bend down on one side and up on the other. On linear-gradient data, the quadratic solve just lands with c_3 ≈ c_4 ≈ c_5 ≈ 0 and behaves like a plane.

Ridge prior pins the gauge (c_0) and keeps curvature coefficients smooth. Samples are limb-masked (r < 0.98R) and bright-in-both, so no sky or near-limb pixel enters the fit. Solved only in translation-only mode (gated on !solve_rotation) — the full similarity solve plus a per-tile 6-parameter gain surface would be an under-constrained joint problem on typical overlap counts.

9.17 Compose pipeline

Two phases, with the division of labour chosen to maximise parallelism:

Phase A — pre-warp (parallel)

For every placed tile, independently (no shared state):

  1. Compute the tile's canvas-space bounding box (_tile_bbox) with a 2-px pad.
  2. Apply g_i and the gradient surface s_i(x, y) to the original tile.
  3. Warp the original, a valid-pixel mask, and the sharpened match copy into the bounding box via cv2.warpAffine(INTER_LINEAR).

Each tile produces a small buffer, keyed by its bounding-box origin on the mosaic canvas. Memory scales with footprint rather than canvas size, which matters for large mosaics (a 20×20 lunar mosaic has one tile's worth of pixels at any point in Phase A).

Phase B — sequential seam + blend

Tiles are composited in brightest-first order (highest quality_score first) so the local seam warp always has a well-stacked reference to measure against. For each tile in turn:

  1. Measure the local seam warp (§9.18) against the running mosaic (restricted to this tile's bounding box).
  2. Apply the dense displacement field to both the warped tile and its match proxy.
  3. Compute the feather weight (distance transform of the valid mask) and optionally multiply by the quality score.
  4. Depending on blend mode: feather-accumulate (sum += I · w, wsum += w) or deposit into the multiband blender store.
  5. Update the running match canvas (max of existing content and new tile) for the next tile's seam measurement.

9.18 Local seam warp

Even with WCS-free global poses solved perfectly, individual stacked panels can carry residual local distortion from seeing differences or imperfect stacking. The local seam warp fits a non-rigid dense displacement field per tile.

Measurement runs on the sharpened match images (so phase correlation sees enough structure), and the recovered field is applied to the original pixels. Procedure:

  1. Autoplace APs over the overlap between this tile and the already-composited running mosaic (grid at ap_spacing, size ap_size, over pixels where cur & ref both have data).
  2. Gate 1 — structure: compute local high-pass std inside each AP window; drop APs whose structure is below 50% of the median structure. Low-structure APs can't localise reliably and would just add noise.
  3. Phase correlation per AP (_ap_phase_shifts_per_ap) — returns each AP's local shift and response strength.
  4. Gate 2 — response: drop APs with response below ap_min_response (default 0.30) or flagged by MAD z-score outlier rejection (_reject_ap_outliers).
  5. Guard — well-placed: if the median AP shift magnitude is below local_warp_min_shift (default 0.5 px), skip the warp. The APs are measuring sub-pixel noise, not real distortion.
  6. Confined dense field: for every point on a grid over the tile, interpolate from the nearby APs only, with Gaussian weighting:
w_{\mathrm{AP}}(x, y) = r_{\mathrm{AP}} \cdot \exp\!\left(-\frac{\|(x, y) - \mathbf{c}_{\mathrm{AP}}\|^2}{2 \sigma^2}\right)

and

\mathbf{s}(x, y) = \frac{\sum_k w_k(x, y) \cdot \mathbf{s}_k}{\sum_k w_k(x, y)}

Band width σ = 2 · ap_size (default; configurable via seam_band_mult). A pixel with no AP support within ~σ gets zero displacement, not an extrapolation from a distant edge's APs. This confines each seam's correction to a band around that seam, so a big pull on one edge cannot propagate across the tile and disturb the opposite edge.

7. Boundary feather: compute the distance transform of the overlap mask and feather the field linearly to zero at the overlap boundary (over ap_size pixels). Non-overlap pixels can't be measured, so they get the global placement rather than an extrapolated local correction.

8. Apply the dense field via cv2.remap to both the pixel data and the match proxy.

9.19 Blend modes

Feather (default)

Distance-transform feather weights:

w(x, y) = \frac{d_{\partial\mathcal{M}}(x, y)}{\max\, d_{\partial\mathcal{M}}}

normalised per tile to a [0, 1] range. Optionally multiplied by the tile's quality score (sharper tiles get stronger weight near the seam midline). Accumulated: sum += I · w, W += w, final mosaic = sum / W. Fast and visually clean when photometric equalisation has done its job.

MultiBand

OpenCV's Laplacian-pyramid blender (cv2.detail_MultiBandBlender, available with opencv-contrib-python). SASpro drives it in winner-take-all mode: each pixel gets owned by exactly one tile (the one with the highest feather × quality weight), and the blender does the cross-band smoothing across those hard ownership transitions.

At each pyramid level ℓ, the output Laplacian is:

L_{\ell}^{\mathrm{out}} = \sum_i W_i^{\ell} \cdot L_i^{\ell}

with the owner mask low-passed down through the pyramid levels so finer details hand off at the exact seam while coarser bands smooth out across a wide crossover zone. Final collapse of the output pyramid back to full resolution.

Why winner-take-all masks instead of soft masks: feeding the multiband blender soft (overlapping) masks confuses its weight normalisation across pyramid levels and ghosts the output. Hard ownership + per-level smoothing is the standard way to drive this blender for mosaic work and is what Brown & Lowe (2007) describe in their panorama stitching paper.

None

Binary ownership, no feather at all. Debug mode — useful when inspecting where tiles disagree.

9.20 Multiband no-data fill

Multiband blending has an issue with tile rectangles: at the tile edge, a Laplacian pyramid sees a hard cliff from zero (border fill) to actual content, and that cliff bleeds down through the coarse pyramid levels, printing a thin dark ring along the seam even when the ownership masks are correct.

SASpro fixes this by replacing the no-data region with a smooth extension of the tile's own content before feeding the blender. For each tile buffer:

  1. Gaussian-blur the whole tile (sigma = 8).
  2. At valid pixels, use the original value; at invalid pixels, use the blurred value.
  3. Iterate 5 times.

This grows a soft extension of the tile's content outward into the no-data region. Far no-data (way outside the tile) is still smooth junk, but the blender's winner-take-all mask excludes those pixels from the output anyway. What matters is that there's no longer a hard cliff at the tile boundary for the pyramid to carry down.

9.21 BBox warping

Warping each tile into its own bounding box (rather than the full mosaic canvas) is important for memory on large mosaics. The bounding box is computed from the pose-rotated tile corners in canvas coordinates, with a 2-pixel pad on each side.

The pad fixes a subtle issue: cv2.warpAffine(INTER_LINEAR) interpolates against the zero border, which darkens the outermost row and column of the warped output by a fraction. If the bounding box were tight, this darkened edge would land exactly at the bbox boundary and print a thin seam line (one pixel wide, a few percent darker). The pad pushes the interpolation seam outside the valid-pixel mask, where the blend masks exclude it from the output. Same effect as the old full-canvas warp used to get naturally from neighbouring tiles covering those edge pixels.

9.22 Parallelisation

Threading strategy:

Credits. The entire Surface Mosaic pipeline is new work by Franklin Marek for SetiAstro Suite Pro, with edge-case robustness shaped by extensive test-data contributions from the SetiAstro Discord community. SASpro-exclusive content: the four-tier alignment ladder (ORB → SIFT → patch-slide correlation → shared-disk geometry) with the overlap NCC ghost gate uniformly applied; the matching-only multiscale structure boost (reuses SASpro's shared multiscale decomposition infrastructure to amplify mid-band detail layers 2–3 at ~2–4 px structure so soft/unsharpened stacks register, strictly matching-only so output pixels are untouched); the shared-disk solution (percentile-threshold limb extraction robust to 98%-disk tiles where Otsu silently splits inside the disk, Kasa hub fit for the shared radius, Gauss-Newton fixed-radius centre refit from a geometric seed “arc midpoint plus R toward disk-mask centroid” so a sliver arc cannot put the centre on the wrong side); the global limb edges that link limb-bearing tiles through the shared disk centre regardless of mutual overlap; the gain-only sky-safe photometry; the radial limb-darkening photometric model (iterated weighted-median solve against a global μ(r/R) profile with limb and sky masking, decoupling equalisation from overlap topology); the per-tile log-domain quadratic gradient-surface fit (6-coefficient polynomial per tile, block-diagonal normal equations, ridge-pinned gauge, limb-masked bright samples, multiplicative so still sky-safe); the spanning-tree pose seeding for the robust similarity solve (composes pairwise transforms as proper 2D similarities t_j = t_i + R(θ_i)d); the confined local seam warp (double-gated AP shifts on structure and response, median-shift guard, seam-band confinement, boundary feathering); the multiband winner-take-all compositing with no-data fill; the pre-warp parallel + sequential blend architecture; and the pose-aware bounding-box warping with 2-pixel INTER_LINEAR edge-dark pad.

Reused SASpro planetary-stacker primitives (also original SASpro work, cross-credited here because they are called directly in this module): the Hann-windowed bandpass match-image generator (_bandpass), the Fourier-Mellin rotation estimator (_estimate_rotation_fm), the gradient-SSD subpixel polish (_refine_shift_ssd), the AP autoplacement over signal (_autoplace_aps), the per-AP phase-correlation shift extractor (_ap_phase_shifts_per_ap), the AP outlier rejector (_reject_ap_outliers), the sparse-to-dense displacement-field constructor (_dense_field_from_ap_shifts), and the quality score (_quality_score).

External algorithms and tools: ORB is Rublee et al. (2011, ICCV); SIFT is Lowe (2004, IJCV 60:91), which also contributes the Lowe ratio test used by both feature matchers; RANSAC is Fischler & Bolles (1981, Comm. ACM 24(6):381); phase correlation is Kuglin & Hines (1975, IEEE); Fourier-Mellin registration is De Castro & Morandi (1987, IEEE PAMI 9(5):700); algebraic circle fit is Kasa (1976, IEEE Trans. Instrum. Meas. 25(1):8); Laplacian pyramid blending is Burt & Adelson (1983, ACM TOG 2(4):217), with the panorama-stitching-specific multiband application (hard-ownership masks + per-level pyramid smoothing) following Brown & Lowe (2007, IJCV 74:59)'s AutoStitch work; normalised cross-correlation template matching follows the standard formulation implemented in OpenCV's matchTemplate (TM_CCOEFF_NORMED); distance transform follows Felzenszwalb & Huttenlocher (2004, Theory of Computing)'s linear-time algorithm as implemented in OpenCV.

Libraries: OpenCV (cv2) provides ORB and SIFT detection, descriptor matching, RANSAC via estimateAffinePartial2D and findHomography, warp operations (warpAffine, remap), template matching, distance transform, morphology, Gaussian blur, and detail_MultiBandBlender (requires opencv-contrib-python). NumPy provides the array engine and the linear-algebra solves (linalg.solve, linalg.lstsq). SciPy is an optional dependency for the robust similarity solve (optimize.least_squares with soft_l1 loss); the pipeline falls back cleanly to the spanning-tree seed when SciPy is unavailable. PyQt6 provides the UI framework (worker thread, dialog, custom TileLayoutView painter).