A little GPU utilisation research (pipeline with composition+RANSAC+Non-Rigid refinement stitching+more)

This is just a little experiment with creating a heavily GPU orientated processing chain where it uses the client side webgl to do the heavy computation with the chain following the OpenCV class architecture but in javascript.

Does it work? Short answer - yes. This is operating as a non-ofm implementation at the moment for ease of use on my little mach mini in a VM:

People may note a few dials and tick boxes. These control the pipeline:

  1. “load” the files any order..

  2. Pipeline runs lens correction. Your microscope’s “spherical error” is radial lens distortion (barrel or pincushion), not the equirectangular spherical projection OpenCV’s SphericalWarper uses for wide-FOV panorama photography. For tiled microscopy, a planar homography is the physically correct model between overlapping tiles, so warper.js uses warpPerspective-style planar warping, while lens-correction.js removes the radial distortion per-objective before stitching. If your optics genuinely need a different distortion model (e.g. a strong fisheye), swap the k1/k2 radial model in lens-correction.js for a division or fisheye model — the rest of the pipeline is unaffected.

    lens-correction.js samples the R, G and B channels at three slightly different radial positions (chromaScale), correcting lateral CA where each wavelength focuses at a different magnification. Calibrate k1, k2, and chromaScale once per objective/condenser setting using a calibration target (e.g. a grid slide), then reuse those constants for every acquisition with that objective.

  3. Run FeatureDetector (Harris response on GPU, NMS + descriptors on CPU).

  4. Match tile pairs (matcher.js) and estimate a homography per pair with RANSAC (homography.js).

  5. matching-graph.js turns those pairwise homographies into one consistent set: it keeps only pairs with enough inliers, drops any tile that isn’t reliably connected to the rest, and builds a maximum spanning tree (weighted by inlier count) from every tile back to the most-connected tile, which becomes the reference frame.

  6. If bundleAdjustment.enabled, bundle-adjuster.js jointly refines every tile’s homography against every confident pairwise match found (not just the spanning tree) before anything else uses them.

  7. If nonRigidRefinement.enabled, nonrigid-field.js estimates a smooth per-tile local displacement field from dense phase-correlation correspondences in each overlap (see below), layered on top of the homographies from step 5.

  8. If exposureCompensation.enabled, exposure-compensator.js estimates a per-tile brightness gain (see below) before the real warp/blend pass.

  9. Compute panorama bounds from all included tiles’ warped corners, warp each into the shared canvas (applying its exposure gain and local displacement field, if any), and additively accumulate weighted color + weight (warper.js).

  10. Normalise (divide by accumulated weight) to get the final composite (blender.js), blit to the visible canvas.

Note - the majority of the ‘compensations’ are there to provide an example to gauge the performance if a GPU was doing some processing. A real pipeline for ofm may have more, less or different processes. Coding using Sonnet 5 and visually inspected during the development.

The key points for me - there’s some processing on each tile, there’s RANSAC+DLT alignment (this is fast and effective with difficult images), there’s across the mosaic multi-tile processing (‘bundling’), and there’s the phase correlation matching for non-rigid stitching, there’s global and local exposure compensation, and finally warping to fit and blending edges.
The idea is that this is alot of work but presents a good system for stitching (medically acceptable is something to be discussed) - all operating on the GPU (including FFT).

Is it fast? The bottom table here shows the ms per stage.

The total full pipeline (all stages including RANSAC plus non-rigid refinement) is 1.6 seconds with no ordering given to the tiles.

Originally the non-rigid refinement took 5.67 seconds just for that stage when implemented in the CPU!

There’s a little more in terms of optimisations and options but I thought I would share some of the results.
If you want to know more about homography and RANSAC: 41  Homographies – Foundations of Computer Vision whilst the Non-Rigid approach I use for refining can be found here: https://academic.oup.com/bioinformatics/article/35/24/5290/5497257

It will be interesting to see how fast this works on a RPi - the Mac mini has a similar shared GPU/CPU memory architecture!

If anyone has a known large mosaic tile set I can use for testing - drop a link!

EDIT: Utilising OSF Sixteen 1200x1200 29MB tiles (4800x4800 mosaic).
This is great for two reasons:

  1. Texture size handling with webgl - this is not set in stone with webgl standard, so the maximum size of supported textures could be as low as 1024x1024 and as high as 16Kx16K. This means the GPU in the web browser machine can vary from the RPi. So optimising performance for constrained GPUs would be a smart move.
    I’ve bumped into this before but with 4096x4096 limits in OpenCL. It’s possible to overcome and it’s probably better to detect and switch to a separate pipeline that trades speed with the flexibility.

  2. Detection and physical volume - impact on performance with the sheer number of features detected, and optimising the homography, rejecting bad homography (ie causing excessive warping it doesn’t make sense) so we get a better match and a better final image.
    There’s also optimisations based on the subject matter - ie features common in biology vs simple lines.

1 Like
Done in 24895 ms — output 13044x2875px from 4/4 tiles (reference: tile 2).

Progress. There’s still some aspects causing a problem with the warping accuracy at this scape (each tile is 3648 × 2736 pixels - 9.9 megapixels, 8bit/channel, 30MB). I’ve switched to running safari as the browser to get around the VM GPU memory limit.

For reference the RPi HQ camera is 4056 × 3040 pixels (12MP).

As expected the webgl limitations (even with the 32GB Mac mini) are a problem for direct use. What I can do here is create the capability of processing the files within a large context (ie giga pixel), allow the UI to navigate that but then have the webgl processing operating to process. That way webgl only sees the tiles, features, and the global context. The image can then be rendered to screen as window with the option for zoom etc.

Screenshot 2026-09-04 at 14.39.11

Google Chrome fails too (544 Mpixels)

This has been a very useful step in assessing the capabilities for supporting the algorithms and how those will need to work with a large image window class - processed on the front.

Memory and rendering limits have informed much of the stitching algorithm in the OpenFlexure software, to allow stitching and display of giga-pixel images on a 2GB Pi.

The stitching does assume that the images are only translated, and makes use of the fact that we know what we expect the relative positions of the files is from the microscope motor steps.

I think that the assumption of no rotation is It would be really nice to be able to look at image distortions as well - though as you say distortion correction can run away. In the case of microscope images there should be a single distortion profile that applies across all images is your method able to do that?

The current ofm stitching uses a circular window and phase correlation based on the stage movement. That can be GPU-ified on the client machine very easily, the same form can be applied to RANSAC (ie restrict the focus of feature analysis based on stage and radius).

I’m a little busy over the next few weeks, but the next logical step is to look at the distributed computation problem (my thinking here is the RPi provides the imaging, the client provides the GPU horse power, and the user experiences a progressive stitching).

The current pipeline does rotation (depending on option). It’s possible to do phase correlation for rotation and scale. You transform the image based on rotation around the 0.5,0.5 and then phase correlate the resulting textures. The same with scaling.
You can use ORB for features and that works too, that makes RANSAC capable more resilient to rotation. I’ve not noted much of a delay. The major improvement to performance seems to be minimising the matching - specifically using either information on the stage position or doing a low resolution test first.

For the next design iteration I was thinking about being more progressive - the processing can take time, and humans don’t wait. Downscaling the larger HQ images allows feature identification/phase correlation on smaller resolution, those can be used quickly to identify if tiles are a possible fit. The tile can then be tested with the full processing at full resolution with that information. It also provides a “low-res/high-res” progression for users. This can be done offline clientside so it appears to be in the background and it allows the 2GB RPI to be left to capture and manage, the GPU on the client desktop is then employed todo the heavy lifting.

I have RPi zero wifi, and a 4GB RPI that I can blind test this approach on.

Just an update, using the existing pipeline.

I’ve put a selectable downscaling 12.5/25/50/100% on the front end - this doesn’t specifically change the pipeline but it does allow for a full 16x16 tile processing, you can see some rotated and disjunction matching, at 25% with all the pipeline enabled:

Done in 50846 ms (preview scale 25%) — output 3217x2402px from 16/16 tiles (reference: tile 7).
Preview run -- check the console's attempt table for confident pairs (a, b), then reuse them as an explicit `pairs` list at 100% scale.

At 12.5% it fails to match 3 tiles:

This is quite complex and I need to re-review the output tomorrow when I have more time to verify, as you can see in this 50% - I think the feature detection needs a little tuning (circled yellow), so some tuning for feature detecting (parameters rather than code I hope). The same downsizing and the same tuning for the camera image size should be possible in the same way.

Done in 85912 ms (preview scale 50%) — output 6416x4773px from 16/16 tiles (reference: tile 6).
Preview run -- check the console's attempt table for confident pairs (a, b), then reuse them as an explicit `pairs` list at 100% scale.

The tuning of the features should help reduce the number of possible matches it needs to consider for each tile vs other tiles which is why it’s taking 85 seconds currently. Here’s the console output to show you what I mean, note the 19,200 points and large numbers of pairs:

[Log] WebGLStitcher: feature detection — 19200 total keypoint(s) across 16 tile(s) (17915.0 ms). (stitcher.js, line 220)
[Log]  (stitcher.js, line 239)
[Log] WebGLStitcher: matching + graph — 120 pair(s) attempted, 26 confident edge(s), reference tile 6 (63548.0 ms). (stitcher.js, line 248)
[Log] WebGLStitcher: bundle adjustment — 1722 correspondence(s) across 26 edge(s), 13 iteration(s), converged (395.0 ms). (stitcher.js, line 276)

I’ve hinted above that this is an AI+Human development. It allows for fast development but at the same time needs careful analysis at each stage - even for experimental work like this.

One of the great things is that the AI will maintain a README file as documentation as you’re going along, so for anyone interested:

WebGL Microscope Tile Stitcher

A GPU-accelerated image stitcher structured like OpenCV’s cv::Stitcher / cv::detail::* pipeline, but with the per-pixel stages implemented as WebGL2 shaders instead of CPU/SIMD code.

Files

File Pipeline stage OpenCV equivalent
gl-utils.js shared GL plumbing cv::Mat / cv::UMat boilerplate
lens-correction.js radial distortion + chromatic aberration cv::undistort, per-channel
feature-detector.js corner detection + descriptors cv::ORB / cv::AKAZE
orb-descriptor.js optional rotation-invariant binary descriptor ORB’s “Oriented FAST + Rotated BRIEF” (rotation half only)
matcher.js descriptor matching cv::BFMatcher
homography.js DLT + RANSAC cv::findHomography
matching-graph.js pairwise match graph → spanning tree BestOf2NearestMatcher + leaveBiggestComponent + HomographyBasedEstimator
bundle-adjuster.js global homography refinement cv::detail::BundleAdjuster
phase-correlation.js GPU (WebGL2) FFT-based local translation estimation — (used by nonrigid-field.js)
nonrigid-field.js per-tile local displacement field NRStitcher’s local correction (see below)
exposure-compensator.js per-tile brightness gain from overlap stats cv::detail::GainCompensator
warper.js perspective warp into panorama space cv::warpPerspective / PlaneWarper
blender.js feather (weighted) blend cv::detail::FeatherBlender
stitcher.js orchestrator cv::Stitcher
demo.html file input + canvas UI —

Run it by serving the folder over HTTP (ES modules need a real origin, not file://) and opening demo.html, e.g.:

npx serve .

File format support

Any format a browser’s <img> can decode (PNG, JPEG, WebP, …) works with no extra setup. TIFF is common for microscopy exports but no browser can decode it natively – demo.html loads UTIF.js from a CDN (a dependency- free pure-JS TIFF decoder) specifically for this, and decodes .tif/ .tiff files to a canvas before handing them to WebGLStitcher.stitch() (a canvas has the same .width/.height as an <img> and is an equally valid WebGL texture source, so nothing else in the pipeline needs to know the difference). This is the only external dependency anywhere in this project, and it’s confined to the demo’s file loader – stitcher.js and every pipeline module remain dependency-free.

Notes:

  • Multi-page/multi-frame TIFFs (e.g. z-stacks) use only the first page – a console warning is logged if more than one page is found.
  • Some exotic TIFF compressions may not be supported by UTIF.js; a failed decode raises a specific error naming the file rather than a generic one.
  • If you’d rather not pull from a CDN, pre-convert TIFFs to PNG externally (e.g. via ImageMagick, Fiji/ImageJ, or tifffile in Python), or vendor UTIF.js locally and change the <script src=...> in demo.html to point at your local copy.

What “spherical” correction means here

Your microscope’s “spherical error” is radial lens distortion (barrel or pincushion), not the equirectangular spherical projection OpenCV’s SphericalWarper uses for wide-FOV panorama photography. For tiled microscopy, a planar homography is the physically correct model between overlapping tiles, so warper.js uses warpPerspective-style planar warping, while lens-correction.js removes the radial distortion per-objective before stitching. If your optics genuinely need a different distortion model (e.g. a strong fisheye), swap the k1/k2 radial model in lens-correction.js for a division or fisheye model — the rest of the pipeline is unaffected.

Chromatic aberration correction

lens-correction.js samples the R, G and B channels at three slightly different radial positions (chromaScale), correcting lateral CA where each wavelength focuses at a different magnification. Calibrate k1, k2, and chromaScale once per objective/condenser setting using a calibration target (e.g. a grid slide), then reuse those constants for every acquisition with that objective.

Pipeline flow (stitcher.js)

  1. Upload each tile, run LensCorrector (GPU).
  2. Run FeatureDetector (Harris response on GPU, NMS + descriptors on CPU).
  3. Match tile pairs (matcher.js) and estimate a homography per pair with RANSAC (homography.js).
  4. matching-graph.js turns those pairwise homographies into one consistent set: it keeps only pairs with enough inliers, drops any tile that isn’t reliably connected to the rest, and builds a maximum spanning tree (weighted by inlier count) from every tile back to the most-connected tile, which becomes the reference frame.
  5. If bundleAdjustment.enabled, bundle-adjuster.js jointly refines every tile’s homography against every confident pairwise match found (not just the spanning tree) before anything else uses them.
  6. If nonRigidRefinement.enabled, nonrigid-field.js estimates a smooth per-tile local displacement field from dense phase-correlation correspondences in each overlap (see below), layered on top of the homographies from step 5.
  7. If exposureCompensation.enabled, exposure-compensator.js estimates a per-tile brightness gain (see below) before the real warp/blend pass.
  8. Compute panorama bounds from all included tiles’ warped corners, warp each into the shared canvas (applying its exposure gain and local displacement field, if any), and additively accumulate weighted color + weight (warper.js).
  9. Normalize (divide by accumulated weight) to get the final composite (blender.js), blit to the visible canvas.

Descriptor options

detector.descriptorType picks how keypoints are described for matching (step 2 above), independent of everything else in the pipeline – matching-graph.js, bundle-adjuster.js, homography.js, warper.js, blender.js, exposure-compensator.js, and nonrigid-field.jsnever touch descriptor internals, only keypoint x/y and the resulting homographies, so switching this doesn’t affect anything downstream.

new WebGLStitcher(canvas, {
  detector: { descriptorType: 'orb' }, // default: 'patch'
});

  • 'patch' (default) – a mean-subtracted, normalized square patch (detector.patchRadius, default 15 → 15×15), compared with Euclidean distance. Fast, simple, and robust for the mostly-translational overlaps typical of a tile mosaic – but it samples a fixed, axis-aligned grid, so it is not rotation-invariant: the same physical corner detected in two tiles related by more than a few degrees of rotation produces two descriptors that don’t look alike, and the ratio test in matcher.js will reject the (correct) match.
  • 'orb' – orb-descriptor.js, borrowing ORB’s “Oriented FAST + Rotated BRIEF” idea (minus the FAST part, since corners here already come from the GPU Harris response): each keypoint gets an orientation from the intensity centroid of a circular patch around it, then a fixed set of sample-point pairs (a reproducible Gaussian-distributed pattern, generated once by a seeded PRNG – not OpenCV’s specific trained pattern) is rotated by that orientation and compared pairwise into a binary descriptor, matched via Hamming distance. Use this when tiles can be rotated relative to each other by more than a few degrees. Sub-options: detector.orbPatchRadius (default 15, radius for both the orientation estimate and the sample pattern) and detector.orbDescriptorBits (default 256).

Note: Harris corner detection (the response computed in feature-detector.js’s GPU pass) is already rotation-invariant on its own, since it comes from the eigenvalues of the local structure tensor – this option only affects whether the descriptionof each detected corner is rotation-invariant too. Scale invariance is out of scope for both descriptor types; a mosaic mixing magnifications would need a separate image-pyramid feature.

Bundle adjustment

Off by default – turn it on with:

new WebGLStitcher(canvas, {
  bundleAdjustment: { enabled: true },
});

What it does: matching-graph.js’s spanning tree gives every tile exactly one path of chained homographies back to the reference frame, using only the tree’s edges – any other pair that matched confidently but wasn’t picked for the tree is otherwise ignored, and small per-pair errors can compound along a long chain (“drift”). Bundle adjustment takes the spanning-tree result as a starting point and refines every included tile’s homography simultaneously (except the reference tile’s, held fixed at identity) to minimize reprojection error across every confident pairwise correspondence at once – so residual error gets distributed and averaged across the whole mosaic instead of accumulating down one path.

This runs as Levenberg-Marquardt on the CPU with a numerically estimated Jacobian (the same “GPU for pixels, CPU for optimization” split RANSAC already uses). One thing worth being explicit about: OpenCV’s cv::detail::BundleAdjusteroptimizes camera intrinsics + rotation for a spherical/cylindrical panorama model; this refines the 8 free parameters of each tile’s planar homography directly, which is the correct model for tiled microscopy (see the “spherical vs planar” note above) – same objective (minimize reprojection error), different parameterization. Sometimes called “global homography alignment” in the mosaic-stitching literature.

Every run with enabled: true logs, via console.log/console.table:

  • how many correspondences and edges were used, how many iterations ran, and whether it converged
  • reprojection error (RMS, in pixels) before and after refinement, and the percentage improvement

Tunable sub-options (pass any subset under bundleAdjustment):

Option Default Effect
maxIterations 20 Cap on LM iterations. Raise if the log shows it stopping without converging and RMS is still improving; lower to bound worst-case runtime.
convergenceTolerance 1e-6 Stops early once a step’s relative cost improvement drops below this. Lower = keeps refining longer for smaller gains.
initialLambda 1e-3 Starting Levenberg-Marquardt damping factor. Rarely needs changing.

Performance note: this is a dense solve over 8 * (tiles - 1) parameters with a numerically estimated Jacobian, recomputed every iteration – reasonable for up to a few dozen tiles in a browser tab, but the per-iteration cost grows roughly cubically in tile count via the normal-equations solve. For large mosaics, consider a sparse/analytic Jacobian (this module’s residual function stays the same; only the Jacobian and linear solve would need to change), or skip this stage and rely on the spanning tree alone.

Non-rigid refinement

Off by default – turn it on with:

new WebGLStitcher(canvas, {
  nonRigidRefinement: { enabled: true },
});

What it’s for: bundle adjustment (and RANSAC before it) fits one rigid projective homography per tile. That’s the right model for lens/camera geometry, but it can’t represent the specimen itself deforming slightly between when two overlapping tiles were captured (drift, radiation damage, drying, live-sample movement). This stage adds a per-tile local correction on top of the homography to absorb exactly that residual – it’s the “local correction” idea from NRStitcher (Miettinen et al. 2019, Bioinformatics 35(24):5290-5297), adapted to run as a refinement layer over our (already more capable, full-projective + bundle-adjusted) global placement rather than replacing it. See the “Why not RANSAC/homography alone” note below for why this division of labor makes sense.

How it works:

  1. For every overlapping tile pair (from the match graph), lay a regular grid of sample points across the overlap.
  2. Every grid point in that overlap is correlated in one batched GPU pass: their block-extraction, both forward FFTs, cross-power spectrum, and inverse FFT all run together across a shared “atlas” texture (a grid of NxN cells, one per point) via phase-correlation.js’s correlateBatch, ending in a single gl.readPixels that retrieves every point’s correlation surface at once – not one full round-trip per point. See “GPU batching” below for why this matters as much as it does.
  3. Low-confidence points (flat/featureless regions, or blocks with no real match) are discarded. The residual at each accepted point is split evenly between the two tiles – neither is treated as “more correct.”
  4. Each tile’s scattered correction samples are splatted into a small coarse grid (fieldGridSize, default 12×12) covering its own pixel extent, smoothed, and uploaded as a texture.
  5. warper.js samples that texture (manual bilinear via texelFetch, so no OES_texture_float_linear extension is needed) and nudges each pixel’s source coordinate by the interpolated correction before the final color sample. Grid cells with no nearby samples stay at exactly zero, so tile interiors far from any overlap are completely unaffected – correction only ever happens near where it was actually measured.

GPU FFT implementation note: phase-correlation.js’s butterfly-stage shader is derived directly from this project’s own (already-verified) CPU radix-2 FFT loop – bit-reversal permutation, then log2(N) in-place butterfly stages – restructured into an out-of-place, ping-ponged form a fragment shader can run per-pixel. It’s the same algorithm, not a different GPU-FFT formulation (e.g. Stockham autosort) that merely agrees with it; see the comment at the top of that file for the full derivation. Runs in single precision (GLSL highp float, effectively 32-bit) rather than the CPU version’s double precision – not a concern at the block sizes used here (peak-finding for a translation estimate doesn’t need double precision; production GPU FFT libraries run single-precision as standard practice).

Why not RANSAC/homography alone, and why not phase correlation alone: phase correlation needs some texture in every grid cell (a flat/blank region contributes nothing) and only cleanly resolves pure translation – if the true local motion includes rotation or scale within a block, the correlation peak degrades. Sparse feature matching handles large rotation/scale/projective effects fine (RANSAC just needs a handful of good corners anywhere in the overlap) but can’t easily give you a dense per-pixel correction. Running this stage after bundle adjustment means the residual it has to find is already small and close to pure translation – exactly phase correlation’s strong suit – while the global model already handled everything phase correlation is bad at.

Every run with enabled: true logs, via console.log/console.table, per-edge grid point / accepted point counts and mean correction magnitude, and warns if every point was rejected (in which case every tile’s field is all-zero – a safe no-op, not an error).

Tunable sub-options (pass any subset under nonRigidRefinement):

Option Default Effect
gridSpacing 96 World-space spacing (px) between sample points in each overlap. Smaller = denser correction, more compute.
blockSize 64 Phase-correlation block size (px), rounded up to the next power of 2 if needed. Larger blocks give more texture to correlate against but a smaller usable overlap area and higher cost; must be smaller than the tile overlap.
minConfidence 2.0 Correlation-peak confidence threshold below which a grid point is discarded. Raise to be more conservative (fewer, more trustworthy points).
maxCorrection 32(≈blockSize/2) Sanity bound on accepted correction magnitude, in px. Corrections larger than this are treated as spurious matches, not real deformation.
fieldGridSize 12 Resolution of each tile’s output correction grid. Higher = can represent more spatially-varying deformation, at the cost of a noisier fit from the same sample density.
smoothingPasses 1 Box-blur passes applied to each tile’s correction grid before upload, to reduce cell-to-cell noise.

MVP scope note (see nonrigid-field.js for the full comment): this is deliberately simplified relative to the paper it’s based on – single-resolution phase correlation only (no coarse-to-fine pyramid; relies on bundle adjustment having already removed large misalignment so the residual is small), and no cross-neighbor region blending at >2-tile overlaps (each edge’s contribution to a tile is averaged in with the others when the field grid is built, but not iteratively reconciled the way the paper’s region-based StL-T is). It’s also a single scalar-per-axis (dx,dy) correction, not a full local affine/rotation model – see the “why not phase correlation alone” note above for why that’s an acceptable trade for a refinement layer specifically.

Side Note - this could be improved by a hybrid pyramid scheme for areas of non-matching.

GPU batching note: the naive version of this (one full extract→FFT→ FFT→cross→invFFT→readPixels round-trip per grid point) turned out to be dominated not by the FFT math but by two fixed costs multiplied by grid point count: dozens of tiny texture/framebuffer allocations per point, and one hard CPU/GPU sync (gl.readPixels drains the whole GPU command queue) per point. Two optimizations were applied in order, each verified against real profiling before moving to the next:

  1. Buffer pooling – reusing a small fixed set of scratch buffers per block size instead of allocating/freeing ~45 textures+framebuffers per point. Measured impact: ~2.3x (a 5000ms non-rigid stage dropped to ~2170ms on a real tile set), confirming allocation churn was a bit over half the original cost.
  2. Atlas batching (current implementation) – every grid point in one overlap is packed into a shared “atlas” texture (a grid of NxN cells) and every FFT stage runs once across the whole atlas in parallel, ending in one readPixels for the entire overlap instead of one per point. This is what actually addresses the sync-stall half of the cost that pooling alone couldn’t touch. correlateBatch (in phase-correlation.js) handles this transparently – it also automatically splits into multiple atlas passes if a single overlap has more grid points than fit under MAX_TEXTURE_SIZE at the chosen blockSize, so there’s no hard cap on overlap size to configure around.

Cost now scales with grid points per overlap (~overlap area / gridSpacing²) times ~6*log2(blockSize) shader passes – but as onebatched pass over however many points fit in an atlas, not as that many separate round-trips. Increase gridSpacing or decrease blockSize if this is still the bottleneck for very large or densely-sampled overlaps.

Exposure/illumination compensation

Off by default – turn it on with:

new WebGLStitcher(canvas, {
  exposureCompensation: { enabled: true },
});

If nonRigidRefinement is also enabled, its output is automatically fed into this stage’s own low-resolution statistics pass, so exposure gains are measured against the locally-corrected geometry rather than just the bundle-adjusted one – no extra configuration needed, stitcher.js wires this up for you.

What it does: for every pair of tiles the match graph found overlapping, it warps both tiles at a cheap reduced resolution (workingScale, default 0.25) and compares mean luminance in the region where they actually overlap. Those pairwise comparisons feed a small weighted least-squares solve for one brightness multiplier (“gain”) per tile – tiles are pulled toward agreeing with their neighbors, with a regularization term (regularization, default 0.05) anchoring everything toward gain 1.0 so the solve can’t drift or blow up on weakly-connected tiles. The gain is then applied when each tile is warped into the final composite, so neighboring tiles agree in brightness before the feather blend runs – this is what actually smooths out a visible seam that feather blending alone can leave when tiles were captured under slightly different exposure/illumination.

Every run with enabled: true logs:

  • a table of the final gain applied to each tile (and which tile was the reference), via console.table
  • a table of every overlap pair’s stats (pixel count, mean luminance each side) that fed the solve
  • a warning if no pairs had enough overlap to compute anything useful (gains all stay at 1.0 in that case – try lowering exposureCompensation.minOverlapPixels)

This logging happens independently of the debug option, since it directly changes the visible output and you’ll usually want to see it.

Tunable sub-options (pass any subset under exposureCompensation):

Option Default Effect
workingScale 0.25 Resolution fraction used for overlap statistics. Lower = cheaper but noisier; exposure differences are low-frequency so this rarely needs to go above ~0.5.
minOverlapPixels 50 Minimum overlapping (working-resolution) pixels for a tile pair to be trusted. Raise if you’re getting gains driven by tiny, unreliable overlap slivers.
regularization 0.05 How strongly each tile’s gain is pulled back toward 1.0, scaled by how much overlap data supports that tile. Raise for more conservative (closer to 1.0) gains; lower to let the data correct brightness more aggressively.

Note: this estimates a single scalar gain per tile (uniform across R/G/B), which handles exposure/illumination differences but not color-cast differences between tiles. A per-channel variant would only need exposure-compensator.js’s overlap-statistics loop to track sums per channel instead of luminance – the rest of the pipeline is unaffected.

Tile ordering

The input image array is treated as an unordered set by default (pairing: 'all' in WebGLStitcher options) — every pair of tiles is matched, and matching-graph.js figures out how they actually connect regardless of what order they were passed in. This is the right default when you don’t yet have a scan-path planner feeding in acquisition order.

Two ways to speed things up once you do know more about the layout:

  • pairing: 'sequential' — only match each tile to the next one in the input array. Fast (O(n) pairs instead of O(n^2)), but only correct if the array is already ordered along a single scan line.
  • options.pairs — supply your own list of [i, j] index pairs to try (e.g. only a tile’s raster/serpentine grid neighbors). This is the hook for a future scan-path-aware planner: it only needs to produce a pair list, not touch any matching/warping/blending code.

stitch() returns { canvas, referenceIndex, includedIndices, excludedIndices } — check excludedIndices for any tiles that had no reliable overlap with the rest and were left out of the panorama.

Discovering pairs quickly: test at reduced resolution first

pairing:'all' is O(n²) candidate pairs, and feature detection’s dominant cost (scanning every pixel before non-max suppression) scales with pixel count – both make the full search expensive on large tiles at scale (e.g. 16 tiles at 1200×1200 can mean 30+ seconds before matching even starts). But whether two tiles overlap at all is largely resolution-independent: a real overlap produces a real, if weaker, correspondence signal even at low resolution. demo.html’s “Preview scale” control downsamples every tile (via canvas, before upload) so you can run the expensive pairing:'all' search once at, say, 25% scale – 16x fewer pixels to scan – read off which pairs came back confident in the WebGLStitcher({ debug: true }) attempt table, and reuse exactly those as an explicit options.pairs list for the real run at 100% scale. The full-resolution run then only tests genuine neighbors instead of every non-overlapping pair, which is both much faster and removes the noise pairs that would otherwise compete during matching (see “Rejected homographies” above for how that noise causes real problems, not just wasted time).

This is a discovery tool, not a substitute for full-resolution matching: fewer pixels means fewer/weaker correspondences, so treat a low-res “not confident” as inconclusive (that pair might still be a real overlap with more detail to work with) – only treat a low-res “confident” as reasonably trustworthy evidence of a genuine overlap.

Output canvas size (snapBoundsEpsilon)

Because canvas width/height are derived by projecting every tile corner through its RANSAC-estimated homography, the X and Y extents are two independent floating-point numbers with their own sub-pixel residual – so the output can come out as, say, 1000×1001 rather than a clean 1000×1000 even for tiles that are essentially grid-aligned. This is harmless (nothing is lost or misplaced), but if you want a deterministic size, set snapBoundsEpsilon (default 0, disabled) to round each bound to the nearest integer when it’s within that many pixels of one, e.g.:

new WebGLStitcher(canvas, { snapBoundsEpsilon: 0.5 });

Exhaustive pairing is O(n^2) RANSAC calls, each with its own iteration budget (options.ransac.iterations, default 2000) — fine up to a few dozen tiles in a browser tab, but worth switching to sequential or a custom pairs list for large mosaics.

Troubleshooting “No tile pair produced a reliable homography”

The error message now includes the actual match/inlier counts for the best few pairs tried (and WebGLStitcher({ debug: true }) logs a full table via console.table for every pair, pass or fail). Read those numbers before changing anything:

  • 0 matches on pairs that should overlap — the detector isn’t finding keypoints in the overlap region. Try lowering detector.qualityLevel (more permissive) or raising detector.maxFeatures.
  • Matches exist, 0 inliers — the matches are likely false positives. Microscopy images with repetitive texture (fields of similar-looking cells) confuse small descriptor patches; try raising detector.patchRadius for a more distinctive (larger) patch, or lowering matcher.ratio (stricter ratio test). Also try raising ransac.threshold slightly in case real matches are being rejected by too tight a reprojection tolerance.
  • Some inliers, just under ransac.minInliers — lower ransac.minInliers, or raise detector.maxFeatures/lowerdetector.qualityLevel to get more tentative matches feeding RANSAC.
  • Tiles genuinely don’t overlap — check with pairing: 'sequential' or an explicit pairs list restricted to tiles you know overlap, to rule out the graph matching unrelated pairs.
  • Enough inliers, but marked implausible — the fitted homography had enough inliers but failed a geometric plausibility check — see “Rejected homographies” below for the two distinct ways this can happen.

Rejected homographies (implausible geometry)

Inlier count alone doesn’t guarantee a homography makes sense, or that applying it won’t visibly warp a tile. estimateHomographyRANSAC (homography.js) runs two independent plausibility checks after fitting a homography with enough inliers – each catches a different failure mode, and the attempt table (WebGLStitcher({ debug: true })) and the “no reliable homography” error both report which one fired via a reason field, so you can tell them apart instead of guessing:

'degenerate-near-data' — with enough candidate pairs (e.g. pairing:'all' on a large tile set, which tries every pair, most of which don’t actually overlap), repetitive texture can produce enough spurious matches between two tiles that don’t overlap at all to clear ransac.minInliers with a nonsensical transform. This check maps the fitted (inlier) points’ own bounding box through the homography and rejects it if a corner lands at/near infinity, the mapped shape self-intersects, or the area is absurdly rescaled relative to the source (ransac.minAreaRatio/maxAreaRatio, default 0.05/20).

'warps-full-tile' — a homography can pass the check above (it looks perfectly sane right where the correspondence data is) and still produce visible warping once applied across the entire tile during warping. This happens because RANSAC/DLT fit from a handful of points confined to a narrow overlap strip is inherently underconstrained outside that strip – the fit can pick up spurious curvature that only becomes visible far from the data. This second check maps the tile’s actual full extent (not just the inlier bounding box) through the homography, with tighter bounds appropriate for what a real adjacent-tile transform should look like (close to a similarity transform): area ratio withinransac.fullTileMinAreaRatio/fullTileMaxAreaRatio (default 0.3/3.0), edge-length ratio (catches trapezoidal/keystone distortion specifically, which can pass an area-ratio check while still looking clearly wrong) within ransac.fullTileMaxEdgeRatio(default 3.0), and the mapped quadrilateral must stay convex (non-self-intersecting).

If you’re seeing either on a large tile set, both point at the same underlying cause: correspondence data that’s too sparse or too spatially concentrated to constrain a full 8-DOF projective homography reliably. Try, in order:

  • Widen the correspondence spread: raise detector.maxFeatures and/or lower detector.qualityLevel to find more keypoints across more of each tile, not just its strongest few corners.
  • Loosen matcher.ratio to admit more tentative matches for RANSAC to work with (raises noise too, but the plausibility checks filter that back out).
  • If you know roughly how tiles are laid out, switch from pairing:'all' to pairing:'sequential' or an explicit pairs list – this removes the ~most candidate pairs that are noise (don’t actually overlap) in the first place, which both directly prevents 'degenerate-near-data' rejections and stops that noise from crowding out genuine pairs’ matches during the ratio test.
  • 'warps-full-tile' specifically may also mean ransac.minInliers is too low for how sparse the real correspondence data is for this tile set – raising it discards fits before they even reach the plausibility check, at the cost of needing stronger matches to accept a pair at all.

A side note here - the AI keeps suggestions for further development but for now the focus is on the non-rigid refinement as that should deform the image to match.
I may create a ghosting detector for debugging - an inverse phase correlation on the proposed match. The result will be a spike in the input where the matching fails and ghosting occurs. This could then trigger a rethink/deeper processing in the pipe for that match. It becomes complex because the image deformation changes global edges, so the alternative is then to resample the tile to fit. Data transformation has implications on ‘accuracy’ of the dataset.

I have a couple of additions in the pipeline:

  • progressive registration - this will attempt a simple transition style alignment first before attempting anything complex. This should help with some of the ghosting - I’ve checked manually and tiles 1 and 2 are simply translated. The pipeline will start simple and work to more complex.
  • debug GUI option - see below - will overlay a the tile mapping (including distortion), I think there’s a little more on this todo..
  • saving the finally stitched image too.

RANSAC was used in earlier versions of our stitching code but removed due to the time taken. Time that doesn’t scale well as we add images.

GPU utilisation for stitching has been discussed before. We need to minimise extra GPU usage on the Raspberry Pi itself as this can cause issues with the data collection.

This offloads the stitching to the client, but:

  • Sometimes the client is also on the pi
  • Often no client is connected during a scan (set and leave), occasionally checking on progress
  • Sometimes multiple clients might connect during scanning (such as demos and teaching)
  • Often 1 computer controls multiple microscopes

We have far less control over the client devices, with people using tablets, phones and old computer hardware. So making something that uses GPU power client side would need to be really robust to different hardware and situations.

We had discussed WebGL in the past for rendering (but not registering) images, so that we can use a WebGL texture. However, the more I think about this, the more I realise we need to either scale each image as we send it over the network (either live for each client connection, using a lot of power on the Raspberry Pi) or save downscaled images using up SD card disk writes, or send all of the data to the client.

Some large scans can be 1000-2000 images. This can be about 2GB of 2MP JPEG images. This either needs to either be transferred to the client and saved locally to temporary storage (which isn’t guaranteed to be available depending on the client and the browser) or held in memory. A full stitched image from these large scans can be 4-5GB of RAM if fully opened for processing.

I’m not discouraging you from researching stitching algorithms and methods. I’m just setting expectations that offloading to the client this is not a direction that the core project would adopt.

1 Like

I think if you wanted RPI GPU processing in the microscope - one key thing is to understand the prioritisation of the memory bus of the RPI when a long GPU operation is in progress and USB data arrives. If the RPI’s CPU/GPU follows a similar design to the STM32 for example it could limit the sizes of processing (ie texture sizes) to ensure minimal conflict on the memory bus.

If large scale processing is required - this would realistically require a different architecture that ofm currently uses - breaking into separate roles: capture, storage, coordination, compute and rendering. Roles can mix and match across the architecture, storage/downsample, render/compute together minimise the working render networking requirements whilst contributing.

The current iteration will create a progressive approach to the pipeline - trying lower compute requirements, then looking at the more complex but only adopting if they’re resulting in a better image. The resulting mosaic could be all done simply, but then have one tile that adapts by using more complex - the resulting system is closer to a particle system.

Let me move off here, finish this initial research and I’ll turn my attention to automated testing and builds.

1 Like

So with a ofm 30 tile demo set, unordered input, set to downsize 50% (ie it downsizes then processes) ~30 seconds:

With auto on this works more progressive with the matching. Takes a little longer than telling it straight to only use translation but it works it out. Also this has exposure compensation on too:

And 100% non-ordered and auto mode :smiley: 123667 ms — output 2771x2444px

If I remember to switch off deforming non-rigid: Done in 60079 ms — output 2771x2444px from 25/30 tiles (reference: tile 1):

That will do.

1 Like