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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
Dropdown with two choices:
cv2.detail_MultiBandBlender), run in winner-take-all mode where each pixel is owned by exactly one tile. Hides residual brightness differences across seams better than feather can. Requires the opencv-contrib-python extras; falls back to Feather with a console warning if the module isn't available in your build.Click Advanced… to open the parameter editor. Groups:
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”.
After every successful build, the right-hand panel draws a schematic of the solved layout:
pair.conf, 0 to 1).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.
The four-tier alignment ladder, bundle adjustment, photometric equalisation, and compositing pipeline, in order.
End-to-end flow:
O(n²) but every pair is independent, so matching runs across threads.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:
and reconstructed with per-layer gains:
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).
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.
feature_max_dim (default 1600 px) and contrast-normalise (1st to 99.5th percentile stretch to [0, 255] uint8).cv2.ORB_create(nfeatures=4000)).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:
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]).
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.
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.
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:
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?
Candidates from any tier are refined using the SASpro planetary stacker's primitives on the pair's integer overlap crop:
_bandpass._phase_corr_shift):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):
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.
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:
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.(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.
With limb arcs extracted from every tile that showed one, SASpro solves the shared disk globally:
len(arc) / (1 + rms). Pick the tile with the highest score as the hub.R: the hub's free-fit radius.R < 0.2 · min(tile_dim) or R > 200 · max(tile_dim).R (§9.10).Kasa's algebraic circle fit:
linearises to:
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.
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:
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:
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.
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:
max(2.5, 0.03·R)). This is the magic case: two tiles that don't overlap each other at all but both sit on the same disk get a position relationship through the disk centre, with no overlap NCC check possible, trusted on geometric tightness alone.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.
With pairs collected, SASpro solves tile poses globally by weighted least squares. Each pair contributes two constraints:
(symmetric for y) with weight w_ij. The joint objective:
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.
When rotation is on and SciPy is available, SASpro solves the joint (t_x, t_y, θ) system robustly. Two parts:
Propagate pairwise transforms out from the anchor along a spanning tree of the overlap graph, composing the transforms as real 2D similarities:
with
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).
Pack each tile's free pose (every tile except the anchor) into a 3-vector (t_x, t_y, θ), build pairwise residuals:
and solve via scipy.optimize.least_squares with soft_l1 loss:
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).
With poses solved, every tile gets a single scalar gain g_i. Equal-sky property first:
No bias term anywhere. For each overlapping pair:
I > 0.3 · p_{95}(I), so sky and glint are excluded:2. Constrain the log-gain ratio by the median bright levels: g_i · med_a = g_j · med_b, i.e.:
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].
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:
r/R for every pixel using the shared disk centre.r/R into 96 bins over [0, 1], taking per-tile per-bin medians I_k(ρ_b).g_k = 1 for every tile.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:
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.
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]:
applied multiplicatively:
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:
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.
Two phases, with the division of labour chosen to maximise parallelism:
For every placed tile, independently (no shared state):
_tile_bbox) with a 2-px pad.g_i and the gradient surface s_i(x, y) to the original tile.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).
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:
sum += I · w, wsum += w) or deposit into the multiband blender store.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:
ap_spacing, size ap_size, over pixels where cur & ref both have data)._ap_phase_shifts_per_ap) — returns each AP's local shift and response strength.ap_min_response (default 0.30) or flagged by MAD z-score outlier rejection (_reject_ap_outliers).local_warp_min_shift (default 0.5 px), skip the warp. The APs are measuring sub-pixel noise, not real distortion.and
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.
Distance-transform feather weights:
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.
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:
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.
Binary ownership, no feather at all. Debug mode — useful when inspecting where tiles disagree.
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:
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.
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.
Threading strategy:
cv2.setNumThreads(1)) to avoid oversubscription; it's restored afterwards.μ(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.
_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).
matchTemplate (TM_CCOEFF_NORMED); distance transform follows Felzenszwalb & Huttenlocher (2004, Theory of Computing)'s linear-time algorithm as implemented in 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).