Skip to content

Commit 293d265

Browse files
Merge pull request #15 from DocsaidLab/feat/add_face_attributes
Add face attributes and fix jsonable by update capybara-docsaid to 0.12.0
2 parents 5e63fba + 0d340aa commit 293d265

File tree

7 files changed

+607
-9
lines changed

7 files changed

+607
-9
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
- name: Bump package version
3737
if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' }}
3838
run: |
39-
sed -i "s/__version__ = '[0-9]\+\(\.[0-9]\+\)\{1,2\}\(rc[0-9]\+\|[ab][0-9]\+\)\?'/__version__ = '${{ github.event.inputs.version_tag }}'/g" pyface/__init__.py
39+
sed -E -i "s/__version__[[:space:]]*=[[:space:]]*['\"][^'\"]+['\"]/__version__ = '${{ github.event.inputs.version_tag }}'/g" pyface/__init__.py
4040
4141
- name: Bump package version
4242
if: ${{ matrix.os == 'macos-13' || matrix.os == 'macos-latest' }}

pyface/face_service.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ def _fill_results_to_faces_list(
9494
for face in faces:
9595
if face.attribute is None:
9696
face.attribute = Attribute()
97-
9897
if gender_results is not None:
9998
face.attribute.gender = gender_results[i]["gender"]
10099
if lmk_results is not None:
@@ -116,7 +115,7 @@ def _fill_results_to_faces_list(
116115
dep_result = dep_results[i]
117116
face.tddfa = TDDFA(
118117
param=dep_result["param"],
119-
lmk68pt=dep_result["lmk3d68pt"],
118+
lmk3d68pt=dep_result["lmk3d68pt"],
120119
depth_img=dep_result["depth_img"],
121120
yaw=dep_result["pose_degree"][0],
122121
roll=dep_result["pose_degree"][1],

pyface/object.py

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import capybara as cb
55
import cv2
66
import numpy as np
7-
from pybase64 import b64encode
87

98
from .components.enums import FacePose, FakeType
109

@@ -30,19 +29,41 @@ class Eye(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
3029
is_open: Optional[bool] = field(default=None)
3130
score: Optional[float] = field(default=None)
3231

32+
@classmethod
33+
def from_json(cls, data) -> "Eye":
34+
return cls(
35+
is_open=data.get("is_open"),
36+
score=data.get("score"),
37+
)
38+
3339

3440
@dataclass()
3541
class Mouth(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
3642
is_open: Optional[bool] = field(default=None)
3743
score: Optional[float] = field(default=None)
3844

45+
@classmethod
46+
def from_json(cls, data) -> "Mouth":
47+
return cls(
48+
is_open=data.get("is_open"),
49+
score=data.get("score"),
50+
)
51+
3952

4053
@dataclass()
4154
class WhetherOrNot(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
4255
is_true: Optional[bool] = field(default=None)
4356
value: Optional[float] = field(default=None)
4457
threshold: Optional[float] = field(default=None)
4558

59+
@classmethod
60+
def from_json(cls, data) -> "WhetherOrNot":
61+
return cls(
62+
is_true=data.get("is_true"),
63+
value=data.get("value"),
64+
threshold=data.get("threshold"),
65+
)
66+
4667

4768
@dataclass()
4869
class Liveness(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
@@ -51,29 +72,64 @@ class Liveness(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
5172
threshold: Optional[Union[float, np.number]] = field(default=None)
5273
fake_type: Optional[FakeType] = field(default=None)
5374

75+
@classmethod
76+
def from_json(cls, data):
77+
return cls(
78+
is_true=data.get("is_true"),
79+
value=data.get("value"),
80+
threshold=data.get("threshold"),
81+
fake_type=FakeType(data["fake_type"]) if data.get("fake_type") is not None else None,
82+
)
83+
5484

5585
@dataclass()
5686
class TDDFA(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
5787
param: Optional[np.ndarray] = field(default=None)
58-
lmk68pt: Optional[np.ndarray] = field(default=None)
88+
lmk3d68pt: Optional[np.ndarray] = field(default=None)
5989
depth_img: Optional[np.ndarray] = field(default=None)
6090
yaw: Optional[float] = field(default=None)
6191
roll: Optional[float] = field(default=None)
6292
pitch: Optional[float] = field(default=None)
6393

94+
@classmethod
95+
def from_json(cls, data) -> "TDDFA":
96+
return cls(
97+
param=cb.b64str_to_npy(data["param"]) if data.get("param") is not None else None,
98+
lmk3d68pt=np.array(data["lmk3d68pt"]) if data.get("lmk3d68pt") is not None else None,
99+
depth_img=cb.b64str_to_img(data["depth_img"]) if data.get("depth_img") is not None else None,
100+
yaw=data.get("yaw"),
101+
roll=data.get("roll"),
102+
pitch=data.get("pitch"),
103+
)
104+
64105

65106
@dataclass()
66107
class Encode(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
67108
vector: Optional[np.ndarray] = field(default=None)
68109
version: Optional[str] = field(default=None)
69110

111+
@classmethod
112+
def from_json(cls, data) -> "Encode":
113+
return cls(
114+
vector=cb.b64str_to_npy(data["vector"]) if data.get("vector") is not None else None,
115+
version=data.get("version"),
116+
)
117+
70118

71119
@dataclass()
72120
class Who(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
73121
name: Optional[str] = field(default="?")
74122
confidence: Optional[float] = field(default=None)
75123
recognized_level: Optional[int] = field(default=None)
76124

125+
@classmethod
126+
def from_json(cls, data) -> "Who":
127+
return cls(
128+
name=data.get("name", "?"),
129+
confidence=data.get("confidence"),
130+
recognized_level=data.get("recognized_level"),
131+
)
132+
77133

78134
@dataclass()
79135
class Attribute(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
@@ -85,6 +141,18 @@ class Attribute(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
85141
right_eye: Optional[Eye] = field(default=None)
86142
mouth: Optional[Mouth] = field(default=None)
87143

144+
@classmethod
145+
def from_json(cls, data) -> "Attribute":
146+
return cls(
147+
age=data.get("age"),
148+
gender=data.get("gender"),
149+
race=data.get("race"),
150+
pose=FacePose.obj_to_enum(data["pose"]) if data.get("pose") is not None else None,
151+
left_eye=Eye.from_json(data["left_eye"]) if data.get("left_eye") is not None else None,
152+
right_eye=Eye.from_json(data["right_eye"]) if data.get("right_eye") is not None else None,
153+
mouth=Mouth.from_json(data["mouth"]) if data.get("mouth") is not None else None,
154+
)
155+
88156

89157
@dataclass()
90158
class Face(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
@@ -98,16 +166,34 @@ class Face(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
98166
lmk106pt: Optional[cb.Keypoints] = field(default=None)
99167
liveness: Optional[Liveness] = field(default=None)
100168
attribute: Optional[Attribute] = field(default=None)
169+
# assign jsonable functions for some fields
101170
jsonable_func = {
102-
"vector": lambda x: b64encode(x.astype("float32").tobytes()).decode("utf-8") if x is not None else None,
171+
"vector": lambda x: cb.npy_to_b64str(x) if x is not None else None,
103172
"norm_img": lambda x: cb.img_to_b64str(x, cb.IMGTYP.PNG) if x is not None else None,
173+
"depth_img": lambda x: cb.img_to_b64str(x, cb.IMGTYP.PNG) if x is not None else None,
174+
"param": lambda x: cb.npy_to_b64str(x) if x is not None else None,
104175
}
105176
# pose: Optional[FacePose] = field(default=None)
106177
# blur: Optional[WhetherOrNot] = field(default=None)
107178
# occlusion: Optional[Occlusion] = field(default=None)
108-
# lmk68pt: Optional[cb.Keypoints] = field(default=None)
179+
# lmk3d68pt: Optional[cb.Keypoints] = field(default=None)
109180
# analysis_infos: Optional[dict] = field(default=None)
110181

182+
@classmethod
183+
def from_json(cls, data: dict) -> "Face":
184+
return cls(
185+
box=cb.Box(data["box"]),
186+
score=data["score"],
187+
lmk5pt=cb.Keypoints(np.array(data["lmk5pt"])) if data.get("lmk5pt") is not None else None,
188+
norm_img=cb.b64str_to_img(data["norm_img"]) if data.get("norm_img") is not None else None,
189+
tddfa=TDDFA.from_json(data["tddfa"]) if data.get("tddfa") is not None else None,
190+
encoding=Encode.from_json(data["encoding"]) if data.get("encoding") is not None else None,
191+
who=Who.from_json(data["who"]) if data.get("who") is not None else None,
192+
lmk106pt=cb.Keypoints(np.array(data["lmk106pt"])) if data.get("lmk106pt") is not None else None,
193+
liveness=Liveness.from_json(data["liveness"]) if data.get("liveness") is not None else None,
194+
attribute=Attribute.from_json(data["attribute"]) if data.get("attribute") is not None else None,
195+
)
196+
111197

112198
ATTR_NAMES = [f.name for f in fields(Face)]
113199

@@ -252,12 +338,19 @@ def gen_info_img(self, mosaic_face: bool = False):
252338
return img
253339

254340
def be_jsonable(self):
255-
raw_image = cb.img_to_b64(self.raw_image, cb.IMGTYP.PNG).decode("utf-8") if self.raw_image is not None else None
341+
raw_image = cb.img_to_b64str(self.raw_image, cb.IMGTYP.PNG) if self.raw_image is not None else None
256342
return {
257343
"raw_image": raw_image,
258344
"faces": [x.be_jsonable() for x in self.faces],
259345
}
260346

347+
@classmethod
348+
def from_json(cls, data: dict) -> "Faces":
349+
return cls(
350+
raw_image=cb.b64str_to_img(data["raw_image"]) if data.get("raw_image") is not None else None,
351+
faces=[Face.from_json(x) for x in data.get("faces", [])],
352+
)
353+
261354

262355
# def _remove_none_in_jsonized_face(jsonized_face: dict) -> dict:
263356
# outs = {}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ classifiers = [
3131
dependencies = [
3232
"numpy>=2", # 執行期也支援 NumPy 2
3333
"scikit-image",
34-
"capybara-docsaid",
34+
"capybara-docsaid>=0.12.0",
3535
"huggingface_hub"
3636
]
3737

tests/resources/answer/EmmaWatson1_jsonable.json

Lines changed: 230 additions & 0 deletions
Large diffs are not rendered by default.

tests/resources/answer/JohnnyDepp1_jsonable.json

Lines changed: 230 additions & 0 deletions
Large diffs are not rendered by default.

tests/test_jsonable.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import capybara as cb
2+
import pytest
3+
4+
import pyface as pf
5+
6+
RESOURCE_DIR = cb.get_curdir(__file__) / "resources"
7+
ANSWER_DIR = RESOURCE_DIR / "answer"
8+
ANSWER_DIR.mkdir(exist_ok=True, parents=True)
9+
10+
TEST_DATA = [
11+
{
12+
"img_fpath": RESOURCE_DIR / "EmmaWatson1.jpg",
13+
"json_fpath": ANSWER_DIR / "EmmaWatson1_jsonable.json",
14+
},
15+
{
16+
"img_fpath": RESOURCE_DIR / "JohnnyDepp1.jpg",
17+
"json_fpath": ANSWER_DIR / "JohnnyDepp1_jsonable.json",
18+
},
19+
]
20+
21+
22+
@pytest.mark.parametrize("data", TEST_DATA)
23+
def test_jsonable(data):
24+
expected = cb.load_json(data["json_fpath"])
25+
faces = pf.Faces.from_json(expected)
26+
output = faces.be_jsonable()
27+
assert output == expected
28+
29+
30+
def gen_data(data):
31+
face_service = pf.FaceService(
32+
enable_depth=True,
33+
enable_landmark=True,
34+
enable_recognition=True,
35+
enable_gender=True,
36+
face_bank=RESOURCE_DIR / "face_bank",
37+
)
38+
img = cb.imread(data["img_fpath"])
39+
faces = face_service([img], do_1n=True)[0]
40+
output = faces.be_jsonable()
41+
cb.dump_json(output, data["expected"])
42+
43+
44+
if __name__ == "__main__":
45+
for data in TEST_DATA:
46+
gen_data(data)

0 commit comments

Comments
 (0)