File size: 8,155 Bytes
c368438 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | """Compare two ink maps from the same input: a reference (e.g. CPU) and a candidate (e.g. MPS).
Acceptance follows the GENChase Apple GPU backend
(https://github.com/ChaseHendrick/GENChase/blob/main/apps/validate/APPLE-GPU.md):
agreement under a stated tolerance counts only if a deliberately wrong input, the
control, is caught by the same comparison. Without a control the verdict says so.
Default tolerance: villa PR #1865 reported CPU and MPS maps differing by at most 1 of
255 gray levels on 0.002% of pixels. The defaults (2 levels, at most 0.01% of pixels
beyond that) leave margin above that report without accepting a different map.
The maps are villa's uint8 TIFFs. Reading them needs numpy and tifffile (and
imagecodecs for LZW); villa's environment has all three.
"""
import math
DEFAULT_TOLERANCE = 2
DEFAULT_MAX_FRACTION = 1e-4
ROWS_PER_BLOCK = 1024
PASS = "pass"
PASS_UNCONTROLLED = "pass-uncontrolled"
FAIL = "fail"
CONTROL_NOT_CAUGHT = "control-not-caught"
# The extra that installs the map tools' dependencies (numpy, tifffile, imagecodecs, zarr, scipy; pyproject.toml).
MAPS_INSTALL = 'python -m pip install "scrolls-workbench[maps]"'
# The command running now, for install hints: (what the user typed, the kit command), such as
# ("python -m kit auc", "auc"). kit.cli sets it while a command runs; None when kit is used as a library.
RUNNING = None
class VerifyError(Exception):
pass
def missing(module):
"""The VerifyError for a map tool that cannot import `module`: the command that needs it, the maps
extra that installs it, and how to run the command inside villa's environment instead."""
typed, name = RUNNING if RUNNING else (None, None)
need = f"{module} is required for {typed}" if typed else f"{module} is required"
note = " (its zarr needs Python 3.11 or newer)" if module == "zarr" else ""
return VerifyError(f"{need}. Install the maps extra{note}: {MAPS_INSTALL}, or run inside villa's environment: "
f"uv run --project \"$VILLA/vesuvius\" --extra models python -m kit {name or 'COMMAND'} ...")
def _numpy():
try:
import numpy
except ImportError as exc:
raise missing("numpy") from exc
return numpy
def load_map(path):
np = _numpy()
try:
import tifffile
except ImportError as exc:
raise missing("tifffile") from exc
try:
array = tifffile.imread(str(path))
except Exception as exc: # tifffile raises several types, e.g. for LZW without imagecodecs
raise VerifyError(f"cannot read {path}: {exc}") from exc
array = np.squeeze(array)
if array.ndim != 2:
raise VerifyError(f"{path}: expected a 2D map, got shape {array.shape}")
return array
def compare(reference, candidate, tolerance=DEFAULT_TOLERANCE):
"""Statistics of candidate minus reference, computed in row blocks to bound memory."""
np = _numpy()
if reference.shape != candidate.shape:
return {"shape_match": False, "reference_shape": list(reference.shape),
"candidate_shape": list(candidate.shape)}
n = reference.size
max_abs = 0.0
sum_abs = 0.0
over = 0
sx = sy = sxx = syy = sxy = 0.0
for start in range(0, reference.shape[0], ROWS_PER_BLOCK):
a = reference[start:start + ROWS_PER_BLOCK].astype(np.float64)
b = candidate[start:start + ROWS_PER_BLOCK].astype(np.float64)
d = np.abs(b - a)
# NaN or inf in one map and not the other is a disagreement: d is then NaN or inf, and
# max() and "d > tolerance" would both pass a NaN by. Equal values (inf and inf) or NaN in
# both maps at the same pixel agree.
same = (a == b) | (np.isnan(a) & np.isnan(b))
bad = ~(d <= tolerance) & ~same
d = np.where(same, 0.0, d)
if bad.any() and not np.isfinite(d[bad]).all():
max_abs = math.inf
max_abs = max(max_abs, float(d.max()))
sum_abs += float(d.sum())
over += int(bad.sum())
sx += float(a.sum())
sy += float(b.sum())
sxx += float((a * a).sum())
syy += float((b * b).sum())
sxy += float((a * b).sum())
cov = sxy - sx * sy / n
var_a = sxx - sx * sx / n
var_b = syy - sy * sy / n
pearson = cov / math.sqrt(var_a * var_b) if var_a > 0 and var_b > 0 else None
return {
"shape_match": True,
"shape": list(reference.shape),
"pixels": n,
"tolerance": tolerance,
"max_abs_diff": max_abs,
"mean_abs_diff": sum_abs / n,
"pixels_over_tolerance": over,
"fraction_over_tolerance": over / n,
"pearson": pearson,
}
def agrees(stats, max_fraction=DEFAULT_MAX_FRACTION):
return bool(stats.get("shape_match")) and stats["fraction_over_tolerance"] <= max_fraction
def verdict(candidate_agrees, control_agrees=None):
if not candidate_agrees:
return FAIL
if control_agrees is None:
return PASS_UNCONTROLLED
if control_agrees:
return CONTROL_NOT_CAUGHT
return PASS
def verify_arrays(reference, candidate, control=None, tolerance=DEFAULT_TOLERANCE,
max_fraction=DEFAULT_MAX_FRACTION):
np = _numpy()
if reference.dtype.kind == "f" and reference.size and not np.isfinite(reference).any():
# NaN at the same pixel counts as agreement, so an all-NaN candidate (both devices overflowing,
# say) would agree with an all-NaN reference and any finite control would look caught
raise VerifyError("the reference map has no finite values; a broken reference cannot show agreement")
result = {"candidate": compare(reference, candidate, tolerance), "max_fraction": max_fraction}
result["candidate_agrees"] = agrees(result["candidate"], max_fraction)
control_agrees = None
if control is not None:
if control.shape != reference.shape:
# A control of another shape would "disagree" by shape alone and turn any candidate
# into a pass; it shows nothing about whether the comparison can fail.
raise VerifyError(f"control shape {tuple(control.shape)} differs from the reference's "
f"{tuple(reference.shape)}; the control must be the same map made wrongly")
result["control"] = compare(reference, control, tolerance)
control_agrees = agrees(result["control"], max_fraction)
result["control_agrees"] = control_agrees
result["verdict"] = verdict(result["candidate_agrees"], control_agrees)
return result
def verify_files(reference, candidate, control=None, tolerance=DEFAULT_TOLERANCE,
max_fraction=DEFAULT_MAX_FRACTION):
result = verify_arrays(
load_map(reference), load_map(candidate),
None if control is None else load_map(control),
tolerance, max_fraction,
)
result["files"] = {"reference": str(reference), "candidate": str(candidate),
"control": None if control is None else str(control)}
return result
EXPLAIN = {
PASS: "candidate agrees with the reference, and the control was caught",
PASS_UNCONTROLLED: "candidate agrees, but no control was given, so this comparison was not shown able to fail",
FAIL: "candidate does not agree with the reference",
CONTROL_NOT_CAUGHT: "the control also agreed, so this comparison cannot tell maps apart; it proves nothing",
}
def format_result(result):
def line(label, stats):
if not stats.get("shape_match"):
return f"{label}: shape mismatch {stats['reference_shape']} vs {stats['candidate_shape']}"
pearson = "n/a" if stats["pearson"] is None else f"{stats['pearson']:.6f}"
return (f"{label}: max |diff| {stats['max_abs_diff']:.0f}, mean {stats['mean_abs_diff']:.4f}, "
f"{stats['fraction_over_tolerance']:.6%} of pixels over {stats['tolerance']}, pearson {pearson}")
rows = [line("candidate", result["candidate"])]
if "control" in result:
rows.append(line("control ", result["control"]))
rows.append(f"verdict: {result['verdict']} ({EXPLAIN[result['verdict']]})")
return "\n".join(rows)
|