ofiqpy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ofiqpy-0.1.0/LICENSE +21 -0
- ofiqpy-0.1.0/NOTICE +27 -0
- ofiqpy-0.1.0/PKG-INFO +178 -0
- ofiqpy-0.1.0/README.md +143 -0
- ofiqpy-0.1.0/ofiqpy/__init__.py +61 -0
- ofiqpy-0.1.0/ofiqpy/align.py +109 -0
- ofiqpy-0.1.0/ofiqpy/batch.py +102 -0
- ofiqpy-0.1.0/ofiqpy/cli.py +47 -0
- ofiqpy-0.1.0/ofiqpy/config.py +101 -0
- ofiqpy-0.1.0/ofiqpy/detectors/__init__.py +0 -0
- ofiqpy-0.1.0/ofiqpy/detectors/ssd.py +48 -0
- ofiqpy-0.1.0/ofiqpy/landmarks/__init__.py +0 -0
- ofiqpy-0.1.0/ofiqpy/landmarks/adnet.py +84 -0
- ofiqpy-0.1.0/ofiqpy/measures/__init__.py +0 -0
- ofiqpy-0.1.0/ofiqpy/measures/core.py +174 -0
- ofiqpy-0.1.0/ofiqpy/measures/geometry.py +90 -0
- ofiqpy-0.1.0/ofiqpy/measures/helpers.py +32 -0
- ofiqpy-0.1.0/ofiqpy/measures/models.py +126 -0
- ofiqpy-0.1.0/ofiqpy/measures/pixel.py +178 -0
- ofiqpy-0.1.0/ofiqpy/native_cv.py +63 -0
- ofiqpy-0.1.0/ofiqpy/output.py +42 -0
- ofiqpy-0.1.0/ofiqpy/pipeline.py +69 -0
- ofiqpy-0.1.0/ofiqpy/pose/__init__.py +0 -0
- ofiqpy-0.1.0/ofiqpy/pose/tddfa.py +68 -0
- ofiqpy-0.1.0/ofiqpy/segmentation/__init__.py +0 -0
- ofiqpy-0.1.0/ofiqpy/segmentation/occlusion.py +28 -0
- ofiqpy-0.1.0/ofiqpy/segmentation/parsing.py +31 -0
- ofiqpy-0.1.0/ofiqpy/session.py +45 -0
- ofiqpy-0.1.0/ofiqpy/sigmoid.py +42 -0
- ofiqpy-0.1.0/ofiqpy.egg-info/PKG-INFO +178 -0
- ofiqpy-0.1.0/ofiqpy.egg-info/SOURCES.txt +36 -0
- ofiqpy-0.1.0/ofiqpy.egg-info/dependency_links.txt +1 -0
- ofiqpy-0.1.0/ofiqpy.egg-info/entry_points.txt +2 -0
- ofiqpy-0.1.0/ofiqpy.egg-info/requires.txt +16 -0
- ofiqpy-0.1.0/ofiqpy.egg-info/top_level.txt +1 -0
- ofiqpy-0.1.0/pyproject.toml +60 -0
- ofiqpy-0.1.0/setup.cfg +4 -0
- ofiqpy-0.1.0/tests/test_smoke.py +57 -0
ofiqpy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aaron Storey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ofiqpy-0.1.0/NOTICE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
ofiqpy — a faithful Python port of OFIQ v1.1.0
|
|
2
|
+
==============================================
|
|
3
|
+
|
|
4
|
+
ofiqpy reproduces the algorithms of OFIQ (Open Source Face Image Quality), the
|
|
5
|
+
ISO/IEC 29794-5 reference implementation developed by the German Federal Office
|
|
6
|
+
for Information Security (BSI), Copyright (c) 2024, MIT-licensed:
|
|
7
|
+
|
|
8
|
+
https://github.com/BSI-OFIQ/OFIQ-Project
|
|
9
|
+
|
|
10
|
+
Models are NOT bundled
|
|
11
|
+
----------------------
|
|
12
|
+
ofiqpy loads OFIQ's own model files at runtime (SSD face detector, ADNet-98
|
|
13
|
+
landmarks, 3DDFA-V2 pose, BiSeNet face parsing, face-occlusion segmentation,
|
|
14
|
+
sharpness random forest, compression CNN, HSEmotion + AdaBoost expression, and
|
|
15
|
+
the MagFace unified-quality model). These files ship with OFIQ and may be
|
|
16
|
+
licensed separately from the OFIQ source (see OFIQ's LICENSE.md). They are NOT
|
|
17
|
+
redistributed with ofiqpy.
|
|
18
|
+
|
|
19
|
+
To run ofiqpy, obtain the models from an OFIQ install and set:
|
|
20
|
+
|
|
21
|
+
export OFIQPY_OFIQ_DATA=/path/to/OFIQ-Project/data
|
|
22
|
+
|
|
23
|
+
Conformance
|
|
24
|
+
-----------
|
|
25
|
+
ofiqpy targets agreement with the OFIQ reference to within +/- 1 quality point
|
|
26
|
+
per component (the ISO/IEC 29794-5 Annex A criterion). See the docs for the
|
|
27
|
+
validated conformance figures.
|
ofiqpy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ofiqpy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A faithful Python port of OFIQ v1.1.0 (ISO/IEC 29794-5 face image quality), matching the reference within ±1.
|
|
5
|
+
Author: Aaron Storey
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AVHBAC/ofiqpy
|
|
8
|
+
Project-URL: Documentation, https://avhbac.github.io/ofiqpy/
|
|
9
|
+
Project-URL: Repository, https://github.com/AVHBAC/ofiqpy
|
|
10
|
+
Project-URL: Issues, https://github.com/AVHBAC/ofiqpy/issues
|
|
11
|
+
Keywords: face,image quality,biometrics,ISO 29794-5,OFIQ,FIQA
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
License-File: NOTICE
|
|
21
|
+
Requires-Dist: numpy<2,>=1.23
|
|
22
|
+
Requires-Dist: opencv-python-headless==4.5.5.64
|
|
23
|
+
Requires-Dist: onnxruntime==1.18.1
|
|
24
|
+
Provides-Extra: verify
|
|
25
|
+
Requires-Dist: pandas>=1.5; extra == "verify"
|
|
26
|
+
Provides-Extra: docs
|
|
27
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
28
|
+
Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
32
|
+
Requires-Dist: pandas>=1.5; extra == "dev"
|
|
33
|
+
Requires-Dist: jupyter>=1.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# ofiqpy — a faithful Python port of OFIQ v1.1.0
|
|
37
|
+
|
|
38
|
+
[](https://github.com/AVHBAC/ofiqpy/actions/workflows/workflow.yml)
|
|
39
|
+
[](https://avhbac.github.io/ofiqpy/)
|
|
40
|
+
[](https://pypi.org/project/ofiqpy/)
|
|
41
|
+
[](https://pypi.org/project/ofiqpy/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
|
|
44
|
+
A behavior-preserving Python reimplementation of the BSI **OFIQ** ISO/IEC 29794-5
|
|
45
|
+
face image quality library. `ofiqpy` **reuses OFIQ's own model files and reproduces
|
|
46
|
+
its exact `.cpp` algorithms**, matching the live OFIQ reference to within **±1** per
|
|
47
|
+
component (the ISO/IEC 29794-5 Annex A.2 conformance criterion).
|
|
48
|
+
|
|
49
|
+
📖 **Docs: <https://avhbac.github.io/ofiqpy/>**
|
|
50
|
+
|
|
51
|
+
> Status: **full coverage + near-exact parity.** All 27 ISO components + UnifiedQualityScore,
|
|
52
|
+
> an OFIQ-compatible CSV writer, a CLI, and a parallel batch runner — all gated against
|
|
53
|
+
> live `OFIQSampleApp`. Validated on **1,000+ real CelebA images**: 27/28 components fully
|
|
54
|
+
> conformant (±1 on every image, most bit-exact); ~99.99% of all component-image pairs
|
|
55
|
+
> within ±1. The rare per-image residuals are numerical boundaries of discrete/learned
|
|
56
|
+
> models (RTrees vote, AdaBoost score, round tie), not algorithm gaps.
|
|
57
|
+
|
|
58
|
+
## Install & run
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install ofiqpy
|
|
62
|
+
export OFIQPY_OFIQ_DATA=/path/to/OFIQ-Project/data # OFIQ's models (not bundled)
|
|
63
|
+
|
|
64
|
+
ofiqpy -i face.jpg -o out.csv # CLI
|
|
65
|
+
```
|
|
66
|
+
```python
|
|
67
|
+
from ofiqpy import assess
|
|
68
|
+
scores = assess("face.jpg") # {component: (raw, scalar)}
|
|
69
|
+
print(scores["UnifiedQualityScore"]) # (magnitude, 0-100)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
See [Installation](https://avhbac.github.io/ofiqpy/installation/) and
|
|
73
|
+
[Quickstart](https://avhbac.github.io/ofiqpy/quickstart/).
|
|
74
|
+
|
|
75
|
+
## Design
|
|
76
|
+
|
|
77
|
+
- **Same weights.** Models are loaded directly from the reference checkout
|
|
78
|
+
`OFIQ-Project/data/models/` via `config.py` (SSD caffemodel, ADNet-98 ONNX,
|
|
79
|
+
3DDFA-V2 ONNX, BiSeNet parsing ONNX, occlusion-seg ONNX, ssim-248 ONNX, MagFace ONNX).
|
|
80
|
+
No re-training, no substitute backbones.
|
|
81
|
+
- **Same math.** Detection, ADNet landmark crop/denorm, the 5-point LMEDS similarity
|
|
82
|
+
alignment to 616×616, the landmarked-region mask, `tmetric`, the generic
|
|
83
|
+
`h·(a+s·sigmoid(x;x0,w))` scalar mapping, and each measure are ported line-faithfully
|
|
84
|
+
from OFIQ's C++ (see per-module docstrings for `file:line` provenance).
|
|
85
|
+
- **Same versions.** The isolated `.venv` pins **OpenCV 4.5.5** and **onnxruntime 1.18.1**
|
|
86
|
+
to match OFIQ's build (`libofiq_lib.so` links OpenCV 4.5.5; bundles onnxruntime 1.18.1).
|
|
87
|
+
- **Gated, not asserted.** `tests/verify_ofiq.py` runs live OFIQ and checks
|
|
88
|
+
`|port_scalar − ofiq_scalar| ≤ 1` per image; `tests/gate_slice.py` reports it.
|
|
89
|
+
|
|
90
|
+
## Conformance (1,000+ real CelebA images, port vs live OFIQ, ISO Annex A ±1)
|
|
91
|
+
|
|
92
|
+
Validated on **1,197 real CelebA images**: **27 of 28 components fully conformant** (±1 on
|
|
93
|
+
every image), 24 of them **bit-exact** (maxΔ=0). **~99.99% of all component-image pairs are
|
|
94
|
+
within ISO ±1** (mean |Δ| ≤ 0.02 for every component).
|
|
95
|
+
|
|
96
|
+
The rare per-image residuals:
|
|
97
|
+
- **Sharpness — 1 image at Δ=2**: OFIQ's RTrees vote count differs by exactly 2 trees at a
|
|
98
|
+
split-threshold knife-edge (a sub-LSB feature difference flips 2 borderline votes through
|
|
99
|
+
the step-function forest). Bit-exact on the rest.
|
|
100
|
+
- **BackgroundUniformity / ExpressionNeutrality / NoHeadCoverings — one ±1 image each**, a
|
|
101
|
+
single sigmoid/round boundary.
|
|
102
|
+
|
|
103
|
+
Sharpness (RTrees) and ExpressionNeutrality (dual EfficientNet + AdaBoost) run OFIQ's own
|
|
104
|
+
`cv2.ml` / ONNX models; UnifiedQualityScore runs OFIQ's MagFace ONNX. These four residuals
|
|
105
|
+
are numerical boundaries of discrete/learned models, not algorithm gaps.
|
|
106
|
+
|
|
107
|
+
### How parity was reached (the residual was a bug, not a build limit)
|
|
108
|
+
|
|
109
|
+
An earlier version of this port was only ~96% conformant, and the residual was *wrongly*
|
|
110
|
+
attributed to a build-level OpenCV float difference. To test that, ctypes bridges were
|
|
111
|
+
built against OFIQ's own conan OpenCV static libs (`native/ofiq_cv.cpp`, `ofiq_ssd.cpp`)
|
|
112
|
+
and used to compare OFIQ's compiled `estimateAffinePartial2D`, `warpAffine`, `resize`, and
|
|
113
|
+
the SSD dnn forward pass against the pip `opencv-python` wheel. **Every OpenCV operation
|
|
114
|
+
was bit-identical** — which disproved the build-level theory and localized the divergence
|
|
115
|
+
to the **ADNet landmark back-projection**: OFIQ scales landmarks back with
|
|
116
|
+
`squareBox.height / 256` (`adnet_landmarks.cpp:313`), but `makeSquareBoundingBox`'s
|
|
117
|
+
`floor`/`ceil` can leave the box 1px non-square, and the port had used the *width*. On
|
|
118
|
+
exactly-square detector boxes it matched; otherwise it drifted ~1px, propagating (via the
|
|
119
|
+
alignment source points → affine → whole aligned face) into every landmark-sensitive
|
|
120
|
+
measure. One-character fix (width→height); all 27 non-model components went bit-exact.
|
|
121
|
+
|
|
122
|
+
The bridges in `native/` are diagnostic only — the runtime uses pip `cv2`, which is
|
|
123
|
+
bit-identical to OFIQ's OpenCV. No source build of OpenCV was needed.
|
|
124
|
+
|
|
125
|
+
## Layout
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
ofiqpy/
|
|
129
|
+
config.py JAXN loader + OFIQ model resolver + sigmoid params
|
|
130
|
+
sigmoid.py OFIQ ScalarConversion (Measure.h:271-285)
|
|
131
|
+
session.py shared preprocessing products
|
|
132
|
+
pipeline.py detect -> pose -> landmarks -> align -> parse -> occlusion -> region
|
|
133
|
+
detectors/ssd.py SSD (OpenCV DNN, Caffe)
|
|
134
|
+
landmarks/adnet.py ADNet-98 + square-crop helpers
|
|
135
|
+
align.py 616x616 alignment, landmarked region (GetFaceMask), tmetric, luminance
|
|
136
|
+
pose/tddfa.py 3DDFA-V2 pose
|
|
137
|
+
segmentation/ BiSeNet parsing, face-occlusion seg
|
|
138
|
+
measures/
|
|
139
|
+
core.py model cache + dispatch; C03/C09/C17/C20, unified, HeadPose (slot swap)
|
|
140
|
+
geometry.py C11,C12,C13,C19,C24-C27
|
|
141
|
+
pixel.py C01,C02,C04(var),C05,C06,C07,C10
|
|
142
|
+
models.py C08 Sharpness (RTrees), C14/C15/C16 occlusion, C18 Expression
|
|
143
|
+
helpers.py get_distance/get_middle, c_round, landmark index maps
|
|
144
|
+
output.py OFIQ-format CSV (named cols, raw + .scalar)
|
|
145
|
+
cli.py OFIQSampleApp-compatible CLI
|
|
146
|
+
tests/
|
|
147
|
+
verify_ofiq.py runs live OFIQ, ±1 gate
|
|
148
|
+
gate_slice.py full-coverage conformance runner
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Run
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
export OFIQPY_OFIQ_DATA=/path/to/OFIQ-Project/data
|
|
155
|
+
|
|
156
|
+
ofiqpy -i <image|dir> -o out.csv # single / small runs (OFIQ-format CSV)
|
|
157
|
+
python -m ofiqpy.batch -i <dir> -o out.csv -w 8 --resume # parallel batch
|
|
158
|
+
|
|
159
|
+
# reproduce the conformance gate (needs a built OFIQSampleApp)
|
|
160
|
+
export OFIQPY_OFIQ_ROOT=/path/to/OFIQ-Project
|
|
161
|
+
export OFIQPY_TEST_IMAGES=/path/to/images
|
|
162
|
+
python tests/gate_slice.py 1000
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Full documentation: <https://avhbac.github.io/ofiqpy/>.
|
|
166
|
+
|
|
167
|
+
## License & attribution
|
|
168
|
+
|
|
169
|
+
`ofiqpy` is released under the [MIT License](LICENSE).
|
|
170
|
+
|
|
171
|
+
It is a faithful port of **OFIQ** (Open Source Face Image Quality), developed by the German
|
|
172
|
+
Federal Office for Information Security (BSI), Copyright © 2024, MIT-licensed
|
|
173
|
+
(<https://github.com/BSI-OFIQ/OFIQ-Project>). Please acknowledge OFIQ when using ofiqpy.
|
|
174
|
+
|
|
175
|
+
**Models are not bundled.** ofiqpy loads OFIQ's own model files at runtime; they ship with
|
|
176
|
+
OFIQ and may be licensed separately (see OFIQ's `LICENSE.md`). Obtain them from an OFIQ
|
|
177
|
+
install and set `OFIQPY_OFIQ_DATA`. See [`NOTICE`](NOTICE) and the
|
|
178
|
+
[licensing docs](https://avhbac.github.io/ofiqpy/licensing/).
|
ofiqpy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# ofiqpy — a faithful Python port of OFIQ v1.1.0
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AVHBAC/ofiqpy/actions/workflows/workflow.yml)
|
|
4
|
+
[](https://avhbac.github.io/ofiqpy/)
|
|
5
|
+
[](https://pypi.org/project/ofiqpy/)
|
|
6
|
+
[](https://pypi.org/project/ofiqpy/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
A behavior-preserving Python reimplementation of the BSI **OFIQ** ISO/IEC 29794-5
|
|
10
|
+
face image quality library. `ofiqpy` **reuses OFIQ's own model files and reproduces
|
|
11
|
+
its exact `.cpp` algorithms**, matching the live OFIQ reference to within **±1** per
|
|
12
|
+
component (the ISO/IEC 29794-5 Annex A.2 conformance criterion).
|
|
13
|
+
|
|
14
|
+
📖 **Docs: <https://avhbac.github.io/ofiqpy/>**
|
|
15
|
+
|
|
16
|
+
> Status: **full coverage + near-exact parity.** All 27 ISO components + UnifiedQualityScore,
|
|
17
|
+
> an OFIQ-compatible CSV writer, a CLI, and a parallel batch runner — all gated against
|
|
18
|
+
> live `OFIQSampleApp`. Validated on **1,000+ real CelebA images**: 27/28 components fully
|
|
19
|
+
> conformant (±1 on every image, most bit-exact); ~99.99% of all component-image pairs
|
|
20
|
+
> within ±1. The rare per-image residuals are numerical boundaries of discrete/learned
|
|
21
|
+
> models (RTrees vote, AdaBoost score, round tie), not algorithm gaps.
|
|
22
|
+
|
|
23
|
+
## Install & run
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install ofiqpy
|
|
27
|
+
export OFIQPY_OFIQ_DATA=/path/to/OFIQ-Project/data # OFIQ's models (not bundled)
|
|
28
|
+
|
|
29
|
+
ofiqpy -i face.jpg -o out.csv # CLI
|
|
30
|
+
```
|
|
31
|
+
```python
|
|
32
|
+
from ofiqpy import assess
|
|
33
|
+
scores = assess("face.jpg") # {component: (raw, scalar)}
|
|
34
|
+
print(scores["UnifiedQualityScore"]) # (magnitude, 0-100)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
See [Installation](https://avhbac.github.io/ofiqpy/installation/) and
|
|
38
|
+
[Quickstart](https://avhbac.github.io/ofiqpy/quickstart/).
|
|
39
|
+
|
|
40
|
+
## Design
|
|
41
|
+
|
|
42
|
+
- **Same weights.** Models are loaded directly from the reference checkout
|
|
43
|
+
`OFIQ-Project/data/models/` via `config.py` (SSD caffemodel, ADNet-98 ONNX,
|
|
44
|
+
3DDFA-V2 ONNX, BiSeNet parsing ONNX, occlusion-seg ONNX, ssim-248 ONNX, MagFace ONNX).
|
|
45
|
+
No re-training, no substitute backbones.
|
|
46
|
+
- **Same math.** Detection, ADNet landmark crop/denorm, the 5-point LMEDS similarity
|
|
47
|
+
alignment to 616×616, the landmarked-region mask, `tmetric`, the generic
|
|
48
|
+
`h·(a+s·sigmoid(x;x0,w))` scalar mapping, and each measure are ported line-faithfully
|
|
49
|
+
from OFIQ's C++ (see per-module docstrings for `file:line` provenance).
|
|
50
|
+
- **Same versions.** The isolated `.venv` pins **OpenCV 4.5.5** and **onnxruntime 1.18.1**
|
|
51
|
+
to match OFIQ's build (`libofiq_lib.so` links OpenCV 4.5.5; bundles onnxruntime 1.18.1).
|
|
52
|
+
- **Gated, not asserted.** `tests/verify_ofiq.py` runs live OFIQ and checks
|
|
53
|
+
`|port_scalar − ofiq_scalar| ≤ 1` per image; `tests/gate_slice.py` reports it.
|
|
54
|
+
|
|
55
|
+
## Conformance (1,000+ real CelebA images, port vs live OFIQ, ISO Annex A ±1)
|
|
56
|
+
|
|
57
|
+
Validated on **1,197 real CelebA images**: **27 of 28 components fully conformant** (±1 on
|
|
58
|
+
every image), 24 of them **bit-exact** (maxΔ=0). **~99.99% of all component-image pairs are
|
|
59
|
+
within ISO ±1** (mean |Δ| ≤ 0.02 for every component).
|
|
60
|
+
|
|
61
|
+
The rare per-image residuals:
|
|
62
|
+
- **Sharpness — 1 image at Δ=2**: OFIQ's RTrees vote count differs by exactly 2 trees at a
|
|
63
|
+
split-threshold knife-edge (a sub-LSB feature difference flips 2 borderline votes through
|
|
64
|
+
the step-function forest). Bit-exact on the rest.
|
|
65
|
+
- **BackgroundUniformity / ExpressionNeutrality / NoHeadCoverings — one ±1 image each**, a
|
|
66
|
+
single sigmoid/round boundary.
|
|
67
|
+
|
|
68
|
+
Sharpness (RTrees) and ExpressionNeutrality (dual EfficientNet + AdaBoost) run OFIQ's own
|
|
69
|
+
`cv2.ml` / ONNX models; UnifiedQualityScore runs OFIQ's MagFace ONNX. These four residuals
|
|
70
|
+
are numerical boundaries of discrete/learned models, not algorithm gaps.
|
|
71
|
+
|
|
72
|
+
### How parity was reached (the residual was a bug, not a build limit)
|
|
73
|
+
|
|
74
|
+
An earlier version of this port was only ~96% conformant, and the residual was *wrongly*
|
|
75
|
+
attributed to a build-level OpenCV float difference. To test that, ctypes bridges were
|
|
76
|
+
built against OFIQ's own conan OpenCV static libs (`native/ofiq_cv.cpp`, `ofiq_ssd.cpp`)
|
|
77
|
+
and used to compare OFIQ's compiled `estimateAffinePartial2D`, `warpAffine`, `resize`, and
|
|
78
|
+
the SSD dnn forward pass against the pip `opencv-python` wheel. **Every OpenCV operation
|
|
79
|
+
was bit-identical** — which disproved the build-level theory and localized the divergence
|
|
80
|
+
to the **ADNet landmark back-projection**: OFIQ scales landmarks back with
|
|
81
|
+
`squareBox.height / 256` (`adnet_landmarks.cpp:313`), but `makeSquareBoundingBox`'s
|
|
82
|
+
`floor`/`ceil` can leave the box 1px non-square, and the port had used the *width*. On
|
|
83
|
+
exactly-square detector boxes it matched; otherwise it drifted ~1px, propagating (via the
|
|
84
|
+
alignment source points → affine → whole aligned face) into every landmark-sensitive
|
|
85
|
+
measure. One-character fix (width→height); all 27 non-model components went bit-exact.
|
|
86
|
+
|
|
87
|
+
The bridges in `native/` are diagnostic only — the runtime uses pip `cv2`, which is
|
|
88
|
+
bit-identical to OFIQ's OpenCV. No source build of OpenCV was needed.
|
|
89
|
+
|
|
90
|
+
## Layout
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
ofiqpy/
|
|
94
|
+
config.py JAXN loader + OFIQ model resolver + sigmoid params
|
|
95
|
+
sigmoid.py OFIQ ScalarConversion (Measure.h:271-285)
|
|
96
|
+
session.py shared preprocessing products
|
|
97
|
+
pipeline.py detect -> pose -> landmarks -> align -> parse -> occlusion -> region
|
|
98
|
+
detectors/ssd.py SSD (OpenCV DNN, Caffe)
|
|
99
|
+
landmarks/adnet.py ADNet-98 + square-crop helpers
|
|
100
|
+
align.py 616x616 alignment, landmarked region (GetFaceMask), tmetric, luminance
|
|
101
|
+
pose/tddfa.py 3DDFA-V2 pose
|
|
102
|
+
segmentation/ BiSeNet parsing, face-occlusion seg
|
|
103
|
+
measures/
|
|
104
|
+
core.py model cache + dispatch; C03/C09/C17/C20, unified, HeadPose (slot swap)
|
|
105
|
+
geometry.py C11,C12,C13,C19,C24-C27
|
|
106
|
+
pixel.py C01,C02,C04(var),C05,C06,C07,C10
|
|
107
|
+
models.py C08 Sharpness (RTrees), C14/C15/C16 occlusion, C18 Expression
|
|
108
|
+
helpers.py get_distance/get_middle, c_round, landmark index maps
|
|
109
|
+
output.py OFIQ-format CSV (named cols, raw + .scalar)
|
|
110
|
+
cli.py OFIQSampleApp-compatible CLI
|
|
111
|
+
tests/
|
|
112
|
+
verify_ofiq.py runs live OFIQ, ±1 gate
|
|
113
|
+
gate_slice.py full-coverage conformance runner
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Run
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
export OFIQPY_OFIQ_DATA=/path/to/OFIQ-Project/data
|
|
120
|
+
|
|
121
|
+
ofiqpy -i <image|dir> -o out.csv # single / small runs (OFIQ-format CSV)
|
|
122
|
+
python -m ofiqpy.batch -i <dir> -o out.csv -w 8 --resume # parallel batch
|
|
123
|
+
|
|
124
|
+
# reproduce the conformance gate (needs a built OFIQSampleApp)
|
|
125
|
+
export OFIQPY_OFIQ_ROOT=/path/to/OFIQ-Project
|
|
126
|
+
export OFIQPY_TEST_IMAGES=/path/to/images
|
|
127
|
+
python tests/gate_slice.py 1000
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Full documentation: <https://avhbac.github.io/ofiqpy/>.
|
|
131
|
+
|
|
132
|
+
## License & attribution
|
|
133
|
+
|
|
134
|
+
`ofiqpy` is released under the [MIT License](LICENSE).
|
|
135
|
+
|
|
136
|
+
It is a faithful port of **OFIQ** (Open Source Face Image Quality), developed by the German
|
|
137
|
+
Federal Office for Information Security (BSI), Copyright © 2024, MIT-licensed
|
|
138
|
+
(<https://github.com/BSI-OFIQ/OFIQ-Project>). Please acknowledge OFIQ when using ofiqpy.
|
|
139
|
+
|
|
140
|
+
**Models are not bundled.** ofiqpy loads OFIQ's own model files at runtime; they ship with
|
|
141
|
+
OFIQ and may be licensed separately (see OFIQ's `LICENSE.md`). Obtain them from an OFIQ
|
|
142
|
+
install and set `OFIQPY_OFIQ_DATA`. See [`NOTICE`](NOTICE) and the
|
|
143
|
+
[licensing docs](https://avhbac.github.io/ofiqpy/licensing/).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""ofiqpy — a faithful Python port of OFIQ v1.1.0 (ISO/IEC 29794-5 face image quality).
|
|
2
|
+
|
|
3
|
+
Reuses OFIQ's own models and reproduces its exact algorithms, matching the reference
|
|
4
|
+
within +/- 1 quality point per component. Point ofiqpy at an OFIQ install's data/ dir:
|
|
5
|
+
|
|
6
|
+
export OFIQPY_OFIQ_DATA=/path/to/OFIQ-Project/data
|
|
7
|
+
|
|
8
|
+
Then:
|
|
9
|
+
|
|
10
|
+
from ofiqpy import assess
|
|
11
|
+
scores = assess("face.jpg") # {component_name: (raw, scalar)}
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
_PIPE = None
|
|
18
|
+
_MEAS = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _lazy():
|
|
22
|
+
"""Build (once) the shared pipeline + measures. Models load on first use."""
|
|
23
|
+
global _PIPE, _MEAS
|
|
24
|
+
if _PIPE is None:
|
|
25
|
+
from .config import OFIQConfig
|
|
26
|
+
from .measures.core import Measures
|
|
27
|
+
from .pipeline import OFIQPipeline
|
|
28
|
+
cfg = OFIQConfig()
|
|
29
|
+
_PIPE = OFIQPipeline(cfg)
|
|
30
|
+
_MEAS = Measures(cfg)
|
|
31
|
+
return _PIPE, _MEAS
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def assess(image: "str | object") -> dict:
|
|
35
|
+
"""Assess one image and return ``{component_name: (raw, scalar)}``.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
image: path to an image file (str or PathLike), or a BGR uint8 numpy
|
|
39
|
+
array of shape (H, W, 3).
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Mapping of OFIQ component name to ``(raw native value, 0-100 scalar)``. Empty
|
|
43
|
+
if no face is detected.
|
|
44
|
+
"""
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
pipe, meas = _lazy()
|
|
48
|
+
if isinstance(image, np.ndarray):
|
|
49
|
+
bgr = image
|
|
50
|
+
else:
|
|
51
|
+
import cv2
|
|
52
|
+
bgr = cv2.imread(str(image))
|
|
53
|
+
if bgr is None:
|
|
54
|
+
raise FileNotFoundError(f"could not read image: {image}")
|
|
55
|
+
session = pipe.process(bgr)
|
|
56
|
+
if session.bbox is None:
|
|
57
|
+
return {}
|
|
58
|
+
return meas.compute(session)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["assess", "__version__"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Alignment (616x616), landmarked-region mask, tmetric, luminance.
|
|
2
|
+
|
|
3
|
+
Faithful port of utils.cpp:236-331, FaceMeasures.cpp:98-228, image_utils.cpp:43-112.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
# ADNet FaceMap indices (adnet_FaceMap.h)
|
|
13
|
+
LEFT_EYE_CORNERS = (60, 64)
|
|
14
|
+
RIGHT_EYE_CORNERS = (68, 72)
|
|
15
|
+
NOSE = 54
|
|
16
|
+
RIGHT_MOUTH = 76
|
|
17
|
+
LEFT_MOUTH = 82
|
|
18
|
+
CHIN = 16
|
|
19
|
+
|
|
20
|
+
REF_POINTS = np.float32([
|
|
21
|
+
[251, 272], [364, 272], [308, 336], [262, 402], [355, 402],
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _round(x):
|
|
26
|
+
return math.copysign(math.floor(abs(x) + 0.5), x)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def eye_centers(P: np.ndarray):
|
|
30
|
+
"""Midpoints of eye-corner pairs, each coord rounded (utils.cpp:302-317)."""
|
|
31
|
+
l = (_round(P[LEFT_EYE_CORNERS[0], 0] + 0.5 * (P[LEFT_EYE_CORNERS[1], 0] - P[LEFT_EYE_CORNERS[0], 0])),
|
|
32
|
+
_round(P[LEFT_EYE_CORNERS[0], 1] + 0.5 * (P[LEFT_EYE_CORNERS[1], 1] - P[LEFT_EYE_CORNERS[0], 1])))
|
|
33
|
+
r = (_round(P[RIGHT_EYE_CORNERS[0], 0] + 0.5 * (P[RIGHT_EYE_CORNERS[1], 0] - P[RIGHT_EYE_CORNERS[0], 0])),
|
|
34
|
+
_round(P[RIGHT_EYE_CORNERS[0], 1] + 0.5 * (P[RIGHT_EYE_CORNERS[1], 1] - P[RIGHT_EYE_CORNERS[0], 1])))
|
|
35
|
+
return l, r
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def align(img: np.ndarray, P: np.ndarray):
|
|
39
|
+
"""Return (aligned 616x616 BGR, aligned_landmarks (98,2), affine 2x3)."""
|
|
40
|
+
Lc, Rc = eye_centers(P)
|
|
41
|
+
src = np.float32([
|
|
42
|
+
[Lc[0], Lc[1]], [Rc[0], Rc[1]],
|
|
43
|
+
[P[NOSE, 0], P[NOSE, 1]],
|
|
44
|
+
[P[RIGHT_MOUTH, 0], P[RIGHT_MOUTH, 1]],
|
|
45
|
+
[P[LEFT_MOUTH, 0], P[LEFT_MOUTH, 1]],
|
|
46
|
+
])
|
|
47
|
+
M, _ = cv2.estimateAffinePartial2D(src, REF_POINTS, method=cv2.LMEDS)
|
|
48
|
+
aligned = cv2.warpAffine(img, M, (616, 616)) # default INTER_LINEAR, BORDER_CONSTANT 0
|
|
49
|
+
ap = cv2.transform(P.reshape(-1, 1, 2).astype(np.float32), M).reshape(-1, 2)
|
|
50
|
+
aligned_lms = np.array([[_round(x), _round(y)] for x, y in ap], np.float64)
|
|
51
|
+
return aligned, aligned_lms, M
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tmetric(P: np.ndarray) -> float:
|
|
55
|
+
"""||chin - eye_midpoint|| (utils.cpp:319-331). Space-agnostic; pass the right landmarks."""
|
|
56
|
+
Lc, Rc = eye_centers(P)
|
|
57
|
+
eye_mid = ((Lc[0] + Rc[0]) / 2.0, (Lc[1] + Rc[1]) / 2.0)
|
|
58
|
+
chin = P[CHIN]
|
|
59
|
+
return float(math.hypot(chin[0] - eye_mid[0], chin[1] - eye_mid[1]))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def landmarked_region(aligned_lms: np.ndarray, height=616, width=616, alpha=0.0) -> np.ndarray:
|
|
63
|
+
"""GetFaceMask at alpha=0 (FaceMeasures.cpp:173-226): convex hull -> 224 -> resize."""
|
|
64
|
+
pts = aligned_lms.astype(np.int32)
|
|
65
|
+
hull = cv2.convexHull(pts)
|
|
66
|
+
rx, ry, rw, rh = cv2.boundingRect(hull)
|
|
67
|
+
b = int(ry - rh * 0.05)
|
|
68
|
+
d = int(ry + rh * 1.05)
|
|
69
|
+
a = int(rx + rw / 2.0 - (d - b) / 2.0)
|
|
70
|
+
c = int(rx + rw / 2.0 + (d - b) / 2.0)
|
|
71
|
+
S = 224
|
|
72
|
+
hull_s = ((hull.reshape(-1, 2).astype(np.float64) - (a, b)) / (d - b) * S).astype(np.int32)
|
|
73
|
+
mask = np.zeros((S, S), np.uint8)
|
|
74
|
+
cv2.fillConvexPoly(mask, hull_s, 1)
|
|
75
|
+
mrs = cv2.resize(mask, (c - a, d - b), interpolation=cv2.INTER_NEAREST)
|
|
76
|
+
region = np.zeros((height, width), np.uint8)
|
|
77
|
+
left, top = 0, 0
|
|
78
|
+
right, bottom = mrs.shape[1], mrs.shape[0]
|
|
79
|
+
an, bn, cn, dn = a, b, c, d
|
|
80
|
+
if a < 0:
|
|
81
|
+
left = -a
|
|
82
|
+
an = 0
|
|
83
|
+
if c > width:
|
|
84
|
+
right = mrs.shape[1] - (c - width)
|
|
85
|
+
cn = width
|
|
86
|
+
if b < 0:
|
|
87
|
+
top = -b
|
|
88
|
+
bn = 0
|
|
89
|
+
if d > height:
|
|
90
|
+
bottom = mrs.shape[0] - (d - height)
|
|
91
|
+
dn = height
|
|
92
|
+
region[bn:dn, an:cn] = mrs[top:bottom, left:right]
|
|
93
|
+
return region
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- luminance (image_utils.cpp:43-112) ---
|
|
97
|
+
_SRGB_LUT = np.array([
|
|
98
|
+
((v / 255.0) / 12.92) if (v / 255.0) <= 0.04045 else (((v / 255.0) + 0.055) / 1.055) ** 2.4
|
|
99
|
+
for v in range(256)
|
|
100
|
+
], np.float64)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def luminance(img_bgr: np.ndarray) -> np.ndarray:
|
|
104
|
+
"""sRGB-linearized Rec.709 luma -> floor(y*255+0.5) uint8 (BGR input)."""
|
|
105
|
+
B = _SRGB_LUT[img_bgr[:, :, 0]]
|
|
106
|
+
G = _SRGB_LUT[img_bgr[:, :, 1]]
|
|
107
|
+
R = _SRGB_LUT[img_bgr[:, :, 2]]
|
|
108
|
+
y = 0.2126 * R + 0.7152 * G + 0.0722 * B
|
|
109
|
+
return np.floor(y * 255.0 + 0.5).astype(np.uint8)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Parallel batch runner — assess a directory of images to an OFIQ-format CSV.
|
|
2
|
+
|
|
3
|
+
Each worker process builds its own pipeline (ONNX/cv2.ml models are not shared
|
|
4
|
+
across processes). Resumable: rows already present in the output CSV are skipped.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import csv
|
|
10
|
+
import time
|
|
11
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import cv2
|
|
15
|
+
|
|
16
|
+
from .output import header, row
|
|
17
|
+
|
|
18
|
+
EXTS = {".jpg", ".jpeg", ".png", ".bmp"}
|
|
19
|
+
|
|
20
|
+
# per-worker singletons
|
|
21
|
+
_PIPE = None
|
|
22
|
+
_MEAS = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _init_worker():
|
|
26
|
+
global _PIPE, _MEAS
|
|
27
|
+
from .config import OFIQConfig
|
|
28
|
+
from .measures.core import Measures
|
|
29
|
+
from .pipeline import OFIQPipeline
|
|
30
|
+
cfg = OFIQConfig()
|
|
31
|
+
_PIPE = OFIQPipeline(cfg)
|
|
32
|
+
_MEAS = Measures(cfg)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _assess_one(path_str: str) -> str:
|
|
36
|
+
t0 = time.time()
|
|
37
|
+
bgr = cv2.imread(path_str)
|
|
38
|
+
try:
|
|
39
|
+
s = _PIPE.process(bgr)
|
|
40
|
+
res = _MEAS.compute(s) if s.bbox is not None else {}
|
|
41
|
+
except Exception:
|
|
42
|
+
res = {}
|
|
43
|
+
return row(path_str, res, (time.time() - t0) * 1000)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def discover(input_path: Path) -> list[Path]:
|
|
47
|
+
p = Path(input_path)
|
|
48
|
+
if p.is_file():
|
|
49
|
+
return [p]
|
|
50
|
+
return sorted(f for f in p.rglob("*") if f.suffix.lower() in EXTS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _already_done(out_csv: Path) -> set[str]:
|
|
54
|
+
if not out_csv.exists():
|
|
55
|
+
return set()
|
|
56
|
+
done = set()
|
|
57
|
+
with open(out_csv, newline="") as f:
|
|
58
|
+
r = csv.reader(f, delimiter=";")
|
|
59
|
+
next(r, None) # header
|
|
60
|
+
for line in r:
|
|
61
|
+
if line:
|
|
62
|
+
done.add(line[0])
|
|
63
|
+
return done
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_batch(input_path, output_csv, workers=None, resume=False, progress=True):
|
|
67
|
+
images = discover(Path(input_path))
|
|
68
|
+
out = Path(output_csv)
|
|
69
|
+
done = _already_done(out) if resume else set()
|
|
70
|
+
todo = [im for im in images if im.name not in done]
|
|
71
|
+
|
|
72
|
+
write_header = not (resume and out.exists())
|
|
73
|
+
mode = "a" if (resume and out.exists()) else "w"
|
|
74
|
+
n_done = 0
|
|
75
|
+
t0 = time.time()
|
|
76
|
+
with open(out, mode) as fh, ProcessPoolExecutor(max_workers=workers,
|
|
77
|
+
initializer=_init_worker) as ex:
|
|
78
|
+
if write_header:
|
|
79
|
+
fh.write(header() + "\n")
|
|
80
|
+
for line in ex.map(_assess_one, [str(im) for im in todo], chunksize=1):
|
|
81
|
+
fh.write(line + "\n")
|
|
82
|
+
fh.flush()
|
|
83
|
+
n_done += 1
|
|
84
|
+
if progress and n_done % 25 == 0:
|
|
85
|
+
rate = n_done / max(time.time() - t0, 1e-6)
|
|
86
|
+
print(f" {n_done}/{len(todo)} {rate:.1f} img/s", flush=True)
|
|
87
|
+
print(f"done: {n_done} images -> {out} ({len(done)} pre-existing skipped)")
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
ap = argparse.ArgumentParser(description="ofiqpy parallel batch runner")
|
|
93
|
+
ap.add_argument("-i", "--input", required=True, help="image directory (recursive) or file")
|
|
94
|
+
ap.add_argument("-o", "--output", required=True, help="output CSV")
|
|
95
|
+
ap.add_argument("-w", "--workers", type=int, default=None, help="worker processes (default: CPUs)")
|
|
96
|
+
ap.add_argument("--resume", action="store_true", help="skip images already in the output CSV")
|
|
97
|
+
args = ap.parse_args()
|
|
98
|
+
run_batch(args.input, args.output, args.workers, args.resume)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|