|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Pixel-value assertions for the TEXT node render sweep. |
| 3 | +
|
| 4 | + analyze.py <grab-dir> |
| 5 | +
|
| 6 | +<grab-dir> holds one 1280x720 PNG per case, produced by text-render.sh from |
| 7 | +a single live app run. Background is pure black; text is drawn white (or the |
| 8 | +case's color) so "text pixels" = pixels with luma > LUMA_T. |
| 9 | +
|
| 10 | +Assertions are VALUES, not just non-blankness: |
| 11 | + default untouched controls: currently XFAIL — known off-screen-default |
| 12 | + bug (default position (0.5,0.5) pushes the text above the |
| 13 | + screen; see the check body for file:line) |
| 14 | + default-pos0 position (0,0) with default string/font/size: MUST be visible |
| 15 | + base solid coverage; reference bbox/centroid for the movement cases |
| 16 | + sizes coverage(96pt) > coverage(48pt) > coverage(24pt), bbox grows |
| 17 | + font DejaVu Sans Mono vs Noto Sans rasterize differently |
| 18 | + color red case: R channel dominates G/B on text pixels |
| 19 | + position pos-right centroid right of pos-left; pos-down moves centroid |
| 20 | + DOWN in image coords (clip -y = screen down); y-cases share x |
| 21 | + scale 0.5 scale halves the bbox (within tolerance) + less coverage |
| 22 | + unicode/cjk render with real coverage (accented Latin + CJK glyphs) |
| 23 | + longstr ~2000 chars: more coverage than base, no crash |
| 24 | + tofu unassigned codepoints: no crash; frame present (tofu boxes or |
| 25 | + blank both acceptable) |
| 26 | + empty clean blank frame |
| 27 | + base-again bit-near-identical to base (recovery after edge cases + |
| 28 | + in-run determinism) |
| 29 | +
|
| 30 | +Exit 0 iff all assertions pass; prints one line per check. |
| 31 | +""" |
| 32 | +import sys |
| 33 | +import pathlib |
| 34 | + |
| 35 | +import numpy as np |
| 36 | +from PIL import Image |
| 37 | + |
| 38 | +LUMA_T = 40.0 # text-pixel threshold on BT.601 luma (bg is pure black) |
| 39 | + |
| 40 | +failures = [] |
| 41 | +checks = 0 |
| 42 | + |
| 43 | + |
| 44 | +def check(name, cond, detail=""): |
| 45 | + global checks |
| 46 | + checks += 1 |
| 47 | + status = "ok " if cond else "FAIL" |
| 48 | + print(f" [{status}] {name} {detail}") |
| 49 | + if not cond: |
| 50 | + failures.append(name) |
| 51 | + |
| 52 | + |
| 53 | +def load(d, name): |
| 54 | + p = d / f"{name}.png" |
| 55 | + if not p.is_file(): |
| 56 | + return None |
| 57 | + return np.asarray(Image.open(p).convert("RGB"), dtype=np.float64) |
| 58 | + |
| 59 | + |
| 60 | +def luma(img): |
| 61 | + return img @ np.array([0.299, 0.587, 0.114]) |
| 62 | + |
| 63 | + |
| 64 | +def stats(img): |
| 65 | + """(count, bbox(x0,y0,x1,y1), centroid(cx,cy)) of text pixels.""" |
| 66 | + mask = luma(img) > LUMA_T |
| 67 | + n = int(mask.sum()) |
| 68 | + if n == 0: |
| 69 | + return 0, None, None |
| 70 | + ys, xs = np.nonzero(mask) |
| 71 | + return n, (xs.min(), ys.min(), xs.max(), ys.max()), (xs.mean(), ys.mean()) |
| 72 | + |
| 73 | + |
| 74 | +def main(): |
| 75 | + d = pathlib.Path(sys.argv[1]) |
| 76 | + imgs = {} |
| 77 | + for name in ["default", "default-pos0", "base", "base-again", "size-small", |
| 78 | + "size-large", "font-sans", "color-red", "pos-left", |
| 79 | + "pos-right", "pos-down", "scale-half", "unicode", "cjk", |
| 80 | + "longstr", "tofu", "empty"]: |
| 81 | + imgs[name] = load(d, name) |
| 82 | + check(f"grab:{name}", imgs[name] is not None) |
| 83 | + |
| 84 | + def S(n): |
| 85 | + return stats(imgs[n]) if imgs[n] is not None else (0, None, None) |
| 86 | + |
| 87 | + # --- default visibility (the off-screen-default regression check) ----- |
| 88 | + # Hard assertion: an untouched Text process MUST render visible text. This |
| 89 | + # guards the fix for the off-screen-default bug (the UBO default position |
| 90 | + # was {0.5,0.5} in Gfx/Graph/TextNode.hpp, shifting the quad +0.5 clip |
| 91 | + # up/right so the default text landed ~180px above the visible area; now |
| 92 | + # {0,0} = screen centre). Regressing it fails the suite. |
| 93 | + n_def, bb_def, _ = S("default") |
| 94 | + check("default-visible", n_def > 200, |
| 95 | + f"count={n_def} bbox={bb_def} (untouched Text must render on-screen)") |
| 96 | + |
| 97 | + # default-pos0 sets ONLY position=(0,0), keeping the default string/font/ |
| 98 | + # size: it must be visible, proving the default text/font/size DO |
| 99 | + # propagate and the position default alone causes the blank frame. |
| 100 | + n_dp, bb_dp, _ = S("default-pos0") |
| 101 | + check("default-pos0-visible", n_dp > 200, |
| 102 | + f"count={n_dp} bbox={bb_dp} (default text, repositioned on screen)") |
| 103 | + |
| 104 | + # --- base reference --------------------------------------------------- |
| 105 | + n_base, bb_base, c_base = S("base") |
| 106 | + check("base-coverage", n_base > 500, f"count={n_base} bbox={bb_base}") |
| 107 | + |
| 108 | + # --- point size ordering --------------------------------------------- |
| 109 | + n_s, bb_s, _ = S("size-small") |
| 110 | + n_l, bb_l, _ = S("size-large") |
| 111 | + check("size-count-order", n_s > 0 and n_l > n_base > n_s, |
| 112 | + f"24pt={n_s} 48pt={n_base} 96pt={n_l}") |
| 113 | + if bb_s and bb_l: |
| 114 | + w_s, w_l = bb_s[2] - bb_s[0], bb_l[2] - bb_l[0] |
| 115 | + h_s, h_l = bb_s[3] - bb_s[1], bb_l[3] - bb_l[1] |
| 116 | + check("size-bbox-grows", w_l > w_s * 1.5 and h_l > h_s * 1.5, |
| 117 | + f"w:{w_s}->{w_l} h:{h_s}->{h_l}") |
| 118 | + else: |
| 119 | + check("size-bbox-grows", False, "missing bbox") |
| 120 | + |
| 121 | + # --- font family actually changes the raster ------------------------- |
| 122 | + if imgs["font-sans"] is not None and imgs["base"] is not None: |
| 123 | + n_f, _, _ = S("font-sans") |
| 124 | + diff = int((np.abs(luma(imgs["font-sans"]) - luma(imgs["base"])) > LUMA_T).sum()) |
| 125 | + check("font-differs", n_f > 500 and diff > 200, |
| 126 | + f"sans-count={n_f} differing-px={diff}") |
| 127 | + else: |
| 128 | + check("font-differs", False, "missing grabs") |
| 129 | + |
| 130 | + # --- color control ---------------------------------------------------- |
| 131 | + if imgs["color-red"] is not None: |
| 132 | + img = imgs["color-red"] |
| 133 | + mask = img[:, :, 0] > 128 # solidly-red pixels (avoid AA fringe) |
| 134 | + n_red = int(mask.sum()) |
| 135 | + if n_red > 0: |
| 136 | + mr = img[:, :, 0][mask].mean() |
| 137 | + mg = img[:, :, 1][mask].mean() |
| 138 | + mb = img[:, :, 2][mask].mean() |
| 139 | + check("color-red-dominant", mr > 200 and mg < 60 and mb < 60, |
| 140 | + f"count={n_red} RGB=({mr:.0f},{mg:.0f},{mb:.0f})") |
| 141 | + else: |
| 142 | + check("color-red-dominant", False, "no red pixels") |
| 143 | + # white base must NOT pass the red predicate (sanity of the check) |
| 144 | + bimg = imgs["base"] |
| 145 | + bmask = bimg[:, :, 0] > 128 |
| 146 | + check("color-base-is-white", |
| 147 | + bmask.sum() > 0 and bimg[:, :, 1][bmask].mean() > 200, |
| 148 | + f"base green-mean={bimg[:, :, 1][bmask].mean():.0f}") |
| 149 | + else: |
| 150 | + check("color-red-dominant", False, "missing grab") |
| 151 | + |
| 152 | + # --- position control ------------------------------------------------- |
| 153 | + n_pl, _, c_pl = S("pos-left") |
| 154 | + n_pr, _, c_pr = S("pos-right") |
| 155 | + n_pd, _, c_pd = S("pos-down") |
| 156 | + if c_pl and c_pr and c_base: |
| 157 | + # clip +x = screen right: right centroid must sit ~640px right of left |
| 158 | + # (1.0 clip delta = full 1280px width => 0.5+0.5 => 640px), and both |
| 159 | + # straddle base. Allow generous slack for clipped glyph edges. |
| 160 | + dx = c_pr[0] - c_pl[0] |
| 161 | + check("pos-x-order", c_pl[0] < c_base[0] < c_pr[0] and dx > 300, |
| 162 | + f"cx left={c_pl[0]:.0f} base={c_base[0]:.0f} right={c_pr[0]:.0f} dx={dx:.0f}") |
| 163 | + check("pos-x-keeps-y", |
| 164 | + abs(c_pl[1] - c_base[1]) < 40 and abs(c_pr[1] - c_base[1]) < 40, |
| 165 | + f"cy l/b/r={c_pl[1]:.0f}/{c_base[1]:.0f}/{c_pr[1]:.0f}") |
| 166 | + else: |
| 167 | + check("pos-x-order", False, f"counts l/r/base={n_pl}/{n_pr}/{n_base}") |
| 168 | + if c_pd and c_base: |
| 169 | + # clip -y = screen DOWN (image row index grows): centroid must move |
| 170 | + # down by ~0.25*720=180px; keep x roughly unchanged. |
| 171 | + dy = c_pd[1] - c_base[1] |
| 172 | + check("pos-y-down", dy > 100, f"cy base={c_base[1]:.0f} down={c_pd[1]:.0f} dy={dy:.0f}") |
| 173 | + check("pos-y-keeps-x", abs(c_pd[0] - c_base[0]) < 40, |
| 174 | + f"cx base={c_base[0]:.0f} down={c_pd[0]:.0f}") |
| 175 | + else: |
| 176 | + check("pos-y-down", False, f"count={n_pd}") |
| 177 | + |
| 178 | + # --- scale ------------------------------------------------------------- |
| 179 | + n_sc, bb_sc, _ = S("scale-half") |
| 180 | + if bb_sc and bb_base: |
| 181 | + w_b, w_h = bb_base[2] - bb_base[0], bb_sc[2] - bb_sc[0] |
| 182 | + ratio = w_h / max(w_b, 1) |
| 183 | + check("scale-half-bbox", 0.3 < ratio < 0.7 and n_sc < n_base, |
| 184 | + f"w {w_b}->{w_h} ratio={ratio:.2f} count {n_base}->{n_sc}") |
| 185 | + else: |
| 186 | + check("scale-half-bbox", False, f"count={n_sc}") |
| 187 | + |
| 188 | + # --- unicode / cjk ----------------------------------------------------- |
| 189 | + n_u, _, _ = S("unicode") |
| 190 | + check("unicode-coverage", n_u > 500, f"count={n_u}") |
| 191 | + n_c, _, _ = S("cjk") |
| 192 | + check("cjk-coverage", n_c > 500, f"count={n_c}") |
| 193 | + |
| 194 | + # --- very long string -------------------------------------------------- |
| 195 | + n_lo, _, _ = S("longstr") |
| 196 | + check("longstr-coverage", n_lo > n_base, f"count={n_lo} (base={n_base})") |
| 197 | + |
| 198 | + # --- tofu: frame present, app alive (recovery asserted by base-again) -- |
| 199 | + n_t, _, _ = S("tofu") |
| 200 | + check("tofu-rendered-frame", imgs["tofu"] is not None, f"count={n_t} (blank or boxes both ok)") |
| 201 | + |
| 202 | + # --- empty string: clean blank ---------------------------------------- |
| 203 | + n_e, _, _ = S("empty") |
| 204 | + check("empty-blank", imgs["empty"] is not None and n_e < 50, f"count={n_e}") |
| 205 | + |
| 206 | + # --- recovery + in-run determinism ------------------------------------ |
| 207 | + if imgs["base-again"] is not None and imgs["base"] is not None: |
| 208 | + d_max = float(np.abs(imgs["base-again"] - imgs["base"]).max()) |
| 209 | + n_a, _, _ = S("base-again") |
| 210 | + check("base-recovery", d_max <= 4 and n_a > 500, |
| 211 | + f"max_abs={d_max} count={n_a} (vs base={n_base})") |
| 212 | + else: |
| 213 | + check("base-recovery", False, "missing grabs") |
| 214 | + |
| 215 | + ok = not failures |
| 216 | + print(f"analyze: {checks - len(failures)}/{checks} ok" |
| 217 | + + ("" if ok else f" FAILURES: {', '.join(failures)}")) |
| 218 | + return 0 if ok else 1 |
| 219 | + |
| 220 | + |
| 221 | +if __name__ == "__main__": |
| 222 | + sys.exit(main()) |
0 commit comments