|
1 |
+ |
#!/usr/bin/env python3 |
|
2 |
+ |
import argparse |
|
3 |
+ |
import json |
|
4 |
+ |
import os |
|
5 |
+ |
import struct |
|
6 |
+ |
import subprocess |
|
7 |
+ |
import sys |
|
8 |
+ |
import tempfile |
|
9 |
+ |
|
|
10 |
+ |
|
|
11 |
+ |
PALETTE = { |
|
12 |
+ |
"background": [16, 16, 24, 255], |
|
13 |
+ |
"foreground": [240, 48, 96, 255], |
|
14 |
+ |
"compute_to_draw": [48, 224, 128, 255], |
|
15 |
+ |
"offscreen_sampled": [64, 128, 240, 255], |
|
16 |
+ |
"mrt": [240, 64, 64, 255], |
|
17 |
+ |
"indirect_instance": [64, 240, 64, 255], |
|
18 |
+ |
"offscreen": [64, 64, 240, 255], |
|
19 |
+ |
} |
|
20 |
+ |
|
|
21 |
+ |
|
|
22 |
+ |
def _read_token(f): |
|
23 |
+ |
token = bytearray() |
|
24 |
+ |
while True: |
|
25 |
+ |
c = f.read(1) |
|
26 |
+ |
if not c: |
|
27 |
+ |
return None |
|
28 |
+ |
if c == b"#": |
|
29 |
+ |
f.readline() |
|
30 |
+ |
continue |
|
31 |
+ |
if not c.isspace(): |
|
32 |
+ |
token.extend(c) |
|
33 |
+ |
break |
|
34 |
+ |
while True: |
|
35 |
+ |
c = f.read(1) |
|
36 |
+ |
if not c or c.isspace(): |
|
37 |
+ |
break |
|
38 |
+ |
token.extend(c) |
|
39 |
+ |
return token.decode("ascii") |
|
40 |
+ |
|
|
41 |
+ |
|
|
42 |
+ |
def load_ppm(path): |
|
43 |
+ |
with open(path, "rb") as f: |
|
44 |
+ |
magic = _read_token(f) |
|
45 |
+ |
if magic != "P6": |
|
46 |
+ |
raise ValueError("unsupported PPM magic") |
|
47 |
+ |
width = int(_read_token(f)) |
|
48 |
+ |
height = int(_read_token(f)) |
|
49 |
+ |
max_value = int(_read_token(f)) |
|
50 |
+ |
if max_value != 255: |
|
51 |
+ |
raise ValueError("unsupported PPM max value") |
|
52 |
+ |
data = f.read(width * height * 3) |
|
53 |
+ |
if len(data) != width * height * 3: |
|
54 |
+ |
raise ValueError("truncated PPM image") |
|
55 |
+ |
rgba = bytearray(width * height * 4) |
|
56 |
+ |
for i in range(width * height): |
|
57 |
+ |
rgba[i * 4:i * 4 + 3] = data[i * 3:i * 3 + 3] |
|
58 |
+ |
rgba[i * 4 + 3] = 255 |
|
59 |
+ |
return width, height, bytes(rgba) |
|
60 |
+ |
|
|
61 |
+ |
|
|
62 |
+ |
def load_with_pillow(path): |
|
63 |
+ |
from PIL import Image |
|
64 |
+ |
img = Image.open(path).convert("RGBA") |
|
65 |
+ |
return img.width, img.height, img.tobytes() |
|
66 |
+ |
|
|
67 |
+ |
|
|
68 |
+ |
def load_with_magick(path): |
|
69 |
+ |
result = subprocess.run( |
|
70 |
+ |
["magick", path, "rgba:-"], |
|
71 |
+ |
stdout=subprocess.PIPE, |
|
72 |
+ |
stderr=subprocess.PIPE, |
|
73 |
+ |
) |
|
74 |
+ |
if result.returncode != 0: |
|
75 |
+ |
raise ValueError(result.stderr.decode("utf-8", "replace").strip() or "magick failed") |
|
76 |
+ |
size = subprocess.run( |
|
77 |
+ |
["magick", "identify", "-format", "%w %h", path], |
|
78 |
+ |
stdout=subprocess.PIPE, |
|
79 |
+ |
stderr=subprocess.PIPE, |
|
80 |
+ |
text=True, |
|
81 |
+ |
) |
|
82 |
+ |
if size.returncode != 0: |
|
83 |
+ |
raise ValueError(size.stderr.strip() or "magick identify failed") |
|
84 |
+ |
width, height = [int(v) for v in size.stdout.split()] |
|
85 |
+ |
return width, height, result.stdout |
|
86 |
+ |
|
|
87 |
+ |
|
|
88 |
+ |
def load_image(path): |
|
89 |
+ |
lower = path.lower() |
|
90 |
+ |
if lower.endswith(".ppm"): |
|
91 |
+ |
return load_ppm(path) |
|
92 |
+ |
try: |
|
93 |
+ |
return load_with_pillow(path) |
|
94 |
+ |
except Exception: |
|
95 |
+ |
return load_with_magick(path) |
|
96 |
+ |
|
|
97 |
+ |
|
|
98 |
+ |
def color_for(value): |
|
99 |
+ |
if isinstance(value, str): |
|
100 |
+ |
if value not in PALETTE: |
|
101 |
+ |
raise ValueError("unknown palette color: " + value) |
|
102 |
+ |
return PALETTE[value] |
|
103 |
+ |
if isinstance(value, list) and len(value) in (3, 4): |
|
104 |
+ |
out = [int(v) for v in value] |
|
105 |
+ |
if len(out) == 3: |
|
106 |
+ |
out.append(255) |
|
107 |
+ |
return out |
|
108 |
+ |
raise ValueError("invalid expected color") |
|
109 |
+ |
|
|
110 |
+ |
|
|
111 |
+ |
def pixel_at(width, data, x, y): |
|
112 |
+ |
idx = (y * width + x) * 4 |
|
113 |
+ |
return list(data[idx:idx + 4]) |
|
114 |
+ |
|
|
115 |
+ |
|
|
116 |
+ |
def within(actual, expected, tolerance): |
|
117 |
+ |
return all(abs(int(a) - int(e)) <= tolerance for a, e in zip(actual, expected)) |
|
118 |
+ |
|
|
119 |
+ |
|
|
120 |
+ |
def evaluate_assertion(width, height, data, assertion): |
|
121 |
+ |
kind = assertion.get("kind") |
|
122 |
+ |
expected = color_for(assertion.get("expected")) |
|
123 |
+ |
tolerance = int(assertion.get("tolerance", 0)) |
|
124 |
+ |
result = { |
|
125 |
+ |
"kind": kind, |
|
126 |
+ |
"expected": expected, |
|
127 |
+ |
"tolerance": tolerance, |
|
128 |
+ |
"pass": False, |
|
129 |
+ |
} |
|
130 |
+ |
if kind == "point": |
|
131 |
+ |
x = int(assertion["x"]) |
|
132 |
+ |
y = int(assertion["y"]) |
|
133 |
+ |
result["x"] = x |
|
134 |
+ |
result["y"] = y |
|
135 |
+ |
if x < 0 or y < 0 or x >= width or y >= height: |
|
136 |
+ |
result["reason"] = "point outside image" |
|
137 |
+ |
return result |
|
138 |
+ |
actual = pixel_at(width, data, x, y) |
|
139 |
+ |
result["actual"] = actual |
|
140 |
+ |
result["pass"] = within(actual, expected, tolerance) |
|
141 |
+ |
if not result["pass"]: |
|
142 |
+ |
result["reason"] = "point color mismatch" |
|
143 |
+ |
return result |
|
144 |
+ |
if kind == "rect": |
|
145 |
+ |
x = int(assertion["x"]) |
|
146 |
+ |
y = int(assertion["y"]) |
|
147 |
+ |
w = int(assertion["w"]) |
|
148 |
+ |
h = int(assertion["h"]) |
|
149 |
+ |
result.update({"x": x, "y": y, "w": w, "h": h}) |
|
150 |
+ |
if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > width or y + h > height: |
|
151 |
+ |
result["reason"] = "rect outside image" |
|
152 |
+ |
return result |
|
153 |
+ |
mismatches = [] |
|
154 |
+ |
for yy in range(y, y + h): |
|
155 |
+ |
for xx in range(x, x + w): |
|
156 |
+ |
actual = pixel_at(width, data, xx, yy) |
|
157 |
+ |
if not within(actual, expected, tolerance): |
|
158 |
+ |
mismatches.append({"x": xx, "y": yy, "actual": actual}) |
|
159 |
+ |
if len(mismatches) >= 8: |
|
160 |
+ |
break |
|
161 |
+ |
if len(mismatches) >= 8: |
|
162 |
+ |
break |
|
163 |
+ |
result["mismatches"] = mismatches |
|
164 |
+ |
result["pass"] = len(mismatches) == 0 |
|
165 |
+ |
if not result["pass"]: |
|
166 |
+ |
result["reason"] = "rect color mismatch" |
|
167 |
+ |
return result |
|
168 |
+ |
result["reason"] = "unknown assertion kind" |
|
169 |
+ |
return result |
|
170 |
+ |
|
|
171 |
+ |
|
|
172 |
+ |
def evaluate(path, spec): |
|
173 |
+ |
if spec.get("source_kind") == "screenshot": |
|
174 |
+ |
return { |
|
175 |
+ |
"ok": False, |
|
176 |
+ |
"path": path, |
|
177 |
+ |
"source_kind": "screenshot", |
|
178 |
+ |
"error": "screenshots are smoke artifacts, not proof artifacts", |
|
179 |
+ |
} |
|
180 |
+ |
width, height, data = load_image(path) |
|
181 |
+ |
expected_width = spec.get("width") |
|
182 |
+ |
expected_height = spec.get("height") |
|
183 |
+ |
results = [] |
|
184 |
+ |
ok = True |
|
185 |
+ |
if expected_width is not None and int(expected_width) != width: |
|
186 |
+ |
ok = False |
|
187 |
+ |
results.append({"kind": "dimensions", "pass": False, "actual": [width, height], "expected": [expected_width, expected_height]}) |
|
188 |
+ |
if expected_height is not None and int(expected_height) != height: |
|
189 |
+ |
ok = False |
|
190 |
+ |
if not results or results[-1].get("kind") != "dimensions": |
|
191 |
+ |
results.append({"kind": "dimensions", "pass": False, "actual": [width, height], "expected": [expected_width, expected_height]}) |
|
192 |
+ |
for assertion in spec.get("assertions", []): |
|
193 |
+ |
item = evaluate_assertion(width, height, data, assertion) |
|
194 |
+ |
results.append(item) |
|
195 |
+ |
ok = ok and item["pass"] |
|
196 |
+ |
return { |
|
197 |
+ |
"ok": ok, |
|
198 |
+ |
"path": path, |
|
199 |
+ |
"source_kind": spec.get("source_kind", "readback"), |
|
200 |
+ |
"origin": spec.get("origin", "top-left"), |
|
201 |
+ |
"format": "RGBA8", |
|
202 |
+ |
"width": width, |
|
203 |
+ |
"height": height, |
|
204 |
+ |
"assertions": results, |
|
205 |
+ |
} |
|
206 |
+ |
|
|
207 |
+ |
|
|
208 |
+ |
def write_ppm(path, width, height, fill): |
|
209 |
+ |
rgb = bytes(fill[:3]) |
|
210 |
+ |
with open(path, "wb") as f: |
|
211 |
+ |
f.write(("P6\n%d %d\n255\n" % (width, height)).encode("ascii")) |
|
212 |
+ |
f.write(rgb * width * height) |
|
213 |
+ |
|
|
214 |
+ |
|
|
215 |
+ |
def patch_rect_ppm(path, width, x, y, w, h, color): |
|
216 |
+ |
with open(path, "r+b") as f: |
|
217 |
+ |
data = f.read() |
|
218 |
+ |
header_end = data.find(b"\n255\n") + 5 |
|
219 |
+ |
pixels = bytearray(data[header_end:]) |
|
220 |
+ |
for yy in range(y, y + h): |
|
221 |
+ |
for xx in range(x, x + w): |
|
222 |
+ |
idx = (yy * width + xx) * 3 |
|
223 |
+ |
pixels[idx:idx + 3] = bytes(color[:3]) |
|
224 |
+ |
f.seek(header_end) |
|
225 |
+ |
f.write(pixels) |
|
226 |
+ |
|
|
227 |
+ |
|
|
228 |
+ |
def selftest(): |
|
229 |
+ |
with tempfile.TemporaryDirectory() as tmp: |
|
230 |
+ |
on = os.path.join(tmp, "feature_on.ppm") |
|
231 |
+ |
off = os.path.join(tmp, "feature_off.ppm") |
|
232 |
+ |
write_ppm(on, 4, 4, PALETTE["background"]) |
|
233 |
+ |
write_ppm(off, 4, 4, PALETTE["background"]) |
|
234 |
+ |
patch_rect_ppm(on, 4, 1, 1, 2, 2, PALETTE["foreground"]) |
|
235 |
+ |
spec = { |
|
236 |
+ |
"source_kind": "fixture", |
|
237 |
+ |
"width": 4, |
|
238 |
+ |
"height": 4, |
|
239 |
+ |
"origin": "top-left", |
|
240 |
+ |
"assertions": [ |
|
241 |
+ |
{"kind": "point", "x": 1, "y": 1, "expected": "foreground"}, |
|
242 |
+ |
{"kind": "rect", "x": 0, "y": 0, "w": 1, "h": 4, "expected": "background"}, |
|
243 |
+ |
], |
|
244 |
+ |
} |
|
245 |
+ |
passed = evaluate(on, spec) |
|
246 |
+ |
failed = evaluate(off, spec) |
|
247 |
+ |
screenshot = evaluate(on, {"source_kind": "screenshot", "assertions": []}) |
|
248 |
+ |
assert passed["ok"], passed |
|
249 |
+ |
assert not failed["ok"], failed |
|
250 |
+ |
assert not screenshot["ok"], screenshot |
|
251 |
+ |
print("visual_assert selftest ok") |
|
252 |
+ |
return 0 |
|
253 |
+ |
|
|
254 |
+ |
|
|
255 |
+ |
def main(): |
|
256 |
+ |
parser = argparse.ArgumentParser() |
|
257 |
+ |
parser.add_argument("image", nargs="?") |
|
258 |
+ |
parser.add_argument("--spec", help="JSON assertion spec path") |
|
259 |
+ |
parser.add_argument("--json", dest="json_path") |
|
260 |
+ |
parser.add_argument("--selftest", action="store_true") |
|
261 |
+ |
args = parser.parse_args() |
|
262 |
+ |
if args.selftest: |
|
263 |
+ |
return selftest() |
|
264 |
+ |
if not args.image or not args.spec: |
|
265 |
+ |
parser.error("image and --spec are required unless --selftest is used") |
|
266 |
+ |
with open(args.spec, "r", encoding="utf-8") as f: |
|
267 |
+ |
spec = json.load(f) |
|
268 |
+ |
result = evaluate(args.image, spec) |
|
269 |
+ |
encoded = json.dumps(result, sort_keys=True) |
|
270 |
+ |
if args.json_path: |
|
271 |
+ |
with open(args.json_path, "w", encoding="utf-8") as f: |
|
272 |
+ |
f.write(encoded + "\n") |
|
273 |
+ |
else: |
|
274 |
+ |
print(encoded) |
|
275 |
+ |
return 0 if result.get("ok") else 1 |
|
276 |
+ |
|
|
277 |
+ |
|
|
278 |
+ |
if __name__ == "__main__": |
|
279 |
+ |
sys.exit(main()) |