CharlesCNorton commited on
Commit
e8b8483
·
0 Parent(s):

Image-level person classification on EUPE-ViT-B features with no free parameters

Browse files
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ *.egg-info/
5
+
6
+ # Synthesis build products; regenerate with `make synth`.
7
+ build/
Makefile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # make test run the backbone-free consistency suite
2
+ # make rtl regenerate rtl/ from rules.json
3
+ # make synth synthesize every rule with nosis
4
+ # make clean
5
+
6
+ PYTHON ?= python
7
+
8
+ test:
9
+ $(PYTHON) -m pytest -q
10
+
11
+ rtl:
12
+ $(PYTHON) rtl_gen.py
13
+
14
+ synth: rtl
15
+ $(PYTHON) synth.py
16
+
17
+ clean:
18
+ rm -rf build
19
+
20
+ .PHONY: test rtl synth clean
README.md ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: fair-research-license
4
+ license_link: https://huggingface.co/facebook/EUPE-ViT-B/blob/main/LICENSE
5
+ base_model: facebook/EUPE-ViT-B
6
+ tags:
7
+ - image-classification
8
+ - binary-classification
9
+ - minimal-models
10
+ - interpretability
11
+ - vision-transformer
12
+ - circuit-synthesis
13
+ library_name: pytorch
14
+ datasets:
15
+ - detection-datasets/coco
16
+ pipeline_tag: image-classification
17
+ ---
18
+
19
+ # Zero-Parameter Classifier
20
+
21
+ Image-level person classification on EUPE-ViT-B features. A 768 pixel image
22
+ gives 2304 patch tokens at the final layer; layernorm across the 768 channels
23
+ and max-pool across patches gives one 768-D vector. The decision compares two
24
+ sums of that vector. The boundary is zero.
25
+
26
+ ```python
27
+ patches = backbone(image)["x_norm_patchtokens"] # (2304, 768)
28
+ pooled = layernorm(patches, 768).max(dim=0) # (768,)
29
+ present = pooled[pos_dims].sum() > pooled[neg_dims].sum()
30
+ ```
31
+
32
+ At two dimensions:
33
+
34
+ ```
35
+ person present ⟺ feat[48] > feat[637]
36
+ ```
37
+
38
+ ```python
39
+ from infer import PersonDetector
40
+ det = PersonDetector.load('d6')
41
+ present = det.predict('image.jpg')
42
+ ```
43
+
44
+ ## Rules
45
+
46
+ Dimensions are selected on COCO train2017, 118,287 images, and scored on
47
+ val2017, 5000 images. The splits are disjoint.
48
+
49
+ | rule | dims | F1 | precision | recall | slices | LUT4 | CCU2C | ns |
50
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|
51
+ | `d2` | 2 | 0.8681 | 0.8685 | 0.8678 | 4 | 7 | 0 | 0.40 |
52
+ | `d4` | 4 | 0.8817 | 0.9069 | 0.8578 | 10 | 8 | 10 | 0.90 |
53
+ | `d6` | 6 | 0.8977 | 0.9461 | 0.8541 | 20 | 9 | 20 | 1.40 |
54
+ | `d8` | 8 | 0.9039 | 0.9475 | 0.8641 | 30 | 9 | 30 | 1.90 |
55
+ | `d12` | 12 | 0.9068 | 0.9500 | 0.8674 | 60 | 10 | 60 | 2.90 |
56
+ | `d16` | 16 | 0.9126 | 0.9568 | 0.8723 | 84 | 10 | 84 | 3.90 |
57
+ | `d20` | 20 | 0.9217 | 0.9587 | 0.8875 | 108 | 11 | 108 | 4.90 |
58
+ | `d40` | 40 | 0.9307 | 0.9698 | 0.8945 | 266 | 12 | 266 | 9.90 |
59
+
60
+ Train and validation F1 differ by 0.0006 at 40 dimensions and by at most 0.0078
61
+ across the set. `d6` is the default.
62
+
63
+ ## Dimensions
64
+
65
+ ```
66
+ d2 48 > 637
67
+ d4 48 + 71 > 637 + 90
68
+ d6 48 + 71 + 292 > 637 + 90 + 82
69
+ ```
70
+
71
+ Selection is greedy over the 192 dimensions with the largest class-mean
72
+ separation, alternating sides and adding whichever remaining dimension most
73
+ improves F1 at a zero boundary. Rules nest; `tests/test_rules.py` checks the
74
+ nesting.
75
+
76
+ Dimension 48 responds to people and to person-associated objects and is
77
+ suppressed on non-human animals and on non-anthropogenic structures.
78
+
79
+ ## Offset
80
+
81
+ A decision of the form `sum(pos) - sum(neg) > t` requires `t` because the two
82
+ sums carry a relative offset. Sets selected under a zero boundary carry none. A
83
+ 40-dimension set selected at `t = 25.28` scores F1 0.7410 on these images when
84
+ `t` is set to zero.
85
+
86
+ Dimension indices and signs are fixed structure. Each rule has no free
87
+ parameters and 2 to 40 fixed ones.
88
+
89
+ ## Circuit
90
+
91
+ `rtl_gen.py` emits one Verilog module per rule. `synth.py` synthesizes them with
92
+ [nosis](https://github.com/CharlesCNorton/nosis) for a Lattice ECP5 LFE5U-25F.
93
+ Counts are LUT4s, carry cells and slices on that device. Inputs are the selected
94
+ channels as signed INT8, post-layernorm and post-max-pool. Output is one bit,
95
+ combinational, with no multipliers, no memory and no constants.
96
+
97
+ `d2` contains no adder and is LUT-bound at 0.40 ns. Wider rules are carry-bound,
98
+ with area and delay linear in dimension count.
99
+
100
+ `tests/test_rtl.py` simulates each module against a Python reference under
101
+ Icarus Verilog, on uniform inputs and on inputs at the decision boundary.
102
+
103
+ ## Layout
104
+
105
+ ```
106
+ common/ pooled features, the comparison rule, metrics, named pools
107
+ cache.py pooled feature cache for a COCO split
108
+ choose.py dimension selection on train2017, writes rules.json
109
+ verify.py scoring on val2017, writes eval.json
110
+ rtl_gen.py Verilog generation from rules.json
111
+ synth.py nosis synthesis, writes synth.json
112
+ infer.py loader for every rule
113
+ rtl/ one module per rule, all generated
114
+ tests/ consistency suite, no backbone or dataset required
115
+ ```
116
+
117
+ Each measured JSON opens with a provenance block naming its generating script
118
+ and the pool it read. `tests/test_artifacts.py` enforces the pairing and that
119
+ selection and scoring name different splits.
120
+
121
+ ## Running
122
+
123
+ ```
124
+ pip install -e .
125
+ python cache.py --split train2017
126
+ python cache.py --split val2017
127
+ python choose.py
128
+ python verify.py
129
+ make synth
130
+ make test
131
+ ```
132
+
133
+ `COCO_ROOT` is the dataset root. `BACKBONE` is the backbone repo id or a local
134
+ path. `BACKBONE_SRC` supplies `argus.py` from a local directory; otherwise it is
135
+ fetched from the backbone repo. Caching the two splits is a backbone forward
136
+ over 123,287 images; every later step reads the cache.
137
+
138
+ bfloat16 kernels select reduction orders by batch size, so cached values depend
139
+ on `--batch`. A cache must be built at one batch size throughout.
140
+
141
+ ## Source backbone
142
+
143
+ EUPE-ViT-B from Meta FAIR ([arXiv:2603.22387](https://arxiv.org/abs/2603.22387),
144
+ Zhu et al., March 2026), distilled from PEcore-G + PElang-G + DINOv3-H+ via a
145
+ 1.9B proxy teacher. License: FAIR Research License, non-commercial. This
146
+ classifier is an artifact derived from that backbone's feature geometry.
cache.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cache the pooled 768-D vector for every image in a COCO split.
2
+
3
+ python cache.py --split train2017
4
+ python cache.py --split val2017
5
+
6
+ Selection and evaluation both read these, so the backbone is forwarded once per
7
+ split rather than once per experiment. Values are written straight through to a
8
+ memmap alongside a per-row status byte, and meta.json is written last, so an
9
+ interrupted run resumes with --resume instead of restarting.
10
+
11
+ bfloat16 kernels select reduction orders by batch size, so cached values depend
12
+ on --batch. A cache must be built at one batch size throughout.
13
+ """
14
+ import argparse
15
+ import json
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import torch
21
+ from PIL import Image
22
+
23
+ from common import COCO_ROOT, D, RES, coco_split, device, image_paths, normalize, pool
24
+ from common.artifacts import write_artifact
25
+ from common.models import load_backbone
26
+ from common.paths import BACKBONE
27
+
28
+ TODO, DONE, UNREADABLE = 0, 1, 2
29
+
30
+
31
+ class Images(torch.utils.data.Dataset):
32
+ """Normalized images for a list of (row, path); unreadable files yield None."""
33
+
34
+ def __init__(self, work):
35
+ self.work = work
36
+
37
+ def __len__(self):
38
+ return len(self.work)
39
+
40
+ def __getitem__(self, i):
41
+ row, path = self.work[i]
42
+ try:
43
+ return row, normalize(Image.open(path), RES, 'cpu')[0]
44
+ except Exception: # noqa: BLE001
45
+ # One undecodable image is not worth losing an hour of forwards.
46
+ return row, None
47
+
48
+
49
+ def collate(batch):
50
+ good = [(r, x) for r, x in batch if x is not None]
51
+ bad = [r for r, x in batch if x is None]
52
+ if not good:
53
+ return None, bad
54
+ rows, xs = zip(*good)
55
+ return (torch.tensor(rows), torch.stack(xs)), bad
56
+
57
+
58
+ def main():
59
+ ap = argparse.ArgumentParser(description=__doc__)
60
+ ap.add_argument('--split', default='val2017', choices=('train2017', 'val2017'))
61
+ ap.add_argument('--backbone', default=BACKBONE)
62
+ ap.add_argument('--batch', type=int, default=8)
63
+ ap.add_argument('--workers', type=int, default=6)
64
+ ap.add_argument('--resume', action='store_true')
65
+ ap.add_argument('--out', type=Path, default=None)
66
+ args = ap.parse_args()
67
+
68
+ out = args.out or COCO_ROOT / f'pooled_{args.split}'
69
+ out.mkdir(parents=True, exist_ok=True)
70
+ dev = device()
71
+
72
+ coco, id_to_file = coco_split(args.split)
73
+ img_ids = sorted(coco.getImgIds())
74
+ paths = image_paths(id_to_file, img_ids, args.split)
75
+ n = len(img_ids)
76
+
77
+ present = all((out / f).exists() for f in ('pooled.dat', 'status.dat'))
78
+ if args.resume and not present:
79
+ raise SystemExit(f'--resume asked for but {out} has no partial run')
80
+ mode = 'r+' if (args.resume and present) else 'w+'
81
+ pooled = np.memmap(out / 'pooled.dat', np.float16, mode, shape=(n, D))
82
+ status = np.memmap(out / 'status.dat', np.uint8, mode, shape=(n,))
83
+
84
+ work = [(r, p) for r, p in enumerate(paths) if status[r] == TODO]
85
+ print(f'[init] {args.split}: {n} images, {len(work)} to compute', flush=True)
86
+ backbone = load_backbone(args.backbone).to(dev).eval()
87
+
88
+ loader = torch.utils.data.DataLoader(
89
+ Images(work), batch_size=args.batch, shuffle=False,
90
+ num_workers=args.workers, collate_fn=collate)
91
+
92
+ t0, seen = time.time(), 0
93
+ with torch.inference_mode():
94
+ for good, bad in loader:
95
+ for r in bad:
96
+ status[r] = UNREADABLE
97
+ if good is None:
98
+ continue
99
+ idx, x = good
100
+ with torch.autocast(dev, dtype=torch.bfloat16):
101
+ tok = backbone.forward_features(x.to(dev))['x_norm_patchtokens']
102
+ rows = idx.numpy()
103
+ pooled[rows] = pool(tok.float()).half().cpu().numpy()
104
+ status[rows] = DONE
105
+ seen += len(rows)
106
+ if seen % (args.batch * 50) < args.batch:
107
+ pooled.flush()
108
+ status.flush()
109
+ rate = seen / max(time.time() - t0, 1e-6)
110
+ print(f' {seen}/{len(work)} {rate:.1f} img/s '
111
+ f'ETA {(len(work) - seen) / max(rate, 1e-6) / 60:.1f} min',
112
+ flush=True)
113
+
114
+ pooled.flush()
115
+ status.flush()
116
+ done = int((np.asarray(status) == DONE).sum())
117
+ np.save(out / 'pooled.npy', np.asarray(pooled))
118
+ (out / 'img_ids.json').write_text(json.dumps([int(i) for i in img_ids]))
119
+ write_artifact(out / 'meta.json', {
120
+ 'split': args.split,
121
+ 'n_images': done,
122
+ 'n_unreadable': int((np.asarray(status) == UNREADABLE).sum()),
123
+ 'resolution': RES,
124
+ 'files': {'pooled.npy': {'shape': [n, D], 'dtype': 'float16'}},
125
+ }, generator='cache.py', backbone=args.backbone, batch=args.batch,
126
+ protocol='live backbone forward at 768 px, layernorm over 768 channels '
127
+ 'per patch, max over patches')
128
+ print(f'[done] {done}/{n} -> {out} ({time.time() - t0:.0f}s)', flush=True)
129
+
130
+
131
+ if __name__ == '__main__':
132
+ main()
choose.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Choose the two dim sets on train2017 and write rules.json.
2
+
3
+ python choose.py
4
+
5
+ The decision is sum(feat[pos]) > sum(feat[neg]), evaluated at zero. A threshold
6
+ would absorb the offset between the two sums; choosing the sets so the offset is
7
+ already zero removes it instead, which is why the rule carries no free parameter.
8
+
9
+ Dims are added greedily, alternating sides, each step taking whichever remaining
10
+ dim most improves F1 with the boundary pinned at zero. Selection reads only
11
+ train2017. Nothing here touches val2017.
12
+ """
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ import torch
17
+
18
+ from common import COCO_ROOT, prf1, write_artifact
19
+ from common.cached import load_pooled
20
+ from common.pools import TRAIN2017
21
+
22
+ HERE = Path(__file__).resolve().parent
23
+
24
+
25
+ def greedy(X, y, k, candidates):
26
+ """Grow pos and neg sets to k each, scoring only at threshold zero."""
27
+ pos, neg = [], []
28
+ cur = torch.zeros(X.shape[0])
29
+ for step in range(2 * k):
30
+ side, sign = (pos, 1.0) if step % 2 == 0 else (neg, -1.0)
31
+ used = set(pos) | set(neg)
32
+ best_f1, best_d = -1.0, None
33
+ for d in candidates:
34
+ if d in used:
35
+ continue
36
+ f1 = prf1(cur + sign * X[:, d] > 0, y).f1
37
+ if f1 > best_f1:
38
+ best_f1, best_d = f1, d
39
+ side.append(int(best_d))
40
+ cur = cur + sign * X[:, best_d]
41
+ return pos, neg, prf1(cur > 0, y).f1
42
+
43
+
44
+ def main():
45
+ ap = argparse.ArgumentParser(description=__doc__)
46
+ ap.add_argument('--cache', type=Path, default=None)
47
+ ap.add_argument('--sizes', type=int, nargs='+',
48
+ default=[1, 2, 3, 4, 6, 8, 10, 20])
49
+ ap.add_argument('--candidates', type=int, default=192)
50
+ ap.add_argument('--out', type=Path, default=HERE / 'rules.json')
51
+ args = ap.parse_args()
52
+
53
+ cache = args.cache or COCO_ROOT / 'pooled_train2017'
54
+ X, y = load_pooled(cache, 'train2017')
55
+ print(f'[train] {X.shape[0]} images, person rate {y.float().mean():.3f}',
56
+ flush=True)
57
+
58
+ # Restrict the search to dims with real class separation, for tractability.
59
+ sep = (X[y].mean(0) - X[~y].mean(0)).abs()
60
+ candidates = torch.topk(sep, args.candidates).indices.tolist()
61
+
62
+ rules = {}
63
+ for k in args.sizes:
64
+ pos, neg, f1 = greedy(X, y, k, candidates)
65
+ rules[f'd{2 * k}'] = {'pos_dims': pos, 'neg_dims': neg,
66
+ 'n_dims': 2 * k, 'free_parameters': 0,
67
+ 'F1_train': round(f1, 4)}
68
+ print(f' d{2 * k:<3} F1 {f1:.4f} {pos} > {neg}', flush=True)
69
+
70
+ write_artifact(args.out, {
71
+ 'candidates': args.candidates,
72
+ 'sizes': [2 * k for k in args.sizes],
73
+ 'rules': rules,
74
+ }, generator='choose.py',
75
+ pool_info={'pool': TRAIN2017.name, 'split': TRAIN2017.split,
76
+ 'n_images': int(X.shape[0]),
77
+ 'positive_rate': round(y.float().mean().item(), 4),
78
+ 'selection': TRAIN2017.selection},
79
+ decision='sum(feat[pos_dims]) > sum(feat[neg_dims])',
80
+ search='greedy, alternating sides, boundary pinned at zero')
81
+ print(f'[done] wrote {args.out}', flush=True)
82
+
83
+
84
+ if __name__ == '__main__':
85
+ main()
common/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pooled features, the comparison rule, metrics, named pools, artifact contract."""
2
+ from .paths import BACKBONE, BACKBONE_SRC, COCO_ROOT, REPO, UPSTREAM_BACKBONE, device
3
+ from .data import (MEAN, STD, coco_split, image_paths, load_image, normalize,
4
+ person_labels)
5
+ from .features import D, RES, backbone_pooled, decide, pool, score
6
+ from .metrics import Metrics, f1_at, prf1
7
+ from .pools import POOLS, TRAIN2017, VAL5000, Pool, by_name
8
+ from .pool import LoadedPool, load_pool
9
+ from .artifacts import (REGISTRY, ArtifactSpec, provenance, read_artifact,
10
+ sha256_of, write_artifact)
11
+
12
+ __all__ = [
13
+ 'BACKBONE', 'BACKBONE_SRC', 'COCO_ROOT', 'REPO', 'UPSTREAM_BACKBONE', 'device',
14
+ 'MEAN', 'STD', 'coco_split', 'image_paths', 'load_image', 'normalize',
15
+ 'person_labels',
16
+ 'D', 'RES', 'backbone_pooled', 'decide', 'pool', 'score',
17
+ 'Metrics', 'f1_at', 'prf1',
18
+ 'POOLS', 'TRAIN2017', 'VAL5000', 'Pool', 'by_name',
19
+ 'LoadedPool', 'load_pool',
20
+ 'REGISTRY', 'ArtifactSpec', 'provenance', 'read_artifact', 'sha256_of',
21
+ 'write_artifact',
22
+ ]
common/artifacts.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Artifact provenance stamping and the generator/artifact registry."""
2
+ import hashlib
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional, Tuple
7
+
8
+ from .paths import REPO
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ArtifactSpec:
13
+ """Owning script, source pool, and required top-level payload keys."""
14
+
15
+ generator: Optional[str]
16
+ pool: Optional[str]
17
+ payload_keys: Tuple[str, ...]
18
+
19
+
20
+ REGISTRY = {
21
+ 'rules.json': ArtifactSpec(
22
+ 'choose.py', 'TRAIN2017', ('candidates', 'sizes', 'rules')),
23
+ 'eval.json': ArtifactSpec('verify.py', 'VAL5000', ('rules',)),
24
+ 'synth.json': ArtifactSpec(
25
+ 'synth.py', None, ('tool', 'target', 'variants')),
26
+ }
27
+
28
+
29
+ def sha256_of(path) -> str:
30
+ """Content hash of a file."""
31
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
32
+
33
+
34
+ def provenance(generator: str, classifier=None, pool_info: Optional[dict] = None,
35
+ **extra) -> dict:
36
+ """Assemble a provenance block from repository-recoverable fields only."""
37
+ block = {'generator': generator}
38
+ if classifier is not None:
39
+ path = Path(classifier)
40
+ block['classifier'] = str(path.resolve().relative_to(REPO)).replace('\\', '/')
41
+ block['classifier_sha256'] = sha256_of(path)
42
+ if pool_info:
43
+ block.update(pool_info)
44
+ block.update(extra)
45
+ return block
46
+
47
+
48
+ def write_artifact(path, payload: dict, *, generator: str, classifier=None,
49
+ pool_info: Optional[dict] = None, **extra):
50
+ """Write `payload` beneath a provenance block and return the document."""
51
+ doc = {'provenance': provenance(generator, classifier, pool_info, **extra)}
52
+ doc.update(payload)
53
+ Path(path).write_text(json.dumps(doc, indent=2) + '\n', encoding='utf-8')
54
+ return doc
55
+
56
+
57
+ def read_artifact(path) -> dict:
58
+ return json.loads(Path(path).read_text(encoding='utf-8'))
common/cached.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reading the pooled feature caches that cache.py writes."""
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Tuple
5
+
6
+ import numpy as np
7
+ import torch
8
+
9
+ from .data import coco_split, person_labels
10
+
11
+
12
+ def load_pooled(cache, split: str) -> Tuple[torch.Tensor, torch.Tensor]:
13
+ """(N, 768) pooled vectors and their person labels, in cache row order."""
14
+ cache = Path(cache)
15
+ pooled = np.load(cache / 'pooled.npy').astype(np.float32)
16
+ img_ids = json.loads((cache / 'img_ids.json').read_text())
17
+ if pooled.shape[0] != len(img_ids):
18
+ raise ValueError(f'{cache} has {pooled.shape[0]} rows and {len(img_ids)} ids')
19
+ coco, _ = coco_split(split)
20
+ return torch.from_numpy(pooled), person_labels(coco, img_ids, 'cpu')
common/data.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """COCO loading and input normalization.
2
+
3
+ Images are resized to a square `resolution` with bilinear interpolation and
4
+ normalized with ImageNet statistics, matching the protocol every stage was
5
+ measured under.
6
+ """
7
+ from pathlib import Path
8
+ from typing import Iterable, List, Sequence, Tuple, Union
9
+
10
+ import numpy as np
11
+ import torch
12
+ from PIL import Image
13
+
14
+ from .paths import COCO_ROOT
15
+
16
+ MEAN = (0.485, 0.456, 0.406)
17
+ STD = (0.229, 0.224, 0.225)
18
+ PERSON_CATEGORY_ID = 1
19
+
20
+
21
+ def _stats(device: str) -> Tuple[torch.Tensor, torch.Tensor]:
22
+ mean = torch.tensor(MEAN).view(1, 3, 1, 1).to(device)
23
+ std = torch.tensor(STD).view(1, 3, 1, 1).to(device)
24
+ return mean, std
25
+
26
+
27
+ def normalize(img: Image.Image, resolution: int, device: str) -> torch.Tensor:
28
+ """PIL image -> (1, 3, R, R) normalized float tensor."""
29
+ img = img.convert('RGB').resize((resolution, resolution), Image.BILINEAR)
30
+ arr = np.asarray(img, dtype=np.uint8).copy()
31
+ x = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to(device).float() / 255.0
32
+ mean, std = _stats(device)
33
+ return (x - mean) / std
34
+
35
+
36
+ def load_image(image: Union[str, Path, Image.Image, np.ndarray, torch.Tensor],
37
+ resolution: int, device: str) -> torch.Tensor:
38
+ """Accept a path, PIL image, HWC array, or CHW tensor; return a batch of 1."""
39
+ if isinstance(image, (str, Path)):
40
+ img = Image.open(image)
41
+ elif isinstance(image, Image.Image):
42
+ img = image
43
+ elif isinstance(image, np.ndarray):
44
+ img = Image.fromarray(image)
45
+ elif isinstance(image, torch.Tensor):
46
+ arr = image.cpu().numpy() if image.ndim == 3 else image[0].cpu().numpy()
47
+ if arr.shape[0] == 3:
48
+ arr = arr.transpose(1, 2, 0)
49
+ img = Image.fromarray((arr * 255).astype('uint8'))
50
+ else:
51
+ raise TypeError(f'unsupported image type: {type(image)}')
52
+ return normalize(img, resolution, device)
53
+
54
+
55
+ def coco_split(split: str = 'val2017'):
56
+ """Return (COCO handle, image-file lookup) for a COCO split."""
57
+ from pycocotools.coco import COCO
58
+ coco = COCO(str(COCO_ROOT / 'annotations' / f'instances_{split}.json'))
59
+ id_to_file = {i['id']: i['file_name'] for i in coco.loadImgs(coco.getImgIds())}
60
+ return coco, id_to_file
61
+
62
+
63
+ def person_labels(coco, img_ids: Sequence[int], device: str = 'cpu') -> torch.Tensor:
64
+ """Image-level person presence for each id, as a bool tensor."""
65
+ labels = [
66
+ any(a['category_id'] == PERSON_CATEGORY_ID
67
+ for a in coco.loadAnns(coco.getAnnIds(imgIds=i, iscrowd=False)))
68
+ for i in img_ids
69
+ ]
70
+ return torch.tensor(labels, dtype=torch.bool, device=device)
71
+
72
+
73
+ def image_paths(id_to_file: dict, img_ids: Iterable[int],
74
+ split: str = 'val2017') -> List[Path]:
75
+ """Absolute paths for a sequence of image ids within a split."""
76
+ root = COCO_ROOT / split
77
+ return [root / id_to_file[i] for i in img_ids]
common/features.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pooled feature extraction and the comparison rule.
2
+
3
+ One 768-D vector per image: layernorm across the 768 channels of every patch
4
+ token, then max-pool across patches. The decision compares two sums of that
5
+ vector against each other, so the boundary sits at zero and carries no offset.
6
+ """
7
+ from typing import Sequence
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+
12
+ D = 768
13
+ RES = 768
14
+
15
+
16
+ def pool(patch_tokens: torch.Tensor) -> torch.Tensor:
17
+ """(N, D) or (B, N, D) patch tokens -> (D,) or (B, D) pooled vector."""
18
+ ln = F.layer_norm(patch_tokens.float(), [D])
19
+ return ln.max(dim=-2).values
20
+
21
+
22
+ @torch.inference_mode()
23
+ def backbone_pooled(backbone, x: torch.Tensor, autocast: bool = True) -> torch.Tensor:
24
+ """Forward a normalized batch through the backbone and pool it."""
25
+ if autocast:
26
+ dev = 'cuda' if x.is_cuda else 'cpu'
27
+ with torch.autocast(dev, dtype=torch.bfloat16):
28
+ out = backbone.forward_features(x)
29
+ else:
30
+ out = backbone.forward_features(x)
31
+ return pool(out['x_norm_patchtokens'].float())
32
+
33
+
34
+ def _as_index(idx, like: torch.Tensor) -> torch.Tensor:
35
+ if torch.is_tensor(idx):
36
+ return idx
37
+ return torch.tensor(list(idx), dtype=torch.long, device=like.device)
38
+
39
+
40
+ def score(pooled: torch.Tensor, pos: Sequence[int], neg: Sequence[int]) -> torch.Tensor:
41
+ """sum(pooled[pos]) - sum(pooled[neg]), over the last axis."""
42
+ p, n = _as_index(pos, pooled), _as_index(neg, pooled)
43
+ return pooled.index_select(-1, p).sum(-1) - pooled.index_select(-1, n).sum(-1)
44
+
45
+
46
+ def decide(pooled: torch.Tensor, pos: Sequence[int], neg: Sequence[int]) -> torch.Tensor:
47
+ """sum(pooled[pos]) > sum(pooled[neg]). No threshold, no free parameter."""
48
+ return score(pooled, pos, neg) > 0
common/metrics.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Binary classification metrics, single-sourced so every stage scores identically."""
2
+ from typing import NamedTuple
3
+
4
+ import torch
5
+
6
+
7
+ class Metrics(NamedTuple):
8
+ """F1, precision, recall, and the threshold they were measured at."""
9
+
10
+ f1: float
11
+ precision: float
12
+ recall: float
13
+ threshold: float = float('nan')
14
+
15
+ def asdict(self) -> dict:
16
+ d = {'F1': self.f1, 'precision': self.precision, 'recall': self.recall}
17
+ if self.threshold == self.threshold: # excludes NaN
18
+ d['threshold'] = self.threshold
19
+ return d
20
+
21
+
22
+ def prf1(pred: torch.Tensor, labels: torch.Tensor) -> Metrics:
23
+ """Metrics for boolean prediction and label tensors."""
24
+ tp = (pred & labels).sum().float()
25
+ fp = (pred & ~labels).sum().float()
26
+ fn = (~pred & labels).sum().float()
27
+ precision = tp / (tp + fp).clamp(min=1)
28
+ recall = tp / (tp + fn).clamp(min=1)
29
+ f1 = 2 * precision * recall / (precision + recall).clamp(min=1e-9)
30
+ return Metrics(float(f1), float(precision), float(recall))
31
+
32
+
33
+ def f1_at(scores: torch.Tensor, labels: torch.Tensor, threshold: float) -> Metrics:
34
+ """Metrics at a fixed threshold."""
35
+ return prf1(scores > threshold, labels)._replace(threshold=float(threshold))
36
+
37
+
38
+ def f1_sweep(scores: torch.Tensor, labels: torch.Tensor, n_candidates: int = 500) -> Metrics:
39
+ """Best metrics over candidate thresholds drawn evenly from the sorted unique scores."""
40
+ uniq = torch.unique(scores).sort().values
41
+ stride = max(1, len(uniq) // n_candidates)
42
+ best = Metrics(0.0, 0.0, 0.0, 0.0)
43
+ for t in uniq.tolist()[::stride]:
44
+ m = prf1(scores > t, labels)
45
+ if m.f1 > best.f1:
46
+ best = m._replace(threshold=float(t))
47
+ return best
common/models.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backbone loading."""
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from .paths import BACKBONE, BACKBONE_SRC
7
+
8
+ _argus = None
9
+
10
+
11
+ def argus_module():
12
+ """Import argus.py from the environment, BACKBONE_SRC, or the backbone repo."""
13
+ global _argus
14
+ if _argus is not None:
15
+ return _argus
16
+ try:
17
+ import argus
18
+ except ImportError:
19
+ if BACKBONE_SRC:
20
+ sys.path.insert(0, str(BACKBONE_SRC))
21
+ else:
22
+ from huggingface_hub import hf_hub_download
23
+ sys.path.insert(0, str(Path(hf_hub_download(BACKBONE, 'argus.py')).parent))
24
+ import argus
25
+ _argus = argus
26
+ return argus
27
+
28
+
29
+ def load_backbone(repo: Optional[str] = None):
30
+ """Load the stock backbone in eval mode."""
31
+ from transformers import AutoModel
32
+ return AutoModel.from_pretrained(repo or BACKBONE, trust_remote_code=True).eval().backbone
common/paths.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Filesystem and repository locations, overridable by environment.
2
+
3
+ BACKBONE HF repo id or local path for the backbone wrapper (alias: ARGUS_PATH)
4
+ BACKBONE_SRC local directory supplying argus.py (alias: ARGUS_SRC)
5
+ COCO_ROOT dataset root holding annotations/, train2017/, val2017/
6
+ DEVICE torch device string
7
+ """
8
+ import os
9
+ from pathlib import Path
10
+
11
+ REPO = Path(__file__).resolve().parent.parent
12
+
13
+ UPSTREAM_BACKBONE = 'facebook/EUPE-ViT-B'
14
+ BACKBONE = os.environ.get('BACKBONE') or os.environ.get('ARGUS_PATH') or 'phanerozoic/argus'
15
+ BACKBONE_SRC = os.environ.get('BACKBONE_SRC') or os.environ.get('ARGUS_SRC') or None
16
+ COCO_ROOT = Path(os.environ.get('COCO_ROOT', '/home/zootest/datasets/coco'))
17
+
18
+ ARGUS = BACKBONE
19
+
20
+
21
+ def device() -> str:
22
+ if 'DEVICE' in os.environ:
23
+ return os.environ['DEVICE']
24
+ import torch
25
+ return 'cuda' if torch.cuda.is_available() else 'cpu'
common/pool.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation-pool loading."""
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Iterator, List, Optional
5
+
6
+ import torch
7
+ from PIL import Image
8
+
9
+ from .data import coco_split, image_paths, normalize, person_labels
10
+ from .features import RES
11
+ from .pools import Pool
12
+
13
+
14
+ @dataclass
15
+ class LoadedPool:
16
+ """Image ids, paths and labels for one named pool, images optionally resident."""
17
+
18
+ pool: Pool
19
+ img_ids: List[int]
20
+ paths: List[Path]
21
+ labels: torch.Tensor
22
+ device: str
23
+ images: Optional[List[torch.Tensor]] = None
24
+
25
+ def __len__(self) -> int:
26
+ return len(self.img_ids)
27
+
28
+ def __iter__(self) -> Iterator[torch.Tensor]:
29
+ """Yield each image as a normalized (1, 3, RES, RES) tensor."""
30
+ if self.images is not None:
31
+ yield from self.images
32
+ return
33
+ for path in self.paths:
34
+ yield normalize(Image.open(path), RES, self.device)
35
+
36
+ @property
37
+ def positive_rate(self) -> float:
38
+ return round(self.labels.float().mean().item(), 4)
39
+
40
+ def provenance(self) -> dict:
41
+ """Pool fields recorded in an artifact's provenance block."""
42
+ return {'pool': self.pool.name, 'split': self.pool.split,
43
+ 'n_images': len(self), 'positive_rate': self.positive_rate,
44
+ 'selection': self.pool.selection}
45
+
46
+
47
+ def balanced_indices(labels: torch.Tensor, seed: int = 0) -> torch.Tensor:
48
+ """Indices subsampling `labels` to equal positive and negative counts, seeded."""
49
+ generator = torch.Generator(device='cpu').manual_seed(seed)
50
+ cpu = labels.cpu()
51
+ pos = cpu.nonzero(as_tuple=True)[0]
52
+ neg = (~cpu).nonzero(as_tuple=True)[0]
53
+ n = min(len(pos), len(neg))
54
+ sel = torch.cat([pos[torch.randperm(len(pos), generator=generator)[:n]],
55
+ neg[torch.randperm(len(neg), generator=generator)[:n]]])
56
+ return sel[torch.randperm(len(sel), generator=generator)]
57
+
58
+
59
+ def load_pool(pool: Pool, device: str, preload: bool = False, seed: int = 0) -> LoadedPool:
60
+ """Resolve a named pool to ids, paths and labels; `preload` holds images in memory."""
61
+ coco, id_to_file = coco_split(pool.split)
62
+ img_ids = sorted(coco.getImgIds())
63
+ if pool.n is not None:
64
+ img_ids = img_ids[:pool.n]
65
+ labels = person_labels(coco, img_ids, device)
66
+ if pool.balanced:
67
+ sel = balanced_indices(labels, seed)
68
+ img_ids = [img_ids[i] for i in sel.tolist()]
69
+ labels = labels[sel.to(labels.device)]
70
+ paths = image_paths(id_to_file, img_ids, pool.split)
71
+ images = [normalize(Image.open(p), RES, device) for p in paths] if preload else None
72
+ return LoadedPool(pool, img_ids, paths, labels, device, images)
common/pools.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Named pools, cited by name in every artifact's provenance block.
2
+
3
+ Dims are selected on TRAIN2017 and reported on VAL5000. The two never overlap,
4
+ so no figure is measured on data that chose it.
5
+ """
6
+ from typing import NamedTuple, Optional
7
+
8
+
9
+ class Pool(NamedTuple):
10
+ name: str
11
+ split: str
12
+ n: Optional[int]
13
+ selection: str
14
+
15
+
16
+ TRAIN2017 = Pool(
17
+ 'TRAIN2017', 'train2017', None,
18
+ 'all 118287 train2017 images, used only to select dims')
19
+
20
+ VAL5000 = Pool(
21
+ 'VAL5000', 'val2017', None,
22
+ 'all 5000 val2017 images, used only to report')
23
+
24
+ POOLS = {p.name: p for p in (TRAIN2017, VAL5000)}
25
+
26
+
27
+ def by_name(name: str) -> Pool:
28
+ if name not in POOLS:
29
+ raise ValueError(f'unknown pool {name!r}; expected one of {sorted(POOLS)}')
30
+ return POOLS[name]
eval.json ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "provenance": {
3
+ "generator": "verify.py",
4
+ "pool": "VAL5000",
5
+ "split": "val2017",
6
+ "n_images": 5000,
7
+ "positive_rate": 0.5386,
8
+ "selection": "all 5000 val2017 images, used only to report",
9
+ "task": "image-level person presence (binary)",
10
+ "protocol": "dims selected on train2017, not refit here"
11
+ },
12
+ "rules": {
13
+ "d2": {
14
+ "n_dims": 2,
15
+ "free_parameters": 0,
16
+ "pos_dims": [
17
+ 48
18
+ ],
19
+ "neg_dims": [
20
+ 637
21
+ ],
22
+ "F1_train": 0.8734,
23
+ "F1": 0.8681,
24
+ "precision": 0.8685,
25
+ "recall": 0.8678
26
+ },
27
+ "d4": {
28
+ "n_dims": 4,
29
+ "free_parameters": 0,
30
+ "pos_dims": [
31
+ 48,
32
+ 71
33
+ ],
34
+ "neg_dims": [
35
+ 637,
36
+ 90
37
+ ],
38
+ "F1_train": 0.8874,
39
+ "F1": 0.8817,
40
+ "precision": 0.9069,
41
+ "recall": 0.8578
42
+ },
43
+ "d6": {
44
+ "n_dims": 6,
45
+ "free_parameters": 0,
46
+ "pos_dims": [
47
+ 48,
48
+ 71,
49
+ 292
50
+ ],
51
+ "neg_dims": [
52
+ 637,
53
+ 90,
54
+ 82
55
+ ],
56
+ "F1_train": 0.9055,
57
+ "F1": 0.8977,
58
+ "precision": 0.9461,
59
+ "recall": 0.8541
60
+ },
61
+ "d8": {
62
+ "n_dims": 8,
63
+ "free_parameters": 0,
64
+ "pos_dims": [
65
+ 48,
66
+ 71,
67
+ 292,
68
+ 111
69
+ ],
70
+ "neg_dims": [
71
+ 637,
72
+ 90,
73
+ 82,
74
+ 269
75
+ ],
76
+ "F1_train": 0.9104,
77
+ "F1": 0.9039,
78
+ "precision": 0.9475,
79
+ "recall": 0.8641
80
+ },
81
+ "d12": {
82
+ "n_dims": 12,
83
+ "free_parameters": 0,
84
+ "pos_dims": [
85
+ 48,
86
+ 71,
87
+ 292,
88
+ 111,
89
+ 724,
90
+ 406
91
+ ],
92
+ "neg_dims": [
93
+ 637,
94
+ 90,
95
+ 82,
96
+ 269,
97
+ 565,
98
+ 140
99
+ ],
100
+ "F1_train": 0.9133,
101
+ "F1": 0.9068,
102
+ "precision": 0.95,
103
+ "recall": 0.8674
104
+ },
105
+ "d16": {
106
+ "n_dims": 16,
107
+ "free_parameters": 0,
108
+ "pos_dims": [
109
+ 48,
110
+ 71,
111
+ 292,
112
+ 111,
113
+ 724,
114
+ 406,
115
+ 510,
116
+ 665
117
+ ],
118
+ "neg_dims": [
119
+ 637,
120
+ 90,
121
+ 82,
122
+ 269,
123
+ 565,
124
+ 140,
125
+ 537,
126
+ 49
127
+ ],
128
+ "F1_train": 0.917,
129
+ "F1": 0.9126,
130
+ "precision": 0.9568,
131
+ "recall": 0.8723
132
+ },
133
+ "d20": {
134
+ "n_dims": 20,
135
+ "free_parameters": 0,
136
+ "pos_dims": [
137
+ 48,
138
+ 71,
139
+ 292,
140
+ 111,
141
+ 724,
142
+ 406,
143
+ 510,
144
+ 665,
145
+ 318,
146
+ 267
147
+ ],
148
+ "neg_dims": [
149
+ 637,
150
+ 90,
151
+ 82,
152
+ 269,
153
+ 565,
154
+ 140,
155
+ 537,
156
+ 49,
157
+ 283,
158
+ 114
159
+ ],
160
+ "F1_train": 0.9213,
161
+ "F1": 0.9217,
162
+ "precision": 0.9587,
163
+ "recall": 0.8875
164
+ },
165
+ "d40": {
166
+ "n_dims": 40,
167
+ "free_parameters": 0,
168
+ "pos_dims": [
169
+ 48,
170
+ 71,
171
+ 292,
172
+ 111,
173
+ 724,
174
+ 406,
175
+ 510,
176
+ 665,
177
+ 318,
178
+ 267,
179
+ 552,
180
+ 323,
181
+ 382,
182
+ 305,
183
+ 127,
184
+ 293,
185
+ 85,
186
+ 721,
187
+ 207,
188
+ 326
189
+ ],
190
+ "neg_dims": [
191
+ 637,
192
+ 90,
193
+ 82,
194
+ 269,
195
+ 565,
196
+ 140,
197
+ 537,
198
+ 49,
199
+ 283,
200
+ 114,
201
+ 310,
202
+ 113,
203
+ 494,
204
+ 571,
205
+ 694,
206
+ 92,
207
+ 518,
208
+ 91,
209
+ 307,
210
+ 437
211
+ ],
212
+ "F1_train": 0.9301,
213
+ "F1": 0.9307,
214
+ "precision": 0.9698,
215
+ "recall": 0.8945
216
+ }
217
+ }
218
+ }
infer.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score images for person presence.
2
+
3
+ from infer import PersonDetector
4
+ det = PersonDetector.load('d6')
5
+ present = det.predict('image.jpg')
6
+
7
+ `predict` returns a bool. `margin` returns the signed difference between the two
8
+ sums, which is positive exactly when the answer is yes; it carries no threshold.
9
+ """
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import torch
15
+
16
+ from common import BACKBONE, RES, backbone_pooled, load_image, read_artifact, score
17
+ from common.models import load_backbone
18
+
19
+ HERE = Path(__file__).resolve().parent
20
+
21
+
22
+ class PersonDetector:
23
+ def __init__(self, forward_fn, pos_dims, neg_dims, dev):
24
+ self._forward = forward_fn
25
+ self._dev = dev
26
+ self._pos = torch.tensor(pos_dims, dtype=torch.long, device=dev)
27
+ self._neg = torch.tensor(neg_dims, dtype=torch.long, device=dev)
28
+
29
+ @property
30
+ def dims(self):
31
+ return self._pos.tolist(), self._neg.tolist()
32
+
33
+ @torch.inference_mode()
34
+ def margin(self, image) -> float:
35
+ pooled = self._forward(load_image(image, RES, self._dev))
36
+ return float(score(pooled, self._pos, self._neg))
37
+
38
+ def predict(self, image) -> bool:
39
+ return self.margin(image) > 0.0
40
+
41
+ @classmethod
42
+ def load(cls, rule: str = None, backbone_repo: str = BACKBONE, root=None):
43
+ from common import device
44
+ root = Path(root) if root else HERE
45
+ doc = read_artifact(root / 'rules.json')
46
+ rules = doc['rules']
47
+ rule = rule or 'd6'
48
+ if rule not in rules:
49
+ raise ValueError(f'unknown rule {rule!r}; expected one of {sorted(rules)}')
50
+ r = rules[rule]
51
+ dev = device()
52
+ backbone = load_backbone(backbone_repo).to(dev).eval()
53
+ return cls(lambda x: backbone_pooled(backbone, x)[0],
54
+ r['pos_dims'], r['neg_dims'], dev)
55
+
56
+
57
+ if __name__ == '__main__':
58
+ ap = argparse.ArgumentParser(description=__doc__)
59
+ ap.add_argument('rule')
60
+ ap.add_argument('images', nargs='+')
61
+ args = ap.parse_args()
62
+ det = PersonDetector.load(args.rule)
63
+ pos, neg = det.dims
64
+ print(f'rule {args.rule}: sum{pos} > sum{neg}')
65
+ for path in args.images:
66
+ m = det.margin(path)
67
+ print(f'{path} margin={m:+.3f} person={m > 0}')
pyproject.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zero-parameter-classifier"
7
+ version = "0.0.0"
8
+ description = "Image-level person classification on EUPE-ViT-B features with no free parameters"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "torch>=2.0",
12
+ "numpy",
13
+ "pillow",
14
+ "transformers>=4.40",
15
+ "huggingface-hub",
16
+ "pycocotools",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=7"]
21
+ synth = ["nosis"]
22
+
23
+ [tool.setuptools]
24
+ packages = ["common"]
25
+ py-modules = ["infer", "cache", "choose", "verify", "rtl_gen", "synth"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
rtl/person_d12.v ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 12 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d12 (
8
+ input signed [7:0] f48, f71, f292, f111, f724, f406, f637, f90,
9
+ input signed [7:0] f82, f269, f565, f140,
10
+ output person_present
11
+ );
12
+ wire signed [10:0] pos_sum =
13
+ {{3{f48[7]}}, f48} + {{3{f71[7]}}, f71} + {{3{f292[7]}}, f292} + {{3{f111[7]}}, f111} +
14
+ {{3{f724[7]}}, f724} + {{3{f406[7]}}, f406};
15
+
16
+ wire signed [10:0] neg_sum =
17
+ {{3{f637[7]}}, f637} + {{3{f90[7]}}, f90} + {{3{f82[7]}}, f82} + {{3{f269[7]}}, f269} +
18
+ {{3{f565[7]}}, f565} + {{3{f140[7]}}, f140};
19
+
20
+ assign person_present = pos_sum > neg_sum;
21
+ endmodule
rtl/person_d16.v ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 16 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d16 (
8
+ input signed [7:0] f48, f71, f292, f111, f724, f406, f510, f665,
9
+ input signed [7:0] f637, f90, f82, f269, f565, f140, f537, f49,
10
+ output person_present
11
+ );
12
+ wire signed [10:0] pos_sum =
13
+ {{3{f48[7]}}, f48} + {{3{f71[7]}}, f71} + {{3{f292[7]}}, f292} + {{3{f111[7]}}, f111} +
14
+ {{3{f724[7]}}, f724} + {{3{f406[7]}}, f406} + {{3{f510[7]}}, f510} + {{3{f665[7]}}, f665};
15
+
16
+ wire signed [10:0] neg_sum =
17
+ {{3{f637[7]}}, f637} + {{3{f90[7]}}, f90} + {{3{f82[7]}}, f82} + {{3{f269[7]}}, f269} +
18
+ {{3{f565[7]}}, f565} + {{3{f140[7]}}, f140} + {{3{f537[7]}}, f537} + {{3{f49[7]}}, f49};
19
+
20
+ assign person_present = pos_sum > neg_sum;
21
+ endmodule
rtl/person_d2.v ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 2 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d2 (
8
+ input signed [7:0] f48, f637,
9
+ output person_present
10
+ );
11
+ assign person_present = f48 > f637;
12
+ endmodule
rtl/person_d20.v ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 20 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d20 (
8
+ input signed [7:0] f48, f71, f292, f111, f724, f406, f510, f665,
9
+ input signed [7:0] f318, f267, f637, f90, f82, f269, f565, f140,
10
+ input signed [7:0] f537, f49, f283, f114,
11
+ output person_present
12
+ );
13
+ wire signed [11:0] pos_sum =
14
+ {{4{f48[7]}}, f48} + {{4{f71[7]}}, f71} + {{4{f292[7]}}, f292} + {{4{f111[7]}}, f111} +
15
+ {{4{f724[7]}}, f724} + {{4{f406[7]}}, f406} + {{4{f510[7]}}, f510} + {{4{f665[7]}}, f665} +
16
+ {{4{f318[7]}}, f318} + {{4{f267[7]}}, f267};
17
+
18
+ wire signed [11:0] neg_sum =
19
+ {{4{f637[7]}}, f637} + {{4{f90[7]}}, f90} + {{4{f82[7]}}, f82} + {{4{f269[7]}}, f269} +
20
+ {{4{f565[7]}}, f565} + {{4{f140[7]}}, f140} + {{4{f537[7]}}, f537} + {{4{f49[7]}}, f49} +
21
+ {{4{f283[7]}}, f283} + {{4{f114[7]}}, f114};
22
+
23
+ assign person_present = pos_sum > neg_sum;
24
+ endmodule
rtl/person_d4.v ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 4 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d4 (
8
+ input signed [7:0] f48, f71, f637, f90,
9
+ output person_present
10
+ );
11
+ wire signed [8:0] pos_sum =
12
+ {{1{f48[7]}}, f48} + {{1{f71[7]}}, f71};
13
+
14
+ wire signed [8:0] neg_sum =
15
+ {{1{f637[7]}}, f637} + {{1{f90[7]}}, f90};
16
+
17
+ assign person_present = pos_sum > neg_sum;
18
+ endmodule
rtl/person_d40.v ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 40 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d40 (
8
+ input signed [7:0] f48, f71, f292, f111, f724, f406, f510, f665,
9
+ input signed [7:0] f318, f267, f552, f323, f382, f305, f127, f293,
10
+ input signed [7:0] f85, f721, f207, f326, f637, f90, f82, f269,
11
+ input signed [7:0] f565, f140, f537, f49, f283, f114, f310, f113,
12
+ input signed [7:0] f494, f571, f694, f92, f518, f91, f307, f437,
13
+ output person_present
14
+ );
15
+ wire signed [12:0] pos_sum =
16
+ {{5{f48[7]}}, f48} + {{5{f71[7]}}, f71} + {{5{f292[7]}}, f292} + {{5{f111[7]}}, f111} +
17
+ {{5{f724[7]}}, f724} + {{5{f406[7]}}, f406} + {{5{f510[7]}}, f510} + {{5{f665[7]}}, f665} +
18
+ {{5{f318[7]}}, f318} + {{5{f267[7]}}, f267} + {{5{f552[7]}}, f552} + {{5{f323[7]}}, f323} +
19
+ {{5{f382[7]}}, f382} + {{5{f305[7]}}, f305} + {{5{f127[7]}}, f127} + {{5{f293[7]}}, f293} +
20
+ {{5{f85[7]}}, f85} + {{5{f721[7]}}, f721} + {{5{f207[7]}}, f207} + {{5{f326[7]}}, f326};
21
+
22
+ wire signed [12:0] neg_sum =
23
+ {{5{f637[7]}}, f637} + {{5{f90[7]}}, f90} + {{5{f82[7]}}, f82} + {{5{f269[7]}}, f269} +
24
+ {{5{f565[7]}}, f565} + {{5{f140[7]}}, f140} + {{5{f537[7]}}, f537} + {{5{f49[7]}}, f49} +
25
+ {{5{f283[7]}}, f283} + {{5{f114[7]}}, f114} + {{5{f310[7]}}, f310} + {{5{f113[7]}}, f113} +
26
+ {{5{f494[7]}}, f494} + {{5{f571[7]}}, f571} + {{5{f694[7]}}, f694} + {{5{f92[7]}}, f92} +
27
+ {{5{f518[7]}}, f518} + {{5{f91[7]}}, f91} + {{5{f307[7]}}, f307} + {{5{f437[7]}}, f437};
28
+
29
+ assign person_present = pos_sum > neg_sum;
30
+ endmodule
rtl/person_d6.v ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 6 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d6 (
8
+ input signed [7:0] f48, f71, f292, f637, f90, f82,
9
+ output person_present
10
+ );
11
+ wire signed [9:0] pos_sum =
12
+ {{2{f48[7]}}, f48} + {{2{f71[7]}}, f71} + {{2{f292[7]}}, f292};
13
+
14
+ wire signed [9:0] neg_sum =
15
+ {{2{f637[7]}}, f637} + {{2{f90[7]}}, f90} + {{2{f82[7]}}, f82};
16
+
17
+ assign person_present = pos_sum > neg_sum;
18
+ endmodule
rtl/person_d8.v ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zero-parameter person classifier, 8 dims.
2
+ // Generated by rtl_gen.py; do not edit by hand.
3
+ //
4
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
5
+ // vector. No threshold, so no constant appears anywhere in this module.
6
+
7
+ module person_d8 (
8
+ input signed [7:0] f48, f71, f292, f111, f637, f90, f82, f269,
9
+ output person_present
10
+ );
11
+ wire signed [9:0] pos_sum =
12
+ {{2{f48[7]}}, f48} + {{2{f71[7]}}, f71} + {{2{f292[7]}}, f292} + {{2{f111[7]}}, f111};
13
+
14
+ wire signed [9:0] neg_sum =
15
+ {{2{f637[7]}}, f637} + {{2{f90[7]}}, f90} + {{2{f82[7]}}, f82} + {{2{f269[7]}}, f269};
16
+
17
+ assign person_present = pos_sum > neg_sum;
18
+ endmodule
rtl_gen.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Emit one Verilog module per rule in rules.json.
2
+
3
+ python rtl_gen.py
4
+
5
+ Inputs are the selected channels as signed INT8, post-LayerNorm and post-max-pool.
6
+ Output is one bit. Combinational, no multipliers, no memory, no constants: the
7
+ comparison is between the two sums, so there is nothing to bake in.
8
+ """
9
+ import argparse
10
+ import math
11
+ from pathlib import Path
12
+
13
+ from common import read_artifact
14
+
15
+ HERE = Path(__file__).resolve().parent
16
+ HEADER = '''// Zero-parameter person classifier, {n} dims.
17
+ // Generated by rtl_gen.py; do not edit by hand.
18
+ //
19
+ // sum(pos) > sum(neg), on signed INT8 channels taken from the pooled feature
20
+ // vector. No threshold, so no constant appears anywhere in this module.
21
+ '''
22
+
23
+
24
+ def _decl(dims, per_line=8):
25
+ rows = []
26
+ for i in range(0, len(dims), per_line):
27
+ names = ', '.join(f'f{d}' for d in dims[i:i + per_line])
28
+ rows.append(f' input signed [7:0] {names},')
29
+ return '\n'.join(rows)
30
+
31
+
32
+ def _sum(dims, width, per_line=4):
33
+ pad = width - 8
34
+ terms = [(f'{{{{{pad}{{f{d}[7]}}}}, f{d}}}' if pad else f'f{d}') for d in dims]
35
+ rows = [' + '.join(terms[i:i + per_line]) for i in range(0, len(terms), per_line)]
36
+ return ' +\n '.join(rows)
37
+
38
+
39
+ def emit(name, pos, neg):
40
+ k = max(len(pos), len(neg))
41
+ width = 8 + max(1, math.ceil(math.log2(k))) if k > 1 else 8
42
+ body = f'''
43
+ module {name} (
44
+ {_decl(list(pos) + list(neg))}
45
+ output person_present
46
+ );
47
+ '''
48
+ if k == 1:
49
+ body += f' assign person_present = f{pos[0]} > f{neg[0]};\nendmodule\n'
50
+ return HEADER.format(n=len(pos) + len(neg)) + body
51
+ body += f''' wire signed [{width - 1}:0] pos_sum =
52
+ {_sum(pos, width)};
53
+
54
+ wire signed [{width - 1}:0] neg_sum =
55
+ {_sum(neg, width)};
56
+
57
+ assign person_present = pos_sum > neg_sum;
58
+ endmodule
59
+ '''
60
+ return HEADER.format(n=len(pos) + len(neg)) + body
61
+
62
+
63
+ def generate(out_dir=None, rules_json=None):
64
+ out_dir = Path(out_dir or HERE / 'rtl')
65
+ out_dir.mkdir(parents=True, exist_ok=True)
66
+ doc = read_artifact(rules_json or HERE / 'rules.json')
67
+ written = []
68
+ for name, r in doc['rules'].items():
69
+ path = out_dir / f'person_{name}.v'
70
+ path.write_text(emit(f'person_{name}', r['pos_dims'], r['neg_dims']),
71
+ encoding='utf-8')
72
+ written.append(path)
73
+ return written
74
+
75
+
76
+ if __name__ == '__main__':
77
+ ap = argparse.ArgumentParser(description=__doc__)
78
+ ap.add_argument('--out', type=Path, default=None)
79
+ args = ap.parse_args()
80
+ for p in generate(args.out):
81
+ print(f'wrote {p}')
rules.json ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "provenance": {
3
+ "generator": "choose.py",
4
+ "pool": "TRAIN2017",
5
+ "split": "train2017",
6
+ "n_images": 118287,
7
+ "positive_rate": 0.542,
8
+ "selection": "all 118287 train2017 images, used only to select dims",
9
+ "decision": "sum(feat[pos_dims]) > sum(feat[neg_dims])",
10
+ "search": "greedy, alternating sides, boundary pinned at zero"
11
+ },
12
+ "candidates": 192,
13
+ "sizes": [
14
+ 2,
15
+ 4,
16
+ 6,
17
+ 8,
18
+ 12,
19
+ 16,
20
+ 20,
21
+ 40
22
+ ],
23
+ "rules": {
24
+ "d2": {
25
+ "pos_dims": [
26
+ 48
27
+ ],
28
+ "neg_dims": [
29
+ 637
30
+ ],
31
+ "n_dims": 2,
32
+ "free_parameters": 0,
33
+ "F1_train": 0.8734
34
+ },
35
+ "d4": {
36
+ "pos_dims": [
37
+ 48,
38
+ 71
39
+ ],
40
+ "neg_dims": [
41
+ 637,
42
+ 90
43
+ ],
44
+ "n_dims": 4,
45
+ "free_parameters": 0,
46
+ "F1_train": 0.8874
47
+ },
48
+ "d6": {
49
+ "pos_dims": [
50
+ 48,
51
+ 71,
52
+ 292
53
+ ],
54
+ "neg_dims": [
55
+ 637,
56
+ 90,
57
+ 82
58
+ ],
59
+ "n_dims": 6,
60
+ "free_parameters": 0,
61
+ "F1_train": 0.9055
62
+ },
63
+ "d8": {
64
+ "pos_dims": [
65
+ 48,
66
+ 71,
67
+ 292,
68
+ 111
69
+ ],
70
+ "neg_dims": [
71
+ 637,
72
+ 90,
73
+ 82,
74
+ 269
75
+ ],
76
+ "n_dims": 8,
77
+ "free_parameters": 0,
78
+ "F1_train": 0.9104
79
+ },
80
+ "d12": {
81
+ "pos_dims": [
82
+ 48,
83
+ 71,
84
+ 292,
85
+ 111,
86
+ 724,
87
+ 406
88
+ ],
89
+ "neg_dims": [
90
+ 637,
91
+ 90,
92
+ 82,
93
+ 269,
94
+ 565,
95
+ 140
96
+ ],
97
+ "n_dims": 12,
98
+ "free_parameters": 0,
99
+ "F1_train": 0.9133
100
+ },
101
+ "d16": {
102
+ "pos_dims": [
103
+ 48,
104
+ 71,
105
+ 292,
106
+ 111,
107
+ 724,
108
+ 406,
109
+ 510,
110
+ 665
111
+ ],
112
+ "neg_dims": [
113
+ 637,
114
+ 90,
115
+ 82,
116
+ 269,
117
+ 565,
118
+ 140,
119
+ 537,
120
+ 49
121
+ ],
122
+ "n_dims": 16,
123
+ "free_parameters": 0,
124
+ "F1_train": 0.917
125
+ },
126
+ "d20": {
127
+ "pos_dims": [
128
+ 48,
129
+ 71,
130
+ 292,
131
+ 111,
132
+ 724,
133
+ 406,
134
+ 510,
135
+ 665,
136
+ 318,
137
+ 267
138
+ ],
139
+ "neg_dims": [
140
+ 637,
141
+ 90,
142
+ 82,
143
+ 269,
144
+ 565,
145
+ 140,
146
+ 537,
147
+ 49,
148
+ 283,
149
+ 114
150
+ ],
151
+ "n_dims": 20,
152
+ "free_parameters": 0,
153
+ "F1_train": 0.9213
154
+ },
155
+ "d40": {
156
+ "pos_dims": [
157
+ 48,
158
+ 71,
159
+ 292,
160
+ 111,
161
+ 724,
162
+ 406,
163
+ 510,
164
+ 665,
165
+ 318,
166
+ 267,
167
+ 552,
168
+ 323,
169
+ 382,
170
+ 305,
171
+ 127,
172
+ 293,
173
+ 85,
174
+ 721,
175
+ 207,
176
+ 326
177
+ ],
178
+ "neg_dims": [
179
+ 637,
180
+ 90,
181
+ 82,
182
+ 269,
183
+ 565,
184
+ 140,
185
+ 537,
186
+ 49,
187
+ 283,
188
+ 114,
189
+ 310,
190
+ 113,
191
+ 494,
192
+ 571,
193
+ 694,
194
+ 92,
195
+ 518,
196
+ 91,
197
+ 307,
198
+ 437
199
+ ],
200
+ "n_dims": 40,
201
+ "free_parameters": 0,
202
+ "F1_train": 0.9301
203
+ }
204
+ }
205
+ }
synth.json ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "provenance": {
3
+ "generator": "synth.py",
4
+ "inputs": "signed INT8 channels of the pooled feature vector",
5
+ "note": "LUT4 and slice counts on an ECP5, not abstract gate counts"
6
+ },
7
+ "tool": "nosis",
8
+ "target": {
9
+ "family": "ecp5",
10
+ "device": "LFE5U-25F"
11
+ },
12
+ "variants": {
13
+ "d2": {
14
+ "slices": 4,
15
+ "lut4": 7,
16
+ "ccu2c": 0,
17
+ "ffs": 0,
18
+ "bound": "lut",
19
+ "critical_path_ns": 0.4,
20
+ "max_freq_mhz": 2040.8,
21
+ "device": "LFE5U-25F",
22
+ "rtl": "rtl/person_d2.v",
23
+ "n_dims": 2
24
+ },
25
+ "d4": {
26
+ "slices": 10,
27
+ "lut4": 8,
28
+ "ccu2c": 10,
29
+ "ffs": 0,
30
+ "bound": "carry",
31
+ "critical_path_ns": 0.9,
32
+ "max_freq_mhz": 973.4,
33
+ "device": "LFE5U-25F",
34
+ "rtl": "rtl/person_d4.v",
35
+ "n_dims": 4
36
+ },
37
+ "d6": {
38
+ "slices": 20,
39
+ "lut4": 9,
40
+ "ccu2c": 20,
41
+ "ffs": 0,
42
+ "bound": "carry",
43
+ "critical_path_ns": 1.4,
44
+ "max_freq_mhz": 654.8,
45
+ "device": "LFE5U-25F",
46
+ "rtl": "rtl/person_d6.v",
47
+ "n_dims": 6
48
+ },
49
+ "d8": {
50
+ "slices": 30,
51
+ "lut4": 9,
52
+ "ccu2c": 30,
53
+ "ffs": 0,
54
+ "bound": "carry",
55
+ "critical_path_ns": 1.9,
56
+ "max_freq_mhz": 493.3,
57
+ "device": "LFE5U-25F",
58
+ "rtl": "rtl/person_d8.v",
59
+ "n_dims": 8
60
+ },
61
+ "d12": {
62
+ "slices": 60,
63
+ "lut4": 10,
64
+ "ccu2c": 60,
65
+ "ffs": 0,
66
+ "bound": "carry",
67
+ "critical_path_ns": 2.9,
68
+ "max_freq_mhz": 330.3,
69
+ "device": "LFE5U-25F",
70
+ "rtl": "rtl/person_d12.v",
71
+ "n_dims": 12
72
+ },
73
+ "d16": {
74
+ "slices": 84,
75
+ "lut4": 10,
76
+ "ccu2c": 84,
77
+ "ffs": 0,
78
+ "bound": "carry",
79
+ "critical_path_ns": 3.9,
80
+ "max_freq_mhz": 248.3,
81
+ "device": "LFE5U-25F",
82
+ "rtl": "rtl/person_d16.v",
83
+ "n_dims": 16
84
+ },
85
+ "d20": {
86
+ "slices": 108,
87
+ "lut4": 11,
88
+ "ccu2c": 108,
89
+ "ffs": 0,
90
+ "bound": "carry",
91
+ "critical_path_ns": 4.9,
92
+ "max_freq_mhz": 198.4,
93
+ "device": "LFE5U-25F",
94
+ "rtl": "rtl/person_d20.v",
95
+ "n_dims": 20
96
+ },
97
+ "d40": {
98
+ "slices": 266,
99
+ "lut4": 12,
100
+ "ccu2c": 266,
101
+ "ffs": 0,
102
+ "bound": "carry",
103
+ "critical_path_ns": 9.9,
104
+ "max_freq_mhz": 99.0,
105
+ "device": "LFE5U-25F",
106
+ "rtl": "rtl/person_d40.v",
107
+ "n_dims": 40
108
+ }
109
+ }
110
+ }
synth.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthesize every generated module with nosis and record the cell counts.
2
+
3
+ python synth.py
4
+
5
+ nosis is a pure-Python SystemVerilog to Lattice ECP5 synthesizer. It is the
6
+ backend used here rather than Yosys, whose gate counts are not comparable.
7
+ Counts are LUT4s and slices on an ECP5, not abstract gates.
8
+ """
9
+ import argparse
10
+ import json
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from common import read_artifact, write_artifact
17
+
18
+ HERE = Path(__file__).resolve().parent
19
+ NOSIS_ROOT = Path(r'D:\nosis')
20
+
21
+
22
+ def synth_one(src: Path, top: str, build: Path, nosis_root: Path):
23
+ build.mkdir(parents=True, exist_ok=True)
24
+ r = subprocess.run(
25
+ [sys.executable, '-m', 'nosis', str(src), '--top', top, '--stats',
26
+ '-o', str(build / f'{top}.json')],
27
+ cwd=str(nosis_root), capture_output=True, text=True)
28
+ if r.returncode != 0:
29
+ raise SystemExit(f'nosis failed on {src.name}:\n{r.stdout[-2000:]}{r.stderr[-2000:]}')
30
+ (build / f'{top}.log').write_text(r.stdout, encoding='utf-8')
31
+
32
+ def grab(pattern, cast=int):
33
+ m = re.search(pattern, r.stdout)
34
+ return cast(m.group(1)) if m else None
35
+
36
+ def text(pattern):
37
+ m = re.search(pattern, r.stdout)
38
+ return m.group(1) if m else None
39
+
40
+ # Adder trees land on the ECP5 carry chain, so CCU2C rather than LUT4 is the
41
+ # size that moves; `bound` records which resource the design is limited by.
42
+ return {
43
+ 'slices': grab(r'Slices:\s+(\d+)'),
44
+ 'lut4': grab(r'LUTs:\s+(\d+)'),
45
+ 'ccu2c': grab(r'CCU2C:\s+(\d+)'),
46
+ 'ffs': grab(r'FFs:\s+(\d+)'),
47
+ 'bound': text(r'Bound:\s+(\S+)'),
48
+ 'critical_path_ns': grab(r'Critical path delay:\s+([\d.]+)', float),
49
+ 'max_freq_mhz': grab(r'Max frequency \(logic\):\s+([\d.]+)', float),
50
+ 'device': text(r'Device:\s+(\S+)'),
51
+ }
52
+
53
+
54
+ def main():
55
+ ap = argparse.ArgumentParser(description=__doc__)
56
+ ap.add_argument('--rtl', type=Path, default=HERE / 'rtl')
57
+ ap.add_argument('--build', type=Path, default=HERE / 'build')
58
+ ap.add_argument('--nosis', type=Path, default=NOSIS_ROOT)
59
+ ap.add_argument('--out', type=Path, default=HERE / 'synth.json')
60
+ args = ap.parse_args()
61
+
62
+ rules = read_artifact(HERE / 'rules.json')['rules']
63
+ variants = {}
64
+ print(f"{'rule':>6}{'dims':>6}{'slices':>8}{'LUT4':>7}{'CCU2C':>7}"
65
+ f"{'bound':>7}{'ns':>7}")
66
+ for name in rules:
67
+ top = f'person_{name}'
68
+ src = args.rtl / f'{top}.v'
69
+ if not src.exists():
70
+ raise SystemExit(f'{src} missing; run rtl_gen.py first')
71
+ s = synth_one(src, top, args.build, args.nosis)
72
+ s['rtl'] = f'rtl/{top}.v'
73
+ s['n_dims'] = rules[name]['n_dims']
74
+ variants[name] = s
75
+ print(f'{name:>6}{s["n_dims"]:>6}{s["slices"]:>8}{s["lut4"]:>7}'
76
+ f'{s["ccu2c"]:>7}{s["bound"]:>7}{s["critical_path_ns"]:>7.2f}',
77
+ flush=True)
78
+
79
+ device = next((v['device'] for v in variants.values() if v['device']), None)
80
+ write_artifact(args.out, {
81
+ 'tool': 'nosis',
82
+ 'target': {'family': 'ecp5', 'device': device},
83
+ 'variants': variants,
84
+ }, generator='synth.py',
85
+ inputs='signed INT8 channels of the pooled feature vector',
86
+ note='LUT4 and slice counts on an ECP5, not abstract gate counts')
87
+ print(f'\n[done] wrote {args.out}')
88
+
89
+
90
+ if __name__ == '__main__':
91
+ main()
tests/conftest.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared fixtures for the consistency suite."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ REPO = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(REPO))
10
+
11
+
12
+ def load(rel: str) -> dict:
13
+ """Parse a repo-relative JSON file."""
14
+ return json.loads((REPO / rel).read_text(encoding='utf-8'))
15
+
16
+
17
+ @pytest.fixture(scope='session')
18
+ def repo() -> Path:
19
+ return REPO
20
+
21
+
22
+ @pytest.fixture(scope='session')
23
+ def rules() -> dict:
24
+ return load('rules.json')
25
+
26
+
27
+ @pytest.fixture(scope='session')
28
+ def evaluation() -> dict:
29
+ return load('eval.json')
tests/test_artifacts.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Every committed artifact matches the schema its generator declares."""
2
+ import pytest
3
+
4
+ from common.artifacts import REGISTRY
5
+ from conftest import REPO, load
6
+
7
+
8
+ @pytest.mark.parametrize('rel', sorted(REGISTRY))
9
+ def test_artifact_exists(rel):
10
+ assert (REPO / rel).exists(), f'{rel} is registered but missing'
11
+
12
+
13
+ @pytest.mark.parametrize('rel', sorted(REGISTRY))
14
+ def test_artifact_opens_with_provenance(rel):
15
+ doc = load(rel)
16
+ assert next(iter(doc)) == 'provenance', f'{rel} does not open with provenance'
17
+
18
+
19
+ @pytest.mark.parametrize('rel', sorted(REGISTRY))
20
+ def test_artifact_payload_keys(rel):
21
+ spec = REGISTRY[rel]
22
+ got = tuple(k for k in load(rel) if k != 'provenance')
23
+ assert got == spec.payload_keys, f'{rel} payload {got} != declared {spec.payload_keys}'
24
+
25
+
26
+ @pytest.mark.parametrize('rel', sorted(REGISTRY))
27
+ def test_artifact_generator(rel):
28
+ assert load(rel)['provenance']['generator'] == REGISTRY[rel].generator
29
+
30
+
31
+ @pytest.mark.parametrize('rel', sorted(REGISTRY))
32
+ def test_artifact_pool(rel):
33
+ spec = REGISTRY[rel]
34
+ pool = load(rel)['provenance'].get('pool')
35
+ if spec.pool is not None:
36
+ assert pool == spec.pool, f'{rel} names pool {pool!r}, registry says {spec.pool!r}'
37
+
38
+
39
+ def test_selection_and_evaluation_read_different_splits():
40
+ """No figure may be measured on the images that chose it."""
41
+ sel = load('rules.json')['provenance']
42
+ ev = load('eval.json')['provenance']
43
+ assert sel['split'] == 'train2017'
44
+ assert ev['split'] == 'val2017'
45
+ assert sel['split'] != ev['split']
tests/test_rtl.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The generated RTL carries the selected dims and computes the comparison.
2
+
3
+ The structural checks run everywhere. The simulation checks run when Icarus
4
+ Verilog is available, on PATH or via the IVERILOG and VVP environment variables.
5
+ """
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+
11
+ import pytest
12
+
13
+ from conftest import REPO, load
14
+
15
+ RTL = REPO / 'rtl'
16
+ RULES = load('rules.json')['rules']
17
+ NAMES = sorted(RULES)
18
+ N_VECTORS = 256
19
+
20
+
21
+ def tool(name, env_var):
22
+ return os.environ.get(env_var) or shutil.which(name)
23
+
24
+
25
+ IVERILOG, VVP = tool('iverilog', 'IVERILOG'), tool('vvp', 'VVP')
26
+ needs_sim = pytest.mark.skipif(not (IVERILOG and VVP),
27
+ reason='Icarus Verilog not found')
28
+
29
+
30
+ def source(name):
31
+ return (RTL / f'person_{name}.v').read_text(encoding='utf-8')
32
+
33
+
34
+ @pytest.mark.parametrize('name', NAMES)
35
+ def test_module_exists(name):
36
+ assert (RTL / f'person_{name}.v').exists()
37
+
38
+
39
+ @pytest.mark.parametrize('name', NAMES)
40
+ def test_ports_are_exactly_the_selected_dims(name):
41
+ r = RULES[name]
42
+ declared = re.findall(r'\bf(\d+)\b', source(name).split(');')[0])
43
+ assert sorted(int(d) for d in set(declared)) == sorted(r['pos_dims'] + r['neg_dims'])
44
+
45
+
46
+ @pytest.mark.parametrize('name', NAMES)
47
+ def test_no_constant_is_baked_in(name):
48
+ """A zero-parameter rule must not contain a fitted threshold."""
49
+ body = source(name).split('\n')
50
+ body = '\n'.join(l for l in body if not l.strip().startswith('//'))
51
+ assert 'localparam' not in body, f'{name} declares a constant'
52
+ for lit in re.findall(r"\d+'s?d(\d+)", body):
53
+ assert int(lit) == 0, f'{name} compares against a non-zero constant'
54
+
55
+
56
+ def vectors(n, dims, seed=0):
57
+ import random
58
+ rng = random.Random(seed)
59
+ return [{d: rng.randint(-128, 127) for d in dims} for _ in range(n)]
60
+
61
+
62
+ def reference(vec, pos, neg):
63
+ return sum(vec[d] for d in pos) > sum(vec[d] for d in neg)
64
+
65
+
66
+ @needs_sim
67
+ @pytest.mark.parametrize('name', NAMES)
68
+ def test_rtl_matches_the_reference(tmp_path, name):
69
+ r = RULES[name]
70
+ dims = r['pos_dims'] + r['neg_dims']
71
+ vecs = vectors(N_VECTORS, dims)
72
+ # Half the vectors are pushed onto the boundary, where > must reject ties.
73
+ for i in range(0, len(vecs), 2):
74
+ v = vecs[i]
75
+ v[r['pos_dims'][0]] = (sum(v[d] for d in r['neg_dims'])
76
+ - sum(v[d] for d in r['pos_dims'][1:]))
77
+ vecs = [v for v in vecs if all(-128 <= x <= 127 for x in v.values())]
78
+
79
+ top = f'person_{name}'
80
+ conns = ',\n '.join(f'.f{d}(f{d})' for d in dims)
81
+ decls = ', '.join(f'f{d}' for d in dims)
82
+ lines = []
83
+ for v in vecs:
84
+ lines.append(' ' + ' '.join(f'f{d} = {v[d]};' for d in dims) + ' #1;'
85
+ ' $display("%b", out);')
86
+ tb = f'''`timescale 1ns/1ps
87
+ module tb;
88
+ reg signed [7:0] {decls};
89
+ wire out;
90
+ {top} dut (
91
+ {conns},
92
+ .person_present(out));
93
+ initial begin
94
+ {chr(10).join(lines)}
95
+ $finish;
96
+ end
97
+ endmodule
98
+ '''
99
+ (tmp_path / 'tb.v').write_text(tb)
100
+ subprocess.run([IVERILOG, '-g2005', '-o', 'tb.vvp',
101
+ str(RTL / f'{top}.v'), 'tb.v'],
102
+ cwd=tmp_path, check=True, capture_output=True)
103
+ out = subprocess.run([VVP, 'tb.vvp'], cwd=tmp_path, check=True,
104
+ capture_output=True, text=True).stdout
105
+ got = [l.strip() == '1' for l in out.splitlines() if l.strip() in ('0', '1')]
106
+ assert got == [reference(v, r['pos_dims'], r['neg_dims']) for v in vecs]
tests/test_rules.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The rules are well formed and say the same thing everywhere they appear."""
2
+ import pytest
3
+
4
+ from conftest import load
5
+
6
+ RULE_NAMES = sorted(load('rules.json')['rules'])
7
+
8
+
9
+ @pytest.mark.parametrize('name', RULE_NAMES)
10
+ def test_sets_are_disjoint_and_balanced(name, rules):
11
+ r = rules['rules'][name]
12
+ pos, neg = r['pos_dims'], r['neg_dims']
13
+ assert len(pos) == len(neg), f'{name}: the two sums must have equal term counts'
14
+ assert not set(pos) & set(neg), f'{name}: a dim appears on both sides'
15
+ assert len(set(pos)) == len(pos) and len(set(neg)) == len(neg)
16
+ assert r['n_dims'] == len(pos) + len(neg)
17
+
18
+
19
+ @pytest.mark.parametrize('name', RULE_NAMES)
20
+ def test_dims_are_in_range(name, rules):
21
+ for d in rules['rules'][name]['pos_dims'] + rules['rules'][name]['neg_dims']:
22
+ assert 0 <= d < 768
23
+
24
+
25
+ @pytest.mark.parametrize('name', RULE_NAMES)
26
+ def test_no_free_parameters(name, rules):
27
+ assert rules['rules'][name]['free_parameters'] == 0
28
+
29
+
30
+ @pytest.mark.parametrize('name', RULE_NAMES)
31
+ def test_name_matches_size(name, rules):
32
+ assert name == f'd{rules["rules"][name]["n_dims"]}'
33
+
34
+
35
+ def test_rules_nest(rules):
36
+ """Greedy selection grows the sets, so each rule extends the one below it."""
37
+ by_size = sorted(rules['rules'].values(), key=lambda r: r['n_dims'])
38
+ for small, large in zip(by_size, by_size[1:]):
39
+ assert small['pos_dims'] == large['pos_dims'][:len(small['pos_dims'])]
40
+ assert small['neg_dims'] == large['neg_dims'][:len(small['neg_dims'])]
41
+
42
+
43
+ @pytest.mark.parametrize('name', RULE_NAMES)
44
+ def test_eval_carries_the_same_dims(name, rules, evaluation):
45
+ r, e = rules['rules'][name], evaluation['rules'][name]
46
+ assert e['pos_dims'] == r['pos_dims']
47
+ assert e['neg_dims'] == r['neg_dims']
48
+ assert e['F1_train'] == r['F1_train']
49
+
50
+
51
+ def test_more_dims_do_not_score_worse(evaluation):
52
+ by_size = sorted(evaluation['rules'].values(), key=lambda r: r['n_dims'])
53
+ f1 = [r['F1'] for r in by_size]
54
+ assert f1 == sorted(f1), f'F1 is not monotone in dim count: {f1}'
55
+
56
+
57
+ def test_val_tracks_train(evaluation):
58
+ """Selection on 118k images should not overfit; val must stay close to train."""
59
+ for name, r in evaluation['rules'].items():
60
+ assert abs(r['F1'] - r['F1_train']) < 0.02, \
61
+ f'{name}: train {r["F1_train"]} against val {r["F1"]}'
verify.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score every rule in rules.json on val2017 and write eval.json.
2
+
3
+ python verify.py
4
+
5
+ Dimensions were chosen on train2017 and are not refit here.
6
+ """
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from common import COCO_ROOT, prf1, read_artifact, write_artifact
11
+ from common.cached import load_pooled
12
+ from common.pools import VAL5000
13
+
14
+ HERE = Path(__file__).resolve().parent
15
+
16
+
17
+ def main():
18
+ ap = argparse.ArgumentParser(description=__doc__)
19
+ ap.add_argument('--cache', type=Path, default=None)
20
+ ap.add_argument('--rules', type=Path, default=HERE / 'rules.json')
21
+ ap.add_argument('--out', type=Path, default=HERE / 'eval.json')
22
+ args = ap.parse_args()
23
+
24
+ cache = args.cache or COCO_ROOT / 'pooled_val2017'
25
+ X, y = load_pooled(cache, 'val2017')
26
+ print(f'[val] {X.shape[0]} images, person rate {y.float().mean():.3f}\n',
27
+ flush=True)
28
+
29
+ doc = read_artifact(args.rules)
30
+ out = {}
31
+ print(f"{'rule':>6}{'dims':>6}{'F1 train':>10}{'F1 val':>9}{'P':>9}{'R':>9}")
32
+ for name, r in doc['rules'].items():
33
+ s = X[:, r['pos_dims']].sum(1) - X[:, r['neg_dims']].sum(1)
34
+ m = prf1(s > 0, y)
35
+ out[name] = {'n_dims': r['n_dims'], 'free_parameters': 0,
36
+ 'pos_dims': r['pos_dims'], 'neg_dims': r['neg_dims'],
37
+ 'F1_train': r['F1_train'],
38
+ 'F1': round(m.f1, 4), 'precision': round(m.precision, 4),
39
+ 'recall': round(m.recall, 4)}
40
+ print(f'{name:>6}{r["n_dims"]:>6}{r["F1_train"]:>10.4f}{m.f1:>9.4f}'
41
+ f'{m.precision:>9.4f}{m.recall:>9.4f}', flush=True)
42
+
43
+ write_artifact(args.out, {'rules': out},
44
+ generator='verify.py',
45
+ pool_info={'pool': VAL5000.name, 'split': VAL5000.split,
46
+ 'n_images': int(X.shape[0]),
47
+ 'positive_rate': round(y.float().mean().item(), 4),
48
+ 'selection': VAL5000.selection},
49
+ task='image-level person presence (binary)',
50
+ protocol='dims selected on train2017, not refit here')
51
+ print(f'\n[done] wrote {args.out}', flush=True)
52
+
53
+
54
+ if __name__ == '__main__':
55
+ main()