Hi @theetails
(and @Marc_Jasner, since this is your thread) — I was stuck on the same thing some time ago, so I think I can help a little bit.
Short version: the [62] vector is not landmarks, and it is not [shape | exp] either. It is [12 pose | 40 shape | 10 exp], and the numbers come out normalised. So two steps are missing in the rewrite: de-normalise the 62 values first, then project the 3D vertices with the 12 pose numbers (a 3×4 R | offset matrix). Without the pose block and the projection there is no way to get pixels out of it.
The two things ChatGPT told you about are actually two different files, and one of them you don’t need to look for:
- The rescale mean/std (62-d) — this is what de-normalises the raw output. It is hardcoded in Hailo’s own model-zoo postproc as
TDDFA_RESCALE_PARAMS (hailo_model_zoo/core/postprocessing/face_landmarks_3d_postprocessing.py). I copy it below so you don’t have to search for it.
- The BFM basis
bfm_noneck_v3.pkl (the mean_face = u and the pca_basis = w_shp/w_exp). This one is license-gated: you sign the 3DDFA license and download it manually (Hailo’s code raises exactly that message). The .npy files you were loading are not the same basis, that is why the reconstruction looked wrong.
Here is a TDDFAProcessor that follows the model-zoo postproc step by step, you can paste it as it is:
import numpy as np, pickle
# 62-d rescale params, copied verbatim from Hailo's model-zoo postproc
# (face_landmarks_3d_postprocessing.py -> TDDFA_RESCALE_PARAMS)
PARAM_MEAN = np.array([3.4926363e-04,2.5279013e-07,-6.8751979e-07,6.0167957e01,-6.2955132e-07,5.7572004e-04,-5.0853912e-05,7.4278198e01,5.4009172e-07,6.5741384e-05,3.4420125e-04,-6.6671577e01,-3.4660369e05,-6.7468234e04,4.6822266e04,-1.5262047e04,4.3505889e03,-5.4261453e04,-1.8328033e04,-1.5843289e03,-8.4566344e04,3.8359607e03,-2.0811361e04,3.8094930e04,-1.9967855e04,-9.2413701e03,-1.9600715e04,1.3168090e04,-5.2591440e03,1.8486478e03,-1.3030662e04,-2.4355562e03,-2.2542065e03,-1.4396562e04,-6.1763291e03,-2.5621920e04,2.2639447e02,-6.3261235e03,-1.0867251e04,8.6846509e02,-5.8311479e03,2.7051238e03,-3.6294177e03,2.0439901e03,-2.4466162e03,3.6586970e03,-7.6459897e03,-6.6744526e03,1.1638839e02,7.1855972e03,-1.4294868e03,2.6173665e03,-1.2070955e00,6.6907924e-01,-1.7760828e-01,5.6725528e-02,3.9678156e-02,-1.3586316e-01,-9.2239931e-02,-1.7260718e-01,-1.5804484e-02,-1.4168486e-01], dtype=np.float32)
PARAM_STD = np.array([1.76321526e-04,6.73794348e-05,4.47084894e-04,2.65502319e01,1.23137695e-04,4.49302170e-05,7.92367064e-05,6.98256302e00,4.35044407e-04,1.23148900e-04,1.74000015e-04,2.08030396e01,5.75421125e05,2.77649062e05,2.58336844e05,2.55163125e05,1.50994375e05,1.60086109e05,1.11277305e05,9.73117812e04,1.17198453e05,8.93173672e04,8.84935547e04,7.22299297e04,7.10802109e04,5.00139531e04,5.59685820e04,4.75255039e04,4.95150664e04,3.81614805e04,4.48720586e04,4.62732383e04,3.81167695e04,2.81911621e04,3.21914375e04,3.60061719e04,3.25598926e04,2.55511172e04,2.42675098e04,2.75213984e04,2.31665312e04,2.11015762e04,1.94123242e04,1.94522031e04,1.74549844e04,2.25376230e04,1.61742812e04,1.46716406e04,1.51156885e04,1.38700732e04,1.37463125e04,1.26631338e04,1.58708346e00,1.50770092e00,5.88135779e-01,5.88974476e-01,2.13278517e-01,2.63020128e-01,2.79642940e-01,3.80302161e-01,1.61628410e-01,2.55969286e-01], dtype=np.float32)
class TDDFAProcessor:
def __init__(self, scale, zero_point, bfm_path="bfm_noneck_v3.pkl"):
self.scale, self.zp = scale, zero_point
bfm = pickle.load(open(bfm_path, "rb"))
u = bfm["u"].astype(np.float32) # (3N, 1)
w_shp = bfm["w_shp"].astype(np.float32)[:, :40] # keep 40 shape dims
w_exp = bfm["w_exp"].astype(np.float32)[:, :10] # keep 10 exp dims
kpt = bfm["keypoints"].astype(int) # the 68-landmark subset (204 idx)
self.u_base = u[kpt].reshape(-1, 1) # (204, 1)
self.w_shp_base = w_shp[kpt] # (204, 40)
self.w_exp_base = w_exp[kpt] # (204, 10)
def __call__(self, out62_uint8, roi_box, size=120):
# 1) int8 -> float, your own output quant (scale / zero_point)
p = (np.asarray(out62_uint8, np.float32).ravel()[:62] - self.zp) * self.scale
# 2) de-normalise -- THIS is the step that was missing
p = p * PARAM_STD + PARAM_MEAN
# 3) split: 12 pose (R | offset) | 40 shape | 10 exp (NOT [shape | exp])
R = p[:12].reshape(3, 4)[:, :3]
offset = p[:12].reshape(3, 4)[:, 3:]
shp = p[12:52].reshape(-1, 1)
exp = p[52:62].reshape(-1, 1)
# 4) reconstruct the 68 vertices and project them
pts = R @ (self.u_base + self.w_shp_base @ shp + self.w_exp_base @ exp).reshape(3, -1, order="F") + offset
# 5) similar_transform: flip y and map the 120 crop back to roi_box (image coords)
pts[0] -= 1; pts[2] -= 1; pts[1] = size - pts[1]
sx, sy, ex, ey = roi_box
pts[0] = pts[0] * (ex - sx) / size + sx
pts[1] = pts[1] * (ey - sy) / size + sy
return pts[:2].T # (68, 2) landmarks in image coordinates
roi_box is the face box in the same coordinate frame you draw on — in your script that is the SCRFD box [x1, y1, x2, y2] in the 640 image, not [0,0,w,h]. Also, 3DDFA works better when the crop is square and a bit padded, so making the SCRFD box square and expanding it a little before the 120 resize gives cleaner landmarks.
Where the old code went wrong, concretely: it sliced raw[:num_shape + num_exp] = raw[:50] and read it as [40 shape | 10 exp], but the first 12 numbers are the pose, so everything was shifted by 12. There was no de-normalisation, and project_to_2d scaled the vertices by the box w/h as if they were in [0,1] — but after the projection they are already in 120-crop pixels, so what you need is the flip-y + similar_transform above, not a w/h scale.
On the outputs: the HEF gives a single [62] tensor. That [120,120,3] you were seeing is not landmarks (it looks like the input frame coming back through the async wrapper) — just decode the [62].
I ran the model-zoo ONNX (tddfa_mobilenet_v1_120x120.onnx) with onnxruntime to check this: the output is one [1,62] vector and it comes out normalised, the first 12 raw values look like [0.0, 2.06, -0.15, 1.12, ...] and say nothing by themselves. After p*STD + MEAN the pose block becomes a reasonable affine — R at ~5e-4 scale and offset ≈ (90, 59) inside the 120 crop. The decode is host-side maths, so it is the same whether the 62 numbers come from the ONNX or from your dequantised HEF output.
For eye open/closed you mostly need the 6 eye points per side (indices 36–41 and 42–47 in the 68-point layout), so the EAR ratio on those is enough once the landmarks are in image coordinates. Hope this helps, and good luck with the baby monitor ..