crackle-check / figures.py
SharpMeow's picture
One page scroll bar and American English
faa2ae9 verified
Raw History Blame Contribute Delete
14.1 kB
"""Crackle Check pictures: the maps beside their control, where the scores fall, and the answer scale (numpy, Pillow).
Everything here draws what the gate already measured; nothing changes the answer. Map tiles are WebP data URIs and
the charts are inline SVG that take the page's text color, so they read in light and dark themes alike. The colors
follow the overlay: gray for CT and for map values under the control's threshold T, copper to amber to ivory above T,
so amber always means "passes the control's 99th percentile". Everything drawn is model output, not a reading.
"""
from __future__ import annotations
import base64
import html
import io
import numpy as np
import gate
# Charcoal to copper to amber to ivory, for map values from T up to the region's top value.
RAMP = ((0.0, (0x5a, 0x34, 0x1f)), (0.35, (0xb7, 0x7a, 0x54)), (0.7, (0xf5, 0x9e, 0x1a)), (1.0, (0xee, 0xe7, 0xd9)))
AXIS_MAX = 8.0 # the answer scale runs 0 to 8 times the control; larger values sit at the end with a "+"
BINS = 40
def ramp(t):
"""RGB float (0 to 1) for t in 0 to 1 along RAMP; t is an array."""
t = np.clip(np.asarray(t, np.float32), 0, 1)
xs = [p for p, _ in RAMP]
return np.stack([np.interp(t, xs, [c[i] / 255.0 for _, c in RAMP]) for i in range(3)], -1)
def ct_gray(ct):
"""A CT layer stretched to its 1st to 99th percentile, float 0 to 1."""
ct = np.asarray(ct, np.float32)
lo, hi = np.percentile(ct, [1, 99])
return np.clip((ct - lo) / max(hi - lo, 1e-6), 0, 1)
def heat(m, threshold, top, papyrus_mask):
"""One map as RGB uint8: under T a dim gray that rises with the value, at or above T the copper ramp.
The same T and top are used for a map and its control, so the two tiles are on one scale."""
m = np.asarray(m, np.float32)
below = np.clip(m / max(threshold, 1e-6), 0, 1) * 0.42 + 0.08
rgb = np.stack([below] * 3, -1)
over = m > threshold
if over.any():
rgb[over] = ramp((m[over] - threshold) / max(top - threshold, 1e-6))
rgb[~papyrus_mask] = 0.03
return (rgb * 255).astype(np.uint8)
def data_uri(rgb, max_side=480, quality=82):
"""An RGB array as a WebP data URI, at most max_side px a side."""
from PIL import Image
im = Image.fromarray(np.asarray(rgb, np.uint8))
if max(im.size) > max_side:
k = max_side / max(im.size)
im = im.resize((max(1, round(im.size[0] * k)), max(1, round(im.size[1] * k))), Image.LANCZOS)
b = io.BytesIO()
im.save(b, "WEBP", quality=quality, method=6)
return "data:image/webp;base64," + base64.b64encode(b.getvalue()).decode("ascii")
def _f(x):
return f"{x:.1f}".rstrip("0").rstrip(".")
def gauge_svg(excess=None, width=440):
"""The answer scale as SVG: No under 1.5 times the control, Maybe to 3, Yes from 3, with a marker at excess."""
h, top, bar = 70, 22, 14
x = lambda v: 8 + (width - 16) * min(max(v, 0.0), AXIS_MAX) / AXIS_MAX # noqa: E731
zones = ((0, gate.WEAK_EXCESS, "var(--cc-no, #4a5563)", "No signal found"),
(gate.WEAK_EXCESS, gate.INK_EXCESS, "var(--cc-maybe, #8a5a00)", "Maybe"),
(gate.INK_EXCESS, AXIS_MAX, "var(--cc-yes, #1f7a4d)", "Yes"))
parts = [f'<svg class="cc-gauge" viewBox="0 0 {width} {h}" width="100%" role="img" '
f'aria-label="Answer scale: No under 1.5, Maybe 1.5 to 3, Yes from 3 times the control'
+ (f'; this region {_f(excess)} times' if excess is not None else '') + '" '
'style="width:100%;max-width:560px;height:auto;font:12px ui-sans-serif,system-ui,sans-serif">']
for a, b, color, label in zones:
parts.append(f'<rect x="{x(a):.1f}" y="{top}" width="{x(b) - x(a):.1f}" height="{bar}" fill="{color}" '
f'opacity=".8"/>')
parts.append(f'<text x="{(x(a) + x(b)) / 2:.1f}" y="{top - 7}" text-anchor="middle" fill="currentColor" '
f'opacity=".85">{label}</text>')
for v in (0, 1, gate.WEAK_EXCESS, gate.INK_EXCESS, 5, AXIS_MAX):
if excess is not None and abs(x(v) - x(excess)) < 34:
continue # the marker's own label goes there
parts.append(f'<text x="{x(v):.1f}" y="{top + bar + 14}" text-anchor="middle" fill="currentColor" '
f'opacity=".6">{_f(v)}{"+" if v == AXIS_MAX else ""}</text>')
parts.append(f'<line x1="{x(1):.1f}" x2="{x(1):.1f}" y1="{top - 2}" y2="{top + bar + 2}" stroke="currentColor" '
'stroke-dasharray="2 2" opacity=".6"/>')
if excess is not None:
cx = x(excess)
parts.append(f'<path d="M{cx - 7:.1f},{top - 1} L{cx + 7:.1f},{top - 1} L{cx:.1f},{top + 9} Z" '
'fill="currentColor" stroke="var(--background-fill-primary, #fff)" stroke-width="1.5"/>')
parts.append(f'<rect x="{cx - 1.5:.1f}" y="{top}" width="3" height="{bar}" fill="currentColor"/>')
anchor = "end" if cx > width - 40 else "start" if cx < 40 else "middle"
parts.append(f'<text x="{cx:.1f}" y="{top + bar + 15}" text-anchor="{anchor}" fill="currentColor" '
f'font-weight="700">{_f(excess)} x</text>')
parts.append("</svg>")
return "".join(parts)
def histogram_svg(F, R, mask, threshold, width=440, height=170):
"""Where the scores fall on the papyrus: the control's (reversed) scores in gray, yours in amber, T dashed.
Bar heights are the share of papyrus pixels per bin on a square-root axis, so the 1 % tail stays visible."""
f, r = np.asarray(F, np.float32)[mask], np.asarray(R, np.float32)[mask]
hi = float(max(f.max(), r.max(), threshold * 1.05, 1e-3))
lo = float(min(f.min(), r.min(), threshold * 0.95)) if f.size else 0.0
edges = np.linspace(lo, hi, BINS + 1)
hf = np.histogram(f, edges)[0] / max(f.size, 1)
hr = np.histogram(r, edges)[0] / max(r.size, 1)
left, right, top, bottom = 8, 8, 26, 30
pw, ph = width - left - right, height - top - bottom
peak = float(np.sqrt(max(hf.max(), hr.max(), 1e-9)))
bw = pw / BINS
X = lambda v: left + pw * (v - lo) / max(hi - lo, 1e-9) # noqa: E731
Y = lambda share: top + ph * (1 - np.sqrt(share) / peak) # noqa: E731
def bars(h, color, opacity, inset):
out = []
for i, s in enumerate(h):
if s <= 0:
continue
y = Y(s)
out.append(f'<rect x="{left + i * bw + inset:.1f}" y="{y:.1f}" width="{max(bw - 2 * inset, 0.5):.1f}" '
f'height="{top + ph - y:.1f}" fill="{color}" opacity="{opacity}"/>')
return "".join(out)
tx = X(threshold)
above_f = float((f > threshold).mean()) if f.size else 0.0
above_r = float((r > threshold).mean()) if r.size else 0.0
parts = [f'<svg class="cc-hist" viewBox="0 0 {width} {height}" width="100%" role="img" '
f'aria-label="Score histogram: {above_f:.1%} of your order and {above_r:.1%} of the control pass T" '
'style="width:100%;max-width:560px;height:auto;font:12px ui-sans-serif,system-ui,sans-serif">',
f'<rect x="{tx:.1f}" y="{top}" width="{left + pw - tx:.1f}" height="{ph}" fill="#f59e1a" opacity=".08"/>',
bars(hr, "currentColor", ".35", 0.6),
bars(hf, "#f59e1a", ".85", 2.2),
f'<line x1="{left}" x2="{left + pw}" y1="{top + ph}" y2="{top + ph}" stroke="currentColor" opacity=".4"/>',
f'<line x1="{tx:.1f}" x2="{tx:.1f}" y1="{top - 6}" y2="{top + ph}" stroke="currentColor" '
'stroke-dasharray="4 3"/>',
f'<text x="{tx - 6:.1f}" y="{top - 10}" text-anchor="end" fill="currentColor">T {threshold:.3f}</text>',
(f'<text x="{tx + 6:.1f}" y="{top - 10}" fill="currentColor">' if tx < 0.6 * width else
f'<text x="{left + pw:.1f}" y="{top + 12}" text-anchor="end" fill="currentColor">')
+ f'<tspan font-weight="700" fill="#e08a0c">{above_f:.1%}</tspan> yours, {above_r:.1%} control</text>',
f'<text x="{left}" y="{height - 8}" fill="currentColor" opacity=".6">{lo:.2f} (less ink-like)</text>',
f'<text x="{left + pw}" y="{height - 8}" text-anchor="end" fill="currentColor" opacity=".6">'
f'{hi:.2f} (more ink-like)</text>',
"</svg>"]
return "".join(parts)
def compare_html(volume, F, R, decision):
"""The two maps on one scale beside each other and the score histogram, as one HTML block (the verdict card
above it carries the answer scale).
Empty when the gate set no threshold (too small or unreadable): there is nothing to compare then."""
T = decision.get("threshold")
if T is None:
return ""
ct = np.asarray(volume)[np.asarray(volume).shape[0] // 2]
mask = gate.papyrus(ct)
F, R = np.asarray(F, np.float32), np.asarray(R, np.float32)
top = float(np.percentile(np.concatenate([F[mask], R[mask]]), 99.8)) if mask.any() else 1.0
tiles = []
for title, m, share in (("Your layer order", F, decision.get("forward_above")),
("Control: layers reversed", R, decision.get("control_above"))):
tiles.append(f'<figure><img src="{data_uri(heat(m, T, max(top, T * 1.01), mask))}" alt="{html.escape(title)}: '
f'model score map, pixels above T in amber"><figcaption><b>{html.escape(title)}</b><br>'
f'{(share or 0):.1%} of papyrus above T</figcaption></figure>')
return (f'<div class="cc-figs"><div class="cc-h">The map and its control, on one scale</div>'
f'<div class="cc-pair">{"".join(tiles)}</div>'
'<div class="cc-cap">Amber: model score above T, the level only 1 % of the control passes. Gray: below T. '
'Black: not papyrus.</div>'
f'<div class="cc-h">Where the scores fall</div>{histogram_svg(F, R, mask, T)}'
'<div class="cc-cap">Share of papyrus pixels at each score (square-root scale): the control in gray, your '
'order in amber. The answer is your share above T divided by the control\'s 1 %. Model output, not a '
'reading.</div></div>')
def composite(volume, F, R, kept, decision, side=480, gap=12, background=(23, 26, 28)):
"""CT, the overlay and the two maps in one RGB strip (for the README and the downloads), no text baked in."""
from PIL import Image
ct = np.asarray(volume)[np.asarray(volume).shape[0] // 2]
g = ct_gray(ct)
mask = gate.papyrus(ct)
T = decision.get("threshold") or 1.0
top = float(np.percentile(np.concatenate([F[mask], R[mask]]), 99.8)) if mask.any() else 1.0
a = (np.asarray(kept, np.float32) / 255.0)[..., None] * 0.85
over = np.stack([g] * 3, -1) * (1 - a) + np.array([1.0, 0.62, 0.1]) * a
panels = [np.stack([g] * 3, -1), over, heat(F, T, max(top, T * 1.01), mask) / 255.0,
heat(R, T, max(top, T * 1.01), mask) / 255.0]
ims = [Image.fromarray((np.clip(p, 0, 1) * 255).astype(np.uint8)).resize((side, side), Image.LANCZOS)
for p in panels]
out = Image.new("RGB", (len(ims) * side + (len(ims) + 1) * gap, side + 2 * gap), background)
for i, im in enumerate(ims):
out.paste(im, (gap + i * (side + gap), gap))
return np.asarray(out)
def stack_svg(width=480, height=280):
"""A drawing (not data) of what the models read: a stack of layers through the papyrus surface, read in your
order and, as the control, reversed."""
sheets, dy, w, h, skew, x0, y0 = 9, 19, 200, 30, 46, 112, 34
mid = sheets // 2
parts = [f'<svg class="cc-stack" viewBox="0 0 {width} {height}" width="100%" role="img" '
'aria-label="Drawing: a stack of nine flat layers through a papyrus sheet; the middle one is the surface '
'with ink; arrows show your order top to bottom and the control bottom to top" '
'style="width:100%;max-width:520px;height:auto;font:12px ui-sans-serif,system-ui,sans-serif">',
'<defs><marker id="cc-arr" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="7" markerHeight="7" '
'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker></defs>']
for i in range(sheets):
y = y0 + i * dy
pts = f"{x0 + skew},{y} {x0 + skew + w},{y} {x0 + w},{y + h} {x0},{y + h}"
if i == mid:
parts.append(f'<polygon points="{pts}" fill="#b77a54" fill-opacity=".55" stroke="#b77a54"/>')
for cx, cy, rx in ((0.30, 0.45, 20), (0.52, 0.55, 14), (0.70, 0.40, 18)):
parts.append(f'<ellipse cx="{x0 + skew * (1 - cy) + w * cx:.1f}" cy="{y + h * cy:.1f}" rx="{rx}" '
'ry="5" fill="#f59e1a"/>')
else:
near = 1 - abs(i - mid) / (mid + 1)
parts.append(f'<polygon points="{pts}" fill="currentColor" fill-opacity="{0.05 + 0.08 * near:.2f}" '
'stroke="currentColor" stroke-opacity=".35"/>')
yb = y0 + (sheets - 1) * dy + h
parts += [f'<text x="{x0 + skew + w + 8}" y="{y0 + mid * dy + 12}" fill="currentColor">surface, with ink</text>',
f'<text x="{x0 + skew + w + 8}" y="{y0 + 12}" fill="currentColor" opacity=".7">layer 00</text>',
f'<text x="{x0 + skew + w + 8}" y="{y0 + (sheets - 1) * dy + 12}" fill="currentColor" '
'opacity=".7">layer 64</text>',
f'<line x1="40" y1="{y0 + 4}" x2="40" y2="{yb - 4}" stroke="currentColor" stroke-width="2" '
'marker-end="url(#cc-arr)"/>',
f'<text x="40" y="{y0 - 12}" text-anchor="middle" fill="currentColor" font-weight="700">Your order</text>',
f'<line x1="86" y1="{yb - 4}" x2="86" y2="{y0 + 4}" stroke="currentColor" stroke-width="2" '
'stroke-dasharray="5 4" opacity=".7" marker-end="url(#cc-arr)"/>',
f'<text x="86" y="{yb + 18}" text-anchor="middle" fill="currentColor" opacity=".8">Control</text>',
f'<text x="{width / 2:.0f}" y="{height - 4}" text-anchor="middle" fill="currentColor" opacity=".7">'
'Drawing, not data. The models read 17 layers at once.</text>',
"</svg>"]
return "".join(parts)