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/max tell 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 between min and max means 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/a are the patch mean. x/y/w/h echo the rectangle actually read after clipping, so a probe near an edge reports what it sampled. width/height are 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
    }
    

    grain is the metric. worst and worstX/worstY are 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. samples is 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/plateauHigh are 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
    }
    

    edgeWidth is the metric, as a median over scanlines so a slanted or interrupted edge does not drag it. peakGradient is diagnostic only, for the reason above. flatScanlines counts 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" }. match is true when the current frame matches the stored reference (or when this call stored a new reference). false only 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 }. stored is true when this call created the reference (then diff is 0); otherwise diff is the percentage of pixels that differ from the reference (-1 if the frame and reference differ in size). Compare diff against your own threshold (e.g. 0.5).
  • 503 Service Unavailable — JSON { "error": "No rendered frame available" } when nothing has rendered yet.