CharlesCNorton commited on
Commit ·
e8b8483
0
Parent(s):
Image-level person classification on EUPE-ViT-B features with no free parameters
Browse files- .gitattributes +35 -0
- .gitignore +7 -0
- Makefile +20 -0
- README.md +146 -0
- cache.py +132 -0
- choose.py +85 -0
- common/__init__.py +22 -0
- common/artifacts.py +58 -0
- common/cached.py +20 -0
- common/data.py +77 -0
- common/features.py +48 -0
- common/metrics.py +47 -0
- common/models.py +32 -0
- common/paths.py +25 -0
- common/pool.py +72 -0
- common/pools.py +30 -0
- eval.json +218 -0
- infer.py +67 -0
- pyproject.toml +28 -0
- rtl/person_d12.v +21 -0
- rtl/person_d16.v +21 -0
- rtl/person_d2.v +12 -0
- rtl/person_d20.v +24 -0
- rtl/person_d4.v +18 -0
- rtl/person_d40.v +30 -0
- rtl/person_d6.v +18 -0
- rtl/person_d8.v +18 -0
- rtl_gen.py +81 -0
- rules.json +205 -0
- synth.json +110 -0
- synth.py +91 -0
- tests/conftest.py +29 -0
- tests/test_artifacts.py +45 -0
- tests/test_rtl.py +106 -0
- tests/test_rules.py +61 -0
- verify.py +55 -0
.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()
|