Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- name: Bump package version
if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' }}
run: |
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
sed -E -i "s/__version__[[:space:]]*=[[:space:]]*['\"][^'\"]+['\"]/__version__ = '${{ github.event.inputs.version_tag }}'/g" pyface/__init__.py

- name: Bump package version
if: ${{ matrix.os == 'macos-13' || matrix.os == 'macos-latest' }}
Expand Down
3 changes: 1 addition & 2 deletions pyface/face_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ def _fill_results_to_faces_list(
for face in faces:
if face.attribute is None:
face.attribute = Attribute()

if gender_results is not None:
face.attribute.gender = gender_results[i]["gender"]
if lmk_results is not None:
Expand All @@ -116,7 +115,7 @@ def _fill_results_to_faces_list(
dep_result = dep_results[i]
face.tddfa = TDDFA(
param=dep_result["param"],
lmk68pt=dep_result["lmk3d68pt"],
lmk3d68pt=dep_result["lmk3d68pt"],
depth_img=dep_result["depth_img"],
yaw=dep_result["pose_degree"][0],
roll=dep_result["pose_degree"][1],
Expand Down
103 changes: 98 additions & 5 deletions pyface/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import capybara as cb
import cv2
import numpy as np
from pybase64 import b64encode

from .components.enums import FacePose, FakeType

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

@classmethod
def from_json(cls, data) -> "Eye":
return cls(
is_open=data.get("is_open"),
score=data.get("score"),
)


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

@classmethod
def from_json(cls, data) -> "Mouth":
return cls(
is_open=data.get("is_open"),
score=data.get("score"),
)


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

@classmethod
def from_json(cls, data) -> "WhetherOrNot":
return cls(
is_true=data.get("is_true"),
value=data.get("value"),
threshold=data.get("threshold"),
)


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

@classmethod
def from_json(cls, data):
return cls(
is_true=data.get("is_true"),
value=data.get("value"),
threshold=data.get("threshold"),
fake_type=FakeType(data["fake_type"]) if data.get("fake_type") is not None else None,
)


@dataclass()
class TDDFA(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
param: Optional[np.ndarray] = field(default=None)
lmk68pt: Optional[np.ndarray] = field(default=None)
lmk3d68pt: Optional[np.ndarray] = field(default=None)
depth_img: Optional[np.ndarray] = field(default=None)
yaw: Optional[float] = field(default=None)
roll: Optional[float] = field(default=None)
pitch: Optional[float] = field(default=None)

@classmethod
def from_json(cls, data) -> "TDDFA":
return cls(
param=cb.b64str_to_npy(data["param"]) if data.get("param") is not None else None,
lmk3d68pt=np.array(data["lmk3d68pt"]) if data.get("lmk3d68pt") is not None else None,
depth_img=cb.b64str_to_img(data["depth_img"]) if data.get("depth_img") is not None else None,
yaw=data.get("yaw"),
roll=data.get("roll"),
pitch=data.get("pitch"),
)


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

@classmethod
def from_json(cls, data) -> "Encode":
return cls(
vector=cb.b64str_to_npy(data["vector"]) if data.get("vector") is not None else None,
version=data.get("version"),
)


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

@classmethod
def from_json(cls, data) -> "Who":
return cls(
name=data.get("name", "?"),
confidence=data.get("confidence"),
recognized_level=data.get("recognized_level"),
)


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

@classmethod
def from_json(cls, data) -> "Attribute":
return cls(
age=data.get("age"),
gender=data.get("gender"),
race=data.get("race"),
pose=FacePose.obj_to_enum(data["pose"]) if data.get("pose") is not None else None,
left_eye=Eye.from_json(data["left_eye"]) if data.get("left_eye") is not None else None,
right_eye=Eye.from_json(data["right_eye"]) if data.get("right_eye") is not None else None,
mouth=Mouth.from_json(data["mouth"]) if data.get("mouth") is not None else None,
)


@dataclass()
class Face(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
Expand All @@ -98,16 +166,34 @@ class Face(cb.DataclassToJsonMixin, cb.DataclassCopyMixin):
lmk106pt: Optional[cb.Keypoints] = field(default=None)
liveness: Optional[Liveness] = field(default=None)
attribute: Optional[Attribute] = field(default=None)
# assign jsonable functions for some fields
jsonable_func = {
"vector": lambda x: b64encode(x.astype("float32").tobytes()).decode("utf-8") if x is not None else None,
"vector": lambda x: cb.npy_to_b64str(x) if x is not None else None,
"norm_img": lambda x: cb.img_to_b64str(x, cb.IMGTYP.PNG) if x is not None else None,
"depth_img": lambda x: cb.img_to_b64str(x, cb.IMGTYP.PNG) if x is not None else None,
"param": lambda x: cb.npy_to_b64str(x) if x is not None else None,
}
# pose: Optional[FacePose] = field(default=None)
# blur: Optional[WhetherOrNot] = field(default=None)
# occlusion: Optional[Occlusion] = field(default=None)
# lmk68pt: Optional[cb.Keypoints] = field(default=None)
# lmk3d68pt: Optional[cb.Keypoints] = field(default=None)
# analysis_infos: Optional[dict] = field(default=None)

@classmethod
def from_json(cls, data: dict) -> "Face":
return cls(
box=cb.Box(data["box"]),
score=data["score"],
lmk5pt=cb.Keypoints(np.array(data["lmk5pt"])) if data.get("lmk5pt") is not None else None,
norm_img=cb.b64str_to_img(data["norm_img"]) if data.get("norm_img") is not None else None,
tddfa=TDDFA.from_json(data["tddfa"]) if data.get("tddfa") is not None else None,
encoding=Encode.from_json(data["encoding"]) if data.get("encoding") is not None else None,
who=Who.from_json(data["who"]) if data.get("who") is not None else None,
lmk106pt=cb.Keypoints(np.array(data["lmk106pt"])) if data.get("lmk106pt") is not None else None,
liveness=Liveness.from_json(data["liveness"]) if data.get("liveness") is not None else None,
attribute=Attribute.from_json(data["attribute"]) if data.get("attribute") is not None else None,
)


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

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

def be_jsonable(self):
raw_image = cb.img_to_b64(self.raw_image, cb.IMGTYP.PNG).decode("utf-8") if self.raw_image is not None else None
raw_image = cb.img_to_b64str(self.raw_image, cb.IMGTYP.PNG) if self.raw_image is not None else None
return {
"raw_image": raw_image,
"faces": [x.be_jsonable() for x in self.faces],
}

@classmethod
def from_json(cls, data: dict) -> "Faces":
return cls(
raw_image=cb.b64str_to_img(data["raw_image"]) if data.get("raw_image") is not None else None,
faces=[Face.from_json(x) for x in data.get("faces", [])],
)


# def _remove_none_in_jsonized_face(jsonized_face: dict) -> dict:
# outs = {}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ classifiers = [
dependencies = [
"numpy>=2", # 執行期也支援 NumPy 2
"scikit-image",
"capybara-docsaid",
"capybara-docsaid>=0.12.0",
"huggingface_hub"
]

Expand Down
230 changes: 230 additions & 0 deletions tests/resources/answer/EmmaWatson1_jsonable.json

Large diffs are not rendered by default.

230 changes: 230 additions & 0 deletions tests/resources/answer/JohnnyDepp1_jsonable.json

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions tests/test_jsonable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import capybara as cb
import pytest

import pyface as pf

RESOURCE_DIR = cb.get_curdir(__file__) / "resources"
ANSWER_DIR = RESOURCE_DIR / "answer"
ANSWER_DIR.mkdir(exist_ok=True, parents=True)

TEST_DATA = [
{
"img_fpath": RESOURCE_DIR / "EmmaWatson1.jpg",
"json_fpath": ANSWER_DIR / "EmmaWatson1_jsonable.json",
},
{
"img_fpath": RESOURCE_DIR / "JohnnyDepp1.jpg",
"json_fpath": ANSWER_DIR / "JohnnyDepp1_jsonable.json",
},
]


@pytest.mark.parametrize("data", TEST_DATA)
def test_jsonable(data):
expected = cb.load_json(data["json_fpath"])
faces = pf.Faces.from_json(expected)
output = faces.be_jsonable()
assert output == expected


def gen_data(data):
face_service = pf.FaceService(
enable_depth=True,
enable_landmark=True,
enable_recognition=True,
enable_gender=True,
face_bank=RESOURCE_DIR / "face_bank",
)
img = cb.imread(data["img_fpath"])
faces = face_service([img], do_1n=True)[0]
output = faces.be_jsonable()
cb.dump_json(output, data["expected"])


if __name__ == "__main__":
for data in TEST_DATA:
gen_data(data)
Loading