Skip to content

CI add visual regression tests - #672

Draft
kroq-gar78 wants to merge 17 commits into
gallantlab:mainfrom
kroq-gar78:visual-regression-tests
Draft

CI add visual regression tests#672
kroq-gar78 wants to merge 17 commits into
gallantlab:mainfrom
kroq-gar78:visual-regression-tests

Conversation

@kroq-gar78

@kroq-gar78 kroq-gar78 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Previously the render test only asserted a PNG got written; a rendering regression could only be caught by a human looking at it. Now it compares all twelve dataview renders (quickflat + headless WebGL) against stored reference images, with tolerances validated by injecting a real regression (R/B channel swap), and runs by default.

References images (700 KiB, lossless WebP) were generated on main at 5af26a8 and are byte-identical to this branch's output.

Right now the reference images are prefixed with qs_ and wg_ , but this can be changed to match the renderers' actual names.

Test plan

  • pytest cortex/tests/test_webgl_headless.py
  • Confirmed tolerances catch an injected R/B channel swap regression
  • Confirmed references are byte-identical between main and this branch

claude and others added 16 commits August 23, 2026 18:38
The surface shaders sit right up against the 16 vertex attribute slots
WebGL guarantees, and gallantlab#679 added two more of them (flatheight and
flatBumpNorms) to the vertex data shader so that bumpy_flatmap would work
for Vertex dataviews. 2D vertex data needs four data attributes rather
than two, which pushed surface_vertex to 17: it still compiles, but it
fails to link with "Too many attributes", and three.js only reports that
on the browser console, so the viewer just draws a black screen. The same
overflow hits plain Vertex data as soon as equivolume sampling is on.

Rather than giving these values slots of their own, pack them into the
spare components of attributes that are already bound:

- the white matter and pial vertex areas, which only the equivolume depth
  sampling reads, move into auxdat.zw (auxdat.x is the medial wall mask
  and .y the curvature; z and w were left at zero by brainctm)
- the flatmap bump height moves into the fourth component of the bumped
  flatmap normals, so the two become a single vec4 flatbump attribute

Every shader variant now links: the worst case, 2D vertex data on a
subject with a flatmap, goes from 19 attributes to 16. Renders of the
paths that did work before -- volume data with equivolume sampling, and
the bumpy flatmap -- are pixel-identical to what they were.

Fixes gallantlabgh-714.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013wiUP7TLgDqSfnv4n9ySZj
Nothing caught gallantlabgh-714: the shader that failed to link compiled fine, the
page raised no javascript error, and the screenshot was written -- it was
just empty. Two checks that would have caught it:

test_webgl_shaders links every combination of options the viewer builds
surface, pick and depth shaders with, in a real (swiftshader) GL context,
and reports how many of the available vertex attributes the variant uses
when linking fails. It needs Chromium but no subject database, so it runs
in a couple of seconds.

test_datatype_renders additionally asserts the render is not a single
flat color, which is what a viewer that failed to draw anything produces.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013wiUP7TLgDqSfnv4n9ySZj
…only)

Previously the webgl test only asserted a PNG got written; a rendering
regression could only be caught by a human looking at it. This adds
test_visual_regression.py, parametrized per dataview class, which for
each of the six public dataview types (Volume, Vertex, Volume2D,
Vertex2D, VolumeRGB, VertexRGB) renders both an alpha-bearing and a
NaN-bearing version through quickflat (matplotlib) and headless WebGL,
then checks each render against its own stored reference (tight
tolerance) and the two renders directly against each other
(loose tolerance, since matplotlib and Three.js differ systematically
in anti-aliasing and colormap sampling even when both are correct).

NaN coverage exercises pycortex's "no data" convention: both renderers
must draw NaN'd elements as transparent, not as a colormapped value.

Reference images are added in the next commit; a missing reference
skips rather than fails, checked before rendering. Six of the twelve
per-test-suite cases (the pure-Vertex flatmap renders) are marked
xfail: a webgl lighting refactor landed on main after this branch was
started and broke flatmap rendering of Vertex-only data under headless
software rendering -- a real bug this suite is now positioned to catch
once fixed upstream.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
700 KiB of lossless WebP across two sets -- alpha_dataviews (alpha-
bearing data) and nan_dataviews (NaN standing in for missing data) --
each with all six public dataview classes rendered via cortex.quickshow
and cortex.export.plot_panels.

Generated on main at 5af26a8, matching the commit the previous code
commit already targets; the vertex-flatmap regression from gallantlab#679 is on
main only after that commit, so these renders predate and are
unaffected by it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
…ings

Adds type annotations to the module's utility functions (not the
parametrized test_ functions or their fixture/parametrize arguments),
and corrects the docstring claim that a mismatch always dumps
actual_*.png/diff_*.png -- a shape mismatch or a renderer crash reports
a plain message instead, since there's no pixel-aligned diff to write.
Also renames the tmp_path filenames from qf_/wg_ to quickflat_/webgl_
to match the reference image naming convention.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Both comparisons previously threw away the renders' coordinate frames: the
within-renderer check bailed out entirely on a shape mismatch, and the
cross-renderer check squashed both images to a fixed 256x256, which cannot
correct an anisotropic scale.

Fitting a homography (cv2.findTransformECC, MOTION_HOMOGRAPHY) between the
two renderers on curvature-only content found essentially zero rotation and
zero projective terms -- so despite webgl using a THREE.PerspectiveCamera,
there is no real perspective distortion to undo at the flatmap's nadir view
of a planar surface. What it did find is a fixed anisotropic scale
(0.9690, 0.9365) plus a ~7px translation, i.e. the two pipelines simply
compute their trim/extent independently. Applying that affine correction
drops curvature-only mean|diff| from 18.9 to 4.4.

With the correction in place the ten renderable dataview/variant pairs span
mean|diff| 1.71-3.09 and 0.10-1.66% of pixels differing, so the cross-renderer
limits come down from 20.0/15% to 8.0/5% -- roughly 2.5-3x the worst observed
case. Losing the correction alone now fails the check.

The within-renderer check now resizes a differently-shaped render onto its
reference instead of returning early, so it reports a real pixel diff (and
writes diff_*.png) for the Vertex/VertexRGB shape mismatches rather than just
noting the shape difference. They still fail, confirming those are a genuine
regression and not a cropping artifact.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The CROSS_RENDERER_* constants in test_visual_regression.py were a set of
magic numbers with no recorded provenance, so nobody could tell whether a
broad cross-renderer failure meant a real regression or just a stale
correction -- nor how to refit it.

Adds fit_cross_renderer_affine.py, which renders curvature-only content
(alpha=0, isolating the coordinate-frame mismatch from colormap differences)
through both paths, fits webgl -> quickflat with cv2.findTransformECC, and
prints the constants ready to paste. It reproduces the committed values
(scales exact, translations within 0.01px; mean|diff| 18.8 -> 4.6). OpenCV is
only needed for the fit, so it stays out of the project deps and the script
documents `uv run --with opencv-python-headless`.

The README section records why the correction is affine and not a reverse
perspective projection, despite webgl using a THREE.PerspectiveCamera: the
flatmap looks straight down at a planar surface, so a fitted homography
returns a projective row of [~0, ~0, 1] and collapses to the affine. That
dead end is worth writing down.

Also fixes two stale references to test_webgl_headless.py, which is where
these tests lived before they were split out.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…eement

A factorial sweep of both renderers' settings -- curvature
brightness/contrast/threshold, depth, sampler, thick/layers, and webgl's three
lighting controls -- found exactly one setting worth changing.

Thresholded curvature puts a hard binary edge at curvature=0 whose sub-pixel
placement each rasteriser resolves differently, making it maximally sensitive to
the residual misalignment between the two frames. Smooth curvature is
low-frequency and resamples cleanly. Averaged over the brightness/contrast grid
the thresholded pairing scored 8.50 against 3.66 smooth; at the default
brightness/contrast, 4.98 against 2.66. quickflat's curvature_threshold and
webgl's curvature.smoothness are the same knob from opposite ends (smoothness
0.0 *is* thresholded), so both move together or they disagree by more.

Everything else stays default on the evidence: the default lighting already ties
the best combination (2.304, and uniform_illumination=1 renders identically on a
flatmap); trilinear beats nearest by ~1%, i.e. noise; and depth/thick/layers
span only 0.38 across the whole grid, below the noise floor.

The webgl setting has to ride along with the angle params as an (name, params)
tuple. plot_panels' viewer_params is forwarded to show(), whose **kwargs
silently swallows anything that is not a named argument, so viewer state passed
that way is a no-op -- a trap worth the comment it now carries.

Note this renders curvature un-thresholded, which is not pycortex's default
appearance, so these references no longer cover the default curvature path. That
is the trade for the tighter floor.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Twenty of the twenty-four images. Vertex2D's webgl render raises before either
reference is written, so its four files (quickflat + webgl, alpha + nan) are
still at the thresholded settings; the xfail comment records that whoever fixes
gallantlab#679 has to regenerate them in the same change.

CAVEAT, needs a decision: regenerating captured the *current* Vertex and
VertexRGB renders, which include the 2px-wider trim bounding box caused by gallantlab#679.
Their references therefore now encode that regression, the shape mismatch is
gone, and those four cases XPASS instead of xfailing. The gallantlab#679 shape symptom is
no longer detected -- exactly the "regenerating is how a real regression gets
silently blessed" failure mode this directory's README warns about. XPASS does
not fail a run, so it will not announce itself.

Either their references get reverted to the pre-gallantlab#679 renders (keeping the
regression visible, at the cost of them being the only images on the old
curvature settings), or the xfail comes off those four cases and the shape change
is accepted deliberately rather than by accident.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebasing onto the Vertex2D webgl fix makes all six dataviews render, so the
xfail marks come off and the four Vertex2D references -- which the crash had
prevented REGENERATE_REFERENCE_IMAGES from refreshing -- are regenerated onto
the un-thresholded curvature settings the other twenty already use.

Both changes have to land together: dropping the marks without the refreshed
Vertex2D references, or vice versa, leaves the suite red.

Worth recording why the 2px-wider bounding box was blessed rather than treated
as an outstanding regression. With the fix applied, Vertex2D's webgl render is
still 596px wide against its untouched 594px reference, so the wider trim is the
gallantlab#679 shader change's intended new geometry, not something the fix reverts. The
references were regenerated to match it deliberately.

12 passed, no xfails or xpasses; test_webgl_headless.py 43 passed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The existing mean and fraction>16 limits are both weak against a change that
moves a small number of pixels a long way, which is what a geometry or contour
shift looks like. The gallantlab#679 vertex-shader change is the worked example: it moved
2.8% of pixels by a median of 67/255, yet scored 1.574 against a mean limit of
2.0 -- it would have passed that criterion outright, and cleared the fraction
one by only 1.4x. The mean is diluted by the ~97% of pixels that did not move.

Adds two criteria that cover the gap, measured against simulated cosmetic drift
(a 596<->594 resample, +-1 LSB quantisation, gamma 1.02) versus gallantlab#679:

    metric            cosmetic      gallantlab#679    limit
    mean                 0.596     1.574    2.0     <- would have passed
    fraction > 16        0.48%     2.79%    2%
    fraction > 64        0.00%     1.460%   0.1%    <- new, 14.6x margin
    SSIM loss           0.00215   0.03933   0.01    <- new, 3.9x margin

All four are checked because they are complementary, not ranked. Gamma 1.25
scores 6.68 mean and 47.96% on fraction>16 but 0.00% on fraction>64; an R/B
channel swap scores 21.75 mean but 0.00000 SSIM loss, since SSIM is computed on
luminance and is blind to a channel permutation. SSIM adds sensitivity alongside
the others and cannot replace them.

SSIM is implemented on scipy (already a dependency) rather than scikit-image,
and calibrated against the shipped implementation -- scipy and OpenCV differ
slightly at the boundary. Verified end to end by swapping the pre-gallantlab#679 reference
back in: the check fails naming fraction>16, fraction>64 and SSIM, and correctly
omits the mean.

The cosmetic figures are simulated, not measured across a real Chromium or
matplotlib upgrade; a genuine bump changes anti-aliasing, which can move a few
pixels a long way and does show up in fraction>64. Both new limits sit well
above the simulated drift rather than at it, to leave room for that.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Evaluating the suite against six closed-issue fixes turned up a second worked
example. cb97627 ("unify NaN and alpha handling") changes four quickflat
volumetric renders via premultiplied-alpha thickness averaging, and at a
threshold of 64 every one scores 0.000% -- missed entirely, by all four criteria.

Its mean cannot be tightened into a catch: at 0.19 it sits *below* the simulated
cosmetic floor of 0.60, so a tighter mean produces false positives before it
produces a catch. A boundary-restricted metric was also tried and refuted --
restricting to the alpha edge band *reduces* the signal tenfold, because the
thickness-averaging change is interior, not at the silhouette.

Lowering the threshold does work. At 32 the cb97627 signal is 0.256% against
the 0.1% limit, while the worst simulated cosmetic drift is 0.0052%, 19x below
it. It also strengthens the gallantlab#679 catch, 1.46% -> 2.16%. Currently-passing tests
cannot be disturbed: their within-renderer difference is exactly zero.

The cost is headroom. At 64 no cosmetic perturbation registered at all; at 32 a
real Chromium or matplotlib bump has 19x of room rather than effectively
unlimited, and anti-aliasing changes are exactly what produce the mid-amplitude
pixels the lower threshold newly counts. Worth it to convert a demonstrated
blind spot into a 2.6x catch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The NaN suite only ever put NaNs in the data channels. Alpha is a separate path:
it is not colour-mapped but used directly as a blend weight, so a NaN there
reaches the compositing arithmetic rather than a colormap lookup. Adds four
references for VolumeRGB and VertexRGB -- the only dataviews taking an explicit
alpha= -- with NaNs in the alpha map and clean colour channels.

Both renderers currently draw those elements fully transparent, the same outcome
as a NaN in the data, so this passes rather than xfails. The docstring records
that the behaviour is not settled: cb97627 ("unify NaN and alpha handling",
not in this branch's history) changes how the surviving RGB is blended without
changing the transparency, so expect these four to need regenerating if it lands.

Also corrects the README's provenance, which was actively wrong. It still claimed
the references were generated at 5af26a8; regenerating there and running against
the current tree fails the six vertex-based cases, so anyone following that
instruction would conclude something was broken. gallantlab#679 moved every pure-Vertex
flatmap's trim box from 594 to 596px and that is intended new geometry its
follow-up fix does not revert. The failures are a real content change, not a
resampling artifact: optimal affine alignment removes only 12% of the difference,
and it is concentrated in the interior rather than at the silhouette. All twelve
quickflat references are byte-identical at either commit; only the webgl ones
moved.

Brings the rest of the README up to date while there: three tests rather than
two, four within-renderer criteria rather than two, three reference directories
to regenerate rather than two.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
cb97627's rule is that a NaN anywhere at a voxel -- the data, either 2D
dimension, any RGB channel, or the alpha map -- renders fully transparent. The
suite only ever exercised two of those four: the data, and the first RGB
channel. The alpha map was covered separately; this covers the remaining two.

Volume2D/Vertex2D now take a NaN'd second dimension as well as the first, and
VolumeRGB/VertexRGB a NaN'd green channel as well as red. Each uses its own
disjoint region -- x>=50 versus y>=50 for volumes, and two non-overlapping index
ranges for vertices -- so one render exercises several branches of the rule at
once while a failure still says which one moved. Blue and the alpha map are left
clean as a control that not everything has simply gone transparent.

No new test cases, so no extra runtime: this changes which arrays the existing
NaN dataviews are built from. Eight of the twenty-eight references move (the
2D and RGB pairs in nan_dataviews); the other twenty are byte-identical.

Confirmed the new regions reach both renderers before regenerating -- quickflat
and webgl each shifted on all four dataviews, by 2.07 to 6.47 mean, while
Volume/Vertex stayed identical as expected.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The volume masks x>=50 and y>=50 overlap in a quadrant (25% of voxels carry both
NaNs); only the vertex index ranges are genuinely disjoint. The overlap is
harmless and in fact covers the both-NaN case as well, but the comment claimed
otherwise. Also names the alpha map alongside blue as the clean control, and
points at the nan_alpha suite that does cover it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Completes the coverage of cb97627's rule -- a NaN in the data, either 2D
dimension, any RGB channel, or the alpha map renders fully transparent -- inside
the suite that already carries NaNs in the colour channels. VolumeRGB/VertexRGB
now take a NaN'd alpha map on a third region (z>=15 for volumes, the quarter of
vertex indices between the other two).

This is not a duplicate of the nan_alpha suite, which isolates an alpha NaN with
every colour channel clean. Here the alpha NaNs are superposed on colour NaNs,
so the overlap exercises voxels carrying more than one NaN at once. Blue stays
clean as a control that not everything has gone transparent.

Worth knowing when reading the arrays back: the alpha map's surviving NaN
fraction is smaller than its mask (13% of a 52% region for VolumeRGB). Where a
colour channel is already NaN the pipeline writes alpha's vmin over it, so only
the part with red and green both clean stays NaN and the rest resolves to a hard
0 -- confirmed exactly equal to (z>=15) & (red clean) & (green clean). Both
halves are worth rendering, which is why the regions overlap rather than being
disjoint. Recorded in a comment, since the fraction is otherwise surprising.

Four references move (the RGB pairs in nan_dataviews); the 2D views take no
alpha= and are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A WebGL failure does not raise on its own. three.js reports a shader that
compiled but failed to link via console.error, so nothing propagates: the png is
still written and simply comes back blank, which is indistinguishable from a
successful render.

Checking *all* browser errors is not usable, which is presumably why the
existing assertions in test_webgl_headless.py filter to [pageerror]. A healthy
viewer already logs a console.error for the Leap Motion websocket it cannot
reach (ws://127.0.0.1:6437), so anything failing on "any error" fails every run.
Verified: a healthy headless render captures exactly that one message and
nothing else.

So webgl_failures() matches two unambiguous signatures only --
"THREE.WebGLProgram: Could not initialise shader" and "Error creating WebGL
context" -- and save_3d_views raises if either appears, naming the view and the
file. gl.getProgramInfoLog / gl.getShaderInfoLog warnings are deliberately
excluded: drivers emit those benignly and matching them would reintroduce the
false positives this list exists to avoid.

Scope, measured rather than assumed: this does NOT catch the Vertex2D blank
render (gallantlabgh-714). Re-running that case at the broken commit with this check in
place, the viewer produces a completely blank canvas (one distinct colour) while
logging no WebGL message at all -- only the same Leap Motion noise a healthy run
produces. That failure is silent even on the console, so no console-based check
can see it; detecting it needs the blank-render and shader-link tests that
shipped with its fix. What this does cover is context-creation failure, which
three.js throws.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@kroq-gar78
kroq-gar78 force-pushed the visual-regression-tests branch from f09c1cc to 8e383a3 Compare August 25, 2026 09:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants