Image Checksum
GET /api/image/pixel
Requires: read (viewer+)
Reads the rendered colour at a point: the mean of a small patch centred on x,y, plus that patch's per-channel minimum and maximum. Answers "what does the frame look like here", which neither /api/image/get (an encoded image you have to decode yourself) nor the checksum endpoints (equal / not equal) can.
Written for automated tests. The comparison available before this was byte-equality on a captured JPEG, which catches changed / did not change but never changed the right way — a shading defect that made glass brighter than the scene behind it passed every such assertion while being obvious on screen.
Three properties worth knowing before asserting against it:
- Sampled from the rendered surface, not from a JPEG. The values are exact 8-bit, with no compression error, which is what makes a tolerance-based assertion stable.
- Post-tonemap, display-encoded (Rec.709) 8-bit — not linear light. Two patches compare the way a viewer sees them. Do not expect linear ratios: glass transmitting 63% of green does not read as
0.63 ×the backdrop's green value. min/maxtell you whether the probe is well placed. A patch straddling an object's silhouette or a colour-bar transition averages two unrelated regions into a plausible-looking number. A wide spread betweenminandmaxmeans the probe is misplaced, not that the render is wrong — assert on a patch whose spread is small.
The patch is small by default on purpose. Averaging is not for noise (a rendered frame is deterministic — the reference-image tests depend on that); it is to tolerate a pixel of placement error. A large patch is actively worse, because at 1080p a 16×16 easily spans two features.
Parameters:
| Parameter | Required | Description |
|---|---|---|
scene |
No | Scene name. If omitted, uses the topmost scene. |
x |
Yes | Horizontal position of the patch centre, in pixels. |
y |
Yes | Vertical position of the patch centre, in pixels. |
w |
No | Patch width. Default 4, maximum 256; 0 or negative means the default. |
h |
No | Patch height. Same defaults as w. |
A patch that hangs over a frame edge is clipped to the part inside the frame rather than rejected, so probing a corner works. A centre point outside the frame is an error rather than being clamped — clamping would answer a question you did not ask, and the wrong answer would be indistinguishable from a real reading.
Response:
200 OK— JSON:{ "r": 19, "g": 109, "b": 48, "a": 255, "min": { "r": 17, "g": 106, "b": 45, "a": 255 }, "max": { "r": 21, "g": 112, "b": 51, "a": 255 }, "x": 958, "y": 398, "w": 4, "h": 4, "width": 1920, "height": 1080 }r/g/b/aare the patch mean.x/y/w/hecho the rectangle actually read after clipping, so a probe near an edge reports what it sampled.width/heightare the frame's dimensions.400 Bad Request— JSON:{ "error": "Point 5000,400 is outside the 1920x1080 frame", "width": 1920, "height": 1080 }.503 Service Unavailable— JSON:{ "error": "No rendered frame available" }when the scene has not produced a frame yet.
Example — assert that glass is not brighter than the backdrop behind it:
GET /api/image/pixel?scene=Scene&x=1250&y=430 # the backdrop, beside the glass
GET /api/image/pixel?scene=Scene&x=1450&y=430&w=8&h=8 # through the glass
ComposerTestSdk wraps this as GetPixelAsync(...); see its MANUAL.md.
GET /api/image/grain
Requires: read (viewer+)
Measures how grainy a region is: the mean absolute difference between each pixel's luma and the median luma of the 3×3 neighbourhood around it. High-frequency variation scores; a smooth gradient or a hard edge does not — a median is unmoved by a single outlier, so a lone bright pixel counts as grain while a step between two flat regions counts as almost nothing. That is what makes it a noise metric rather than a contrast one.
Written because ray-traced indirect light is a stochastic estimate, so "is this too noisy" is a question no pixel probe can answer — /api/image/pixel reports a patch mean, which is exactly what noise averages out of. Three tickets in a row needed this figure and each computed it by hand from a captured JPEG, which is wrong twice over: compression removes the fine variation being measured, and an ad-hoc metric is not comparable between runs. One measured consequence — a sample that reads 9 through this route read 3 off a JPEG, which was enough to mis-set an autotest threshold.
Compare, do not calibrate. Grain differs between GPUs because ray ordering does, so an absolute threshold is not portable. A ratio between two configurations of the same scene on the same machine is. For scale, measured on a Cornell box lit by one spot: about 1.5 is the residual after filtering, and about 16 is the same scene tracing per pixel unfiltered.
Values are post-tonemap, display-encoded 8-bit luma, so the figure reads as levels of visible grain rather than anything in linear light.
Parameters:
| Parameter | Required | Description |
|---|---|---|
scene |
No | Scene name. If omitted, uses the topmost scene. |
x |
Yes | Horizontal position of the patch centre, in pixels. |
y |
Yes | Vertical position of the patch centre, in pixels. |
w |
No | Patch width, centred on x like /api/image/pixel. Default 4, silently clamped to 256 — ask for 320 and you measure 256, so check the echoed w rather than assuming. Must clip to at least 3, since a 3×3 neighbourhood needs a one-pixel border. |
h |
No | Patch height. Same defaults as w. |
Only the patch interior is measured, since an edge pixel has no full neighbourhood; inventing one by clamping would bias the result toward zero along the border. A useful patch is therefore considerably larger than a pixel probe's — 64×32 over a flat wall is a reasonable size.
Response:
200 OK— JSON:{ "grain": 1.39, "worst": 10.52, "worstX": 907, "worstY": 141, "samples": 21844, "x": 832, "y": 93, "w": 256, "h": 88, "width": 1920, "height": 720 }grainis the metric.worstandworstX/worstYare the single worst pixel and where it is — a high mean with a modest worst is grain, whereas a low mean with an extreme worst is one hot pixel or a misplaced probe, and the two want different fixes.samplesis how many interior pixels were measured.400 Bad Request— when the patch clips to smaller than 3×3.503 Service Unavailable— JSON:{ "error": "No rendered frame available" }when the scene has not produced a frame yet.
Example — the assertion that ray-traced indirect light is actually being smoothed, expressed as a ratio so it survives a change of GPU:
GET /api/image/grain?scene=Scene&x=960&y=137&w=256&h=88 # a bounce-lit wall
Measured on that patch, the same scene twice: 16.72 with FilterTracedLighting off and 1.39 with it on. Assert the
ratio, not either number. Note also that worst moves with it — 80.58 against 10.52 — which is the check that the probe is
measuring grain rather than sitting on an edge.
ComposerTestSdk wraps this as GetGrainAsync(...); see its MANUAL.md.
GET /api/image/sharpness
Requires: read (viewer+)
Measures how sharp an edge is: the 10-90% rise distance across the strongest transition in the patch, in pixels. 1-2 px is a hard edge; tens of pixels is a soft one. The counterpart to /api/image/grain - that measures unwanted high-frequency variation, this measures wanted high-frequency structure. Neither is reachable with /api/image/pixel, which reports a patch mean.
Added after a change that blurred every projected gobo in the renderer passed a full 86-fixture band and 873 unit tests while being obvious on screen. The gobo fixture asserts pattern contrast at bar centres, and a blurred bar's centre stays fully saturated, so the suite was blind to blur by construction rather than by accident.
Why level crossings and not a gradient. Mean gradient, Tenengrad and Laplacian variance were tried first and are unusable here: on a soft edge the true ramp slope is around 1 luma/px while single-pixel dither is around 10, so they measure the noise. Measured on a real penumbra, a gradient estimator reported 4 px where the edge is 24. This smooths by three, anchors at the steepest point, then walks outward to the 10% and 90% levels - none of which single-pixel noise can move.
Parameters:
| Parameter | Required | Description |
|---|---|---|
scene |
No | Scene name. If omitted, uses the topmost scene. |
x |
Yes | Horizontal position of the patch centre, in pixels. |
y |
Yes | Vertical position of the patch centre, in pixels. |
w |
No | Patch width, centred on x. Default 4, silently clamped to 256. |
h |
No | Patch height. Same defaults as w. |
axis |
No | The direction that crosses the edge: x (default, for a vertical edge) or y. Scanning along an edge finds nothing. |
Two placement rules, and the first one is the trap:
- The strongest edge in the patch wins, and it may not be yours. In a composited frame the 2D layers have the sharpest edges of all, so a probe that strays over a colour-bar boundary reports a hard edge whatever the 3D is doing.
plateauLow/plateauHighare how you confirm which step was measured - if they are not the two levels you expected, the probe is misplaced. - Make the patch several times wider than the edge. The plateaus are read a third of the span either side of the transition, so a patch barely wider than the edge never reaches them and under-reports. At least 12 pixels across, and far more for a soft edge.
Response:
200 OK- JSON:{ "edgeWidth": 2, "plateauLow": 62, "plateauHigh": 150, "peakGradient": 39.54, "scanlines": 36, "flatScanlines": 0, "axis": "x", "x": 620, "y": 362, "w": 80, "h": 36, "width": 1920, "height": 720 }edgeWidthis the metric, as a median over scanlines so a slanted or interrupted edge does not drag it.peakGradientis diagnostic only, for the reason above.flatScanlinescounts lines with no measurable step - a large share means the patch mostly misses the edge.400 Bad Request- when the patch is under 12 px across, or when every scanline is flat.503 Service Unavailable- when the scene has not produced a frame yet.
Example - the assertion behind ticket 10688, comparing two edges of one shadow so it needs no per-GPU or per-camera calibration:
GET /api/image/sharpness?scene=Scene&x=660&y=380&w=80&h=36 # a gobo's keyed-stripe boundary
GET /api/image/sharpness?scene=Scene&x=360&y=380&w=160&h=36 # the occluder's own penumbra
Measured on 10687-catcher-gobo-demo: 2 px against 24 px - the 12x mismatch inside one composited shadow that 10688 exists to fix.
ComposerTestSdk wraps this as GetSharpnessAsync(...); see its MANUAL.md.
GET /api/image/checksum
Requires: read (viewer+)
Returns a perceptual hash of the current rendered frame for a scene, as a 16-character hexadecimal string. Perceptual (rather than cryptographic / pixel-perfect) so that minor codec / colour drift doesn't trip the comparison — useful for visual-regression checks where two near-identical frames should compare equal but a meaningful change should not.
Pairs with /api/image/checksum/compareorstore for stored-reference workflows.
Parameters:
| Parameter | Required | Description |
|---|---|---|
scene |
No | Scene name. If omitted, uses the topmost scene. |
Response:
200 OK— JSON:{ "checksum": "A1B2C3D4E5F60718" }503 Service Unavailable— JSON:{ "error": "No rendered frame available" }when the scene has not produced a frame yet.
GET /api/image/checksum/compareorstore
Requires: read (viewer+) — it only reads rendered frames and an in-memory reference cache.
Compares the current rendered scene's perceptual hash against a stored reference identified by step. If no reference exists for that step name yet, the current hash is stored as the new reference (so the first call to a step is always a "store"; subsequent calls compare against it). Stored references live in-memory for the lifetime of the running Composer session.
The natural workflow is for autotest / regression scripts: drive the project to a known state, call compareorstore?step=intro to seed a baseline, then re-run the test and call the same URL to confirm the rendered output still matches.
Parameters:
| Parameter | Required | Description |
|---|---|---|
scene |
No | Scene name. If omitted, uses the topmost scene. |
step |
Yes | Reference name to compare against or store. |
Response:
200 OK— JSON:{ "match": true, "step": "intro" }.matchistruewhen the current frame matches the stored reference (or when this call stored a new reference).falseonly when a reference existed and the current frame differs from it.400 Bad Request— JSON:{ "error": "Missing required 'step' query parameter" }.
GET /api/image/reference/compareorstore
Requires: read (viewer+) — it reads rendered frames and a reference PNG stored next to the project file.
Pixel-compares the current rendered scene against the project's reference image — {ProjectName}-reference.png, next to the loaded .prj. If that file does not exist yet, the current frame is stored as the reference (a lossless PNG) and the call reports stored: true; otherwise it returns the percentage of differing pixels. This is a stricter, pixel-accurate alternative to /api/image/checksum/compareorstore (which uses a tolerant perceptual hash): use it when you want to catch subtle rendering changes and keep a human-reviewable golden image under version control.
The natural workflow: drive the project to a known state, call this once to seed {ProjectName}-reference.png, review that PNG, commit it, then re-run and call the same URL to confirm the output still matches within your tolerance.
Parameters:
| Parameter | Required | Description |
|---|---|---|
scene |
No | Scene name. If omitted, uses the topmost scene. |
Response:
200 OK— JSON:{ "stored": false, "diff": 0.12 }.storedistruewhen this call created the reference (thendiffis0); otherwisediffis the percentage of pixels that differ from the reference (-1if the frame and reference differ in size). Comparediffagainst your own threshold (e.g.0.5).503 Service Unavailable— JSON{ "error": "No rendered frame available" }when nothing has rendered yet.