ofiqpy 0.1.0__py3-none-any.whl
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/__init__.py +61 -0
- ofiqpy/align.py +109 -0
- ofiqpy/batch.py +102 -0
- ofiqpy/cli.py +47 -0
- ofiqpy/config.py +101 -0
- ofiqpy/detectors/__init__.py +0 -0
- ofiqpy/detectors/ssd.py +48 -0
- ofiqpy/landmarks/__init__.py +0 -0
- ofiqpy/landmarks/adnet.py +84 -0
- ofiqpy/measures/__init__.py +0 -0
- ofiqpy/measures/core.py +174 -0
- ofiqpy/measures/geometry.py +90 -0
- ofiqpy/measures/helpers.py +32 -0
- ofiqpy/measures/models.py +126 -0
- ofiqpy/measures/pixel.py +178 -0
- ofiqpy/native_cv.py +63 -0
- ofiqpy/output.py +42 -0
- ofiqpy/pipeline.py +69 -0
- ofiqpy/pose/__init__.py +0 -0
- ofiqpy/pose/tddfa.py +68 -0
- ofiqpy/segmentation/__init__.py +0 -0
- ofiqpy/segmentation/occlusion.py +28 -0
- ofiqpy/segmentation/parsing.py +31 -0
- ofiqpy/session.py +45 -0
- ofiqpy/sigmoid.py +42 -0
- ofiqpy-0.1.0.dist-info/METADATA +178 -0
- ofiqpy-0.1.0.dist-info/RECORD +32 -0
- ofiqpy-0.1.0.dist-info/WHEEL +5 -0
- ofiqpy-0.1.0.dist-info/entry_points.txt +2 -0
- ofiqpy-0.1.0.dist-info/licenses/LICENSE +21 -0
- ofiqpy-0.1.0.dist-info/licenses/NOTICE +27 -0
- ofiqpy-0.1.0.dist-info/top_level.txt +1 -0
ofiqpy/__init__.py
ADDED
|
@@ -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__"]
|
ofiqpy/align.py
ADDED
|
@@ -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)
|
ofiqpy/batch.py
ADDED
|
@@ -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()
|
ofiqpy/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ofiqpy CLI — OFIQSampleApp-compatible: -i <file|dir> -o <out.csv>."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import cv2
|
|
9
|
+
|
|
10
|
+
from .config import OFIQConfig
|
|
11
|
+
from .measures.core import Measures
|
|
12
|
+
from .output import row, write_csv
|
|
13
|
+
from .pipeline import OFIQPipeline
|
|
14
|
+
|
|
15
|
+
EXTS = {".jpg", ".jpeg", ".png", ".bmp"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
ap = argparse.ArgumentParser(description="ofiqpy — faithful OFIQ Python port")
|
|
20
|
+
ap.add_argument("-i", "--input", required=True, help="image file or directory")
|
|
21
|
+
ap.add_argument("-o", "--output", required=True, help="output CSV")
|
|
22
|
+
args = ap.parse_args()
|
|
23
|
+
|
|
24
|
+
inp = Path(args.input)
|
|
25
|
+
imgs = ([inp] if inp.is_file()
|
|
26
|
+
else sorted(p for p in inp.rglob("*") if p.suffix.lower() in EXTS))
|
|
27
|
+
|
|
28
|
+
cfg = OFIQConfig()
|
|
29
|
+
pipe = OFIQPipeline(cfg)
|
|
30
|
+
meas = Measures(cfg)
|
|
31
|
+
|
|
32
|
+
rows = []
|
|
33
|
+
for img in imgs:
|
|
34
|
+
t0 = time.time()
|
|
35
|
+
bgr = cv2.imread(str(img))
|
|
36
|
+
try:
|
|
37
|
+
s = pipe.process(bgr)
|
|
38
|
+
res = meas.compute(s) if s.bbox is not None else {}
|
|
39
|
+
except Exception:
|
|
40
|
+
res = {}
|
|
41
|
+
rows.append(row(str(img), res, (time.time() - t0) * 1000))
|
|
42
|
+
write_csv(args.output, rows)
|
|
43
|
+
print(f"wrote {args.output} ({len(rows)} images)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|
ofiqpy/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""OFIQ config loader — parses ofiq_config.jaxn and resolves model paths.
|
|
2
|
+
|
|
3
|
+
Faithful port of OFIQ v1.1.0. Model files and config are reused directly from the
|
|
4
|
+
reference OFIQ-Project checkout so the port loads the *same* weights OFIQ uses.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# OFIQ's models + config are reused directly (they are separately licensed and NOT bundled).
|
|
13
|
+
# Point ofiqpy at an OFIQ checkout's data/ directory via the OFIQPY_OFIQ_DATA env var, e.g.:
|
|
14
|
+
# export OFIQPY_OFIQ_DATA=/path/to/OFIQ-Project/data
|
|
15
|
+
# Falls back to ./OFIQ-Project/data (relative to the current directory) if unset.
|
|
16
|
+
_DEFAULT_DATA = Path("OFIQ-Project/data")
|
|
17
|
+
OFIQ_DATA = Path(os.environ.get("OFIQPY_OFIQ_DATA", _DEFAULT_DATA))
|
|
18
|
+
OFIQ_CONFIG = OFIQ_DATA / "ofiq_config.jaxn"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_jaxn(text: str) -> str:
|
|
22
|
+
"""Strip // and /* */ comments and trailing commas from JAXN, respecting strings."""
|
|
23
|
+
out = []
|
|
24
|
+
i, n = 0, len(text)
|
|
25
|
+
in_str = False
|
|
26
|
+
while i < n:
|
|
27
|
+
c = text[i]
|
|
28
|
+
if in_str:
|
|
29
|
+
out.append(c)
|
|
30
|
+
if c == "\\" and i + 1 < n: # escape
|
|
31
|
+
out.append(text[i + 1])
|
|
32
|
+
i += 2
|
|
33
|
+
continue
|
|
34
|
+
if c == '"':
|
|
35
|
+
in_str = False
|
|
36
|
+
i += 1
|
|
37
|
+
continue
|
|
38
|
+
if c == '"':
|
|
39
|
+
in_str = True
|
|
40
|
+
out.append(c)
|
|
41
|
+
i += 1
|
|
42
|
+
continue
|
|
43
|
+
if c == "/" and i + 1 < n and text[i + 1] == "/": # line comment
|
|
44
|
+
while i < n and text[i] != "\n":
|
|
45
|
+
i += 1
|
|
46
|
+
continue
|
|
47
|
+
if c == "/" and i + 1 < n and text[i + 1] == "*": # block comment
|
|
48
|
+
i += 2
|
|
49
|
+
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
|
|
50
|
+
i += 1
|
|
51
|
+
i += 2
|
|
52
|
+
continue
|
|
53
|
+
out.append(c)
|
|
54
|
+
i += 1
|
|
55
|
+
s = "".join(out)
|
|
56
|
+
# remove trailing commas before } or ]
|
|
57
|
+
import re
|
|
58
|
+
|
|
59
|
+
s = re.sub(r",(\s*[}\]])", r"\1", s)
|
|
60
|
+
return s
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_config(path: Path = OFIQ_CONFIG) -> dict:
|
|
64
|
+
raw = Path(path).read_text()
|
|
65
|
+
data = json.loads(_strip_jaxn(raw))
|
|
66
|
+
return data["config"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OFIQConfig:
|
|
70
|
+
"""Parsed OFIQ config with model-path resolution against the OFIQ data root."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, path: Path = OFIQ_CONFIG, data_root: Path = OFIQ_DATA):
|
|
73
|
+
self.cfg = load_config(path)
|
|
74
|
+
self.data_root = Path(data_root)
|
|
75
|
+
self.params = self.cfg["params"]
|
|
76
|
+
|
|
77
|
+
def resolve(self, rel_path: str) -> Path:
|
|
78
|
+
p = self.data_root / rel_path
|
|
79
|
+
if not p.exists():
|
|
80
|
+
raise FileNotFoundError(f"OFIQ model not found: {p}")
|
|
81
|
+
return p
|
|
82
|
+
|
|
83
|
+
def measure(self, name: str) -> dict:
|
|
84
|
+
return self.params["measures"].get(name, {})
|
|
85
|
+
|
|
86
|
+
def sigmoid_params(self, measure: str) -> dict:
|
|
87
|
+
return self.measure(measure).get("Sigmoid", {})
|
|
88
|
+
|
|
89
|
+
def detector(self) -> dict:
|
|
90
|
+
return self.params["detector"]["ssd"]
|
|
91
|
+
|
|
92
|
+
def landmarks_model(self) -> Path:
|
|
93
|
+
return self.resolve(self.params["landmarks"]["ADNet"]["model_path"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
c = OFIQConfig()
|
|
98
|
+
print("detector:", c.detector())
|
|
99
|
+
print("ADNet:", c.landmarks_model())
|
|
100
|
+
print("UnifiedQualityScore sigmoid:", c.sigmoid_params("UnifiedQualityScore"))
|
|
101
|
+
print("measures listed:", len(c.cfg["measures"]))
|
|
File without changes
|
ofiqpy/detectors/ssd.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""SSD face detector — faithful port of opencv_ssd_face_detector.cpp.
|
|
2
|
+
|
|
3
|
+
Caffe model via OpenCV DNN (NOT onnxruntime): 300x300, BGR mean (104,117,123),
|
|
4
|
+
pad 20%, conf>=0.4, min rel face size 0.05, primary = largest area.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
import cv2
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _round(x: float) -> int:
|
|
15
|
+
return int(math.copysign(math.floor(abs(x) + 0.5), x))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SSDDetector:
|
|
19
|
+
def __init__(self, caffemodel, prototxt, confidence_thr=0.4,
|
|
20
|
+
min_rel_face_size=0.05, padding=0.2):
|
|
21
|
+
self.net = cv2.dnn.readNetFromCaffe(str(prototxt), str(caffemodel))
|
|
22
|
+
self.thr = confidence_thr
|
|
23
|
+
self.min_rel = min_rel_face_size
|
|
24
|
+
self.pad = padding
|
|
25
|
+
|
|
26
|
+
def detect(self, img: np.ndarray) -> list[tuple[int, int, int, int]]:
|
|
27
|
+
"""Return face boxes [(left, top, w, h), ...] in ORIGINAL px, largest first."""
|
|
28
|
+
H, W = img.shape[:2]
|
|
29
|
+
padH = int(W * self.pad) # static_cast truncation
|
|
30
|
+
padV = int(H * self.pad)
|
|
31
|
+
padded = cv2.copyMakeBorder(img, padV, padV, padH, padH,
|
|
32
|
+
cv2.BORDER_CONSTANT, value=(0, 0, 0))
|
|
33
|
+
Ph, Pw = padded.shape[:2]
|
|
34
|
+
blob = cv2.dnn.blobFromImage(padded, 1.0, (300, 300), (104, 117, 123),
|
|
35
|
+
swapRB=False, crop=False)
|
|
36
|
+
self.net.setInput(blob)
|
|
37
|
+
det = self.net.forward() # (1,1,N,7)
|
|
38
|
+
faces = []
|
|
39
|
+
for row in det[0, 0]:
|
|
40
|
+
conf, l, t, r, b = float(row[2]), float(row[3]), float(row[4]), float(row[5]), float(row[6])
|
|
41
|
+
if conf >= self.thr and l > 0 and t > 0 and r < 1 and b < 1 and (r - l) > self.min_rel:
|
|
42
|
+
left = _round(l * Pw) - padH
|
|
43
|
+
top = _round(t * Ph) - padV
|
|
44
|
+
width = _round((r - l) * Pw)
|
|
45
|
+
height = _round((b - t) * Ph)
|
|
46
|
+
faces.append((left, top, width, height))
|
|
47
|
+
faces.sort(key=lambda f: f[2] * f[3], reverse=True)
|
|
48
|
+
return faces
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""ADNet 98-point landmarks — faithful port of adnet_landmarks.cpp + utils.cpp squaring.
|
|
2
|
+
|
|
3
|
+
ONNX input 'input' [1,3,256,256] BGR planes, norm 2p/255-1; output '3209' [1,98,2];
|
|
4
|
+
denorm (v+1)/2*255; back-map to original px via square-box scale.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
import cv2
|
|
11
|
+
import numpy as np
|
|
12
|
+
import onnxruntime as ort
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _floor(x):
|
|
16
|
+
return int(math.floor(x))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ceil(x):
|
|
20
|
+
return int(math.ceil(x))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _round(x):
|
|
24
|
+
return int(math.copysign(math.floor(abs(x) + 0.5), x))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_square_bbox(left, top, w, h):
|
|
28
|
+
"""utils.cpp:109-167 — extend the shorter side symmetrically to a square."""
|
|
29
|
+
xtl, ytl, xbr, ybr = left, top, left + w, top + h
|
|
30
|
+
if w < h:
|
|
31
|
+
xl = _floor((xbr + xtl - h) / 2.0)
|
|
32
|
+
xr = _ceil((xbr + xtl + h) / 2.0)
|
|
33
|
+
return xl, ytl, xr - xl, ybr - ytl # (x, y, side, side)
|
|
34
|
+
else:
|
|
35
|
+
yt = _floor((ybr + ytl - w) / 2.0)
|
|
36
|
+
yb = _ceil((ybr + ytl + w) / 2.0)
|
|
37
|
+
return xtl, yt, xbr - xtl, yb - yt
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_square_with_padding(sq, img):
|
|
41
|
+
"""utils.cpp:50-107 — pad image if the square box escapes bounds."""
|
|
42
|
+
x, y, sw, sh = sq
|
|
43
|
+
H, W = img.shape[:2]
|
|
44
|
+
xbr, ybr = x + sw, y + sh
|
|
45
|
+
top = max(0, -y)
|
|
46
|
+
bottom = max(0, ybr - H + 1)
|
|
47
|
+
left = max(0, -x)
|
|
48
|
+
right = max(0, xbr - W + 1)
|
|
49
|
+
if top or bottom or left or right:
|
|
50
|
+
padded = cv2.copyMakeBorder(img, top, bottom, left, right,
|
|
51
|
+
cv2.BORDER_CONSTANT, value=(0, 0, 0))
|
|
52
|
+
else:
|
|
53
|
+
padded = img
|
|
54
|
+
tx, ty = left, top
|
|
55
|
+
sq_pad = (x + tx, y + ty, sw, sh)
|
|
56
|
+
return padded, sq_pad, (tx, ty)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ADNetLandmarker:
|
|
60
|
+
def __init__(self, onnx_path):
|
|
61
|
+
self.sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
|
|
62
|
+
self.out_name = "3209"
|
|
63
|
+
|
|
64
|
+
def extract(self, img: np.ndarray, primary_box) -> np.ndarray:
|
|
65
|
+
"""img BGR uint8, primary_box=(left,top,w,h). Returns (98,2) original-px coords."""
|
|
66
|
+
sq = make_square_bbox(*primary_box)
|
|
67
|
+
padded, sq_pad, (tx, ty) = make_square_with_padding(sq, img)
|
|
68
|
+
x, y, sw, sh = sq_pad
|
|
69
|
+
crop = padded[y:y + sh, x:x + sw]
|
|
70
|
+
if crop.shape[:2] != (256, 256):
|
|
71
|
+
crop = cv2.resize(crop, (256, 256), interpolation=cv2.INTER_LINEAR)
|
|
72
|
+
arr = crop.astype(np.float32) * (2.0 / 255.0) - 1.0 # [-1,1], BGR planes
|
|
73
|
+
blob = np.transpose(arr, (2, 0, 1))[None] # (1,3,256,256)
|
|
74
|
+
out = self.sess.run([self.out_name], {"input": blob})[0].reshape(-1)
|
|
75
|
+
out = (out + 1.0) / 2.0 * 255.0 # denorm to 256-space
|
|
76
|
+
scale = sq[3] / 256.0 # OFIQ uses square HEIGHT/256 (adnet_landmarks.cpp:313),
|
|
77
|
+
# not width — the floor/ceil square can be 1px non-square.
|
|
78
|
+
ox = sq_pad[0] - tx # original square xleft
|
|
79
|
+
oy = sq_pad[1] - ty
|
|
80
|
+
pts = np.empty((98, 2), np.float64)
|
|
81
|
+
for i in range(98):
|
|
82
|
+
pts[i, 0] = _round(out[2 * i] * scale + ox)
|
|
83
|
+
pts[i, 1] = _round(out[2 * i + 1] * scale + oy)
|
|
84
|
+
return pts
|
|
File without changes
|