feat(gateway): 接入42和72并为42/52/72启用可刷新令牌
This commit is contained in:
@@ -44,18 +44,18 @@ class FaceLandmarker:
|
||||
self._detector = vision.FaceLandmarker.create_from_options(options)
|
||||
self._mp = mp
|
||||
|
||||
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
|
||||
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
|
||||
def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None:
|
||||
"""Returns (468, 3), or (478, 3) with iris, normalized float32 points.
|
||||
|
||||
The task model produces 478 landmarks (468 face + 10 iris); we return
|
||||
only the first 468 to match the canonical FaceMesh topology used by
|
||||
the SDK's OBJ file.
|
||||
The task model produces 478 landmarks (468 face + 10 iris). By default
|
||||
only the first 468 match the SDK's canonical FaceMesh topology;
|
||||
include_iris=True retains all 478 for face-measurement calibration.
|
||||
"""
|
||||
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
|
||||
result = self._detector.detect(mp_image)
|
||||
if not result.face_landmarks:
|
||||
return None
|
||||
lm = result.face_landmarks[0][:468]
|
||||
lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468]
|
||||
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
|
||||
return arr
|
||||
|
||||
@@ -66,6 +66,25 @@ class FaceLandmarker:
|
||||
pass
|
||||
|
||||
|
||||
def to_landmark_list(landmarks):
|
||||
from mediapipe.framework.formats import landmark_pb2
|
||||
|
||||
result = landmark_pb2.NormalizedLandmarkList()
|
||||
for x, y, z in landmarks:
|
||||
result.landmark.add(x=float(x), y=float(y), z=float(z))
|
||||
return result
|
||||
|
||||
|
||||
_landmarker = None
|
||||
|
||||
|
||||
def get_landmarker() -> FaceLandmarker:
|
||||
global _landmarker
|
||||
if _landmarker is None:
|
||||
_landmarker = FaceLandmarker(static_image_mode=True)
|
||||
return _landmarker
|
||||
|
||||
|
||||
class SolutionsFaceLandmarker:
|
||||
"""CPU-oriented fallback using mediapipe.solutions.face_mesh."""
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Interface 52: upright face axis, then native-resolution reference framing."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json"
|
||||
|
||||
|
||||
def decode_portrait(raw):
|
||||
"""Respect phone EXIF orientation before detecting or transforming points."""
|
||||
try:
|
||||
with Image.open(BytesIO(raw)) as image:
|
||||
return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_profile():
|
||||
return json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def transform_points(points, matrix):
|
||||
return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2]
|
||||
|
||||
|
||||
def _rotation(landmarks, width, height):
|
||||
xy = landmarks[:, :2].astype(np.float64) * [width, height]
|
||||
delta = xy[152] - xy[9]
|
||||
length = float(np.linalg.norm(delta))
|
||||
if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0:
|
||||
raise ValueError("9号点与152号点无效,无法扶正人脸")
|
||||
down = delta / length
|
||||
rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64)
|
||||
return xy, rotation, length
|
||||
|
||||
|
||||
def _matrix(rotation, source_anchor, target_anchor):
|
||||
return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor))
|
||||
|
||||
|
||||
def crop_fits(matrix, source_size, output_size):
|
||||
w, h = output_size
|
||||
corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix))
|
||||
sw, sh = source_size
|
||||
return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all()
|
||||
and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all())
|
||||
|
||||
|
||||
def _inscribed_frame(rotation, source_size, aspect=None):
|
||||
"""Largest centred valid rectangle: no generated background or upscaling."""
|
||||
sw, sh = source_size
|
||||
ratio = sw / sh if aspect is None else aspect
|
||||
source_center = np.array([(sw-1)/2, (sh-1)/2])
|
||||
low, high = 2.0, min(float(sh), sw/ratio)
|
||||
for _ in range(48):
|
||||
h = (low+high)/2
|
||||
w = h*ratio
|
||||
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
|
||||
if crop_fits(matrix, source_size, (w, h)):
|
||||
low = h
|
||||
else:
|
||||
high = h
|
||||
if aspect == .75:
|
||||
h = max(4, int(low+1e-7)//4*4)
|
||||
w = h//4*3
|
||||
else:
|
||||
h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7))
|
||||
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
|
||||
if not crop_fits(matrix, source_size, (w, h)):
|
||||
raise ValueError("图片边缘空间不足,无法得到有效扶正画面")
|
||||
return matrix, (w, h)
|
||||
|
||||
|
||||
def _tall_portrait_frame(xy, rotation, source_size, profile):
|
||||
"""Largest native 3:4 frame, positioned to retain the entire face."""
|
||||
_, size = _inscribed_frame(rotation, source_size, aspect=.75)
|
||||
half = (np.array(size, np.float64)-1)/2
|
||||
extent = np.abs(rotation.T) @ half
|
||||
low, high = extent, np.array(source_size)-1-extent
|
||||
polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]),
|
||||
(high[0], high[1]), (low[0], high[1]))]
|
||||
face = xy[:468] @ rotation.T
|
||||
# The crop centre must satisfy both the source boundaries and the face
|
||||
# bounds. Clip the feasible polygon in double precision to avoid padding.
|
||||
face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half
|
||||
for axis in range(2):
|
||||
for normal, bound in ((rotation[axis], face_high[axis]),
|
||||
(-rotation[axis], -face_low[axis])):
|
||||
clipped = []
|
||||
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
|
||||
a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8)
|
||||
if (a <= 0) != (b <= 0):
|
||||
clipped.append(start+(end-start)*(a/(a-b)))
|
||||
if b <= 0:
|
||||
clipped.append(end)
|
||||
polygon = clipped
|
||||
if not polygon:
|
||||
raise ValueError("3:4裁切窗口无法保留完整人脸")
|
||||
target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]])
|
||||
preferred = xy[9]-rotation.T @ (target-half)
|
||||
# Euclidean projection keeps the brow as close as possible to its target.
|
||||
candidates = []
|
||||
if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all():
|
||||
upright = rotation @ preferred
|
||||
if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all():
|
||||
candidates.append(preferred)
|
||||
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
|
||||
edge = end-start
|
||||
length2 = float(edge @ edge)
|
||||
t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0
|
||||
candidates.append(start+t*edge)
|
||||
centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2)))
|
||||
matrix = _matrix(rotation, centre, half)
|
||||
if not crop_fits(matrix, source_size, size):
|
||||
raise ValueError("3:4裁切窗口超出原图有效范围")
|
||||
return matrix, size
|
||||
|
||||
|
||||
def _apply(photo, landmarks, matrix, size):
|
||||
h, w = photo.shape[:2]
|
||||
ow, oh = size
|
||||
moved = np.asarray(landmarks, np.float64).copy()
|
||||
moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh]
|
||||
# MediaPipe Z is measured in normalized image-width units.
|
||||
moved[:, 2] *= w/ow
|
||||
result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
|
||||
return result, moved
|
||||
|
||||
|
||||
def calibrate_reference(photo, landmarks):
|
||||
"""First straighten the actual reference; derive ratios from its final frame."""
|
||||
h, w = photo.shape[:2]
|
||||
_xy, rotation, length = _rotation(landmarks, w, h)
|
||||
matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75)
|
||||
image, moved = _apply(photo, landmarks, matrix, size)
|
||||
ow, oh = size
|
||||
b, c = moved[[9, 152], :2]*[ow, oh]
|
||||
profile = {
|
||||
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
|
||||
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
|
||||
"reference_image_url": "/static/framing_reference.png",
|
||||
"reference_size": {"width": ow, "height": oh},
|
||||
"source_size": {"width": w, "height": h},
|
||||
"brow_9": {"x": float(b[0]), "y": float(b[1])},
|
||||
"chin_152": {"x": float(c[0]), "y": float(c[1])},
|
||||
"face_axis_length_px": length,
|
||||
"brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh),
|
||||
"reference_width_per_face_length": ow/length,
|
||||
"reference_height_per_face_length": oh/length,
|
||||
"width_per_face_length": 2.5, "height_per_face_length": 3.0/.9,
|
||||
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
|
||||
"axis_x_error_px": float(abs(b[0]-c[0])),
|
||||
"source_to_reference": matrix.tolist(),
|
||||
"landmark_source": "source_landmarks_transformed_without_redetection",
|
||||
}
|
||||
return image, moved, profile
|
||||
|
||||
|
||||
def normalize_portrait(photo, landmarks, profile=None):
|
||||
profile = load_profile() if profile is None else profile
|
||||
h, w = photo.shape[:2]
|
||||
xy, rotation, length = _rotation(landmarks, w, h)
|
||||
# Round to an exact 3:4 frame without changing pixel scale.
|
||||
target_h = max(4, round(profile["height_per_face_length"]*length/4)*4)
|
||||
target_w = target_h//4*3
|
||||
matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w,
|
||||
profile["brow_y_ratio"]*target_h])
|
||||
cropped = crop_fits(matrix, (w, h), (target_w, target_h))
|
||||
aspect_cropped = False
|
||||
aspect_skip_reason = None
|
||||
if cropped:
|
||||
size = (target_w, target_h)
|
||||
elif h*3 > w*4:
|
||||
try:
|
||||
matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile)
|
||||
aspect_cropped = True
|
||||
except ValueError as ex:
|
||||
aspect_skip_reason = str(ex)
|
||||
matrix, size = _inscribed_frame(rotation, (w, h))
|
||||
else:
|
||||
matrix, size = _inscribed_frame(rotation, (w, h))
|
||||
image, moved = _apply(photo, landmarks, matrix, size)
|
||||
ow, oh = size
|
||||
axis = moved[[9, 152], :2]*[ow, oh]
|
||||
if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all()
|
||||
and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()):
|
||||
raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点")
|
||||
metadata = {
|
||||
"enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped,
|
||||
"aspect_crop_skip_reason": aspect_skip_reason,
|
||||
"status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"),
|
||||
"skip_reason": None if cropped else "insufficient_space",
|
||||
"message": ("已扶正并按参考图裁切" if cropped else
|
||||
"原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else
|
||||
"3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else
|
||||
"空间不足,仅扶正,未按参考比例裁切"),
|
||||
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
|
||||
"source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh},
|
||||
"requested_crop_size": {"width": target_w, "height": target_h},
|
||||
"face_axis_length_px": length,
|
||||
"height_per_face_length": profile["height_per_face_length"],
|
||||
"width_per_face_length": profile["height_per_face_length"]*.75,
|
||||
"source_to_processed": matrix.tolist(),
|
||||
"processed_to_source": cv2.invertAffineTransform(matrix).tolist(),
|
||||
"axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])),
|
||||
"profile_version": profile["version"],
|
||||
"reference_image_url": profile["reference_image_url"],
|
||||
}
|
||||
return image, moved, metadata
|
||||
|
||||
|
||||
class NoPortraitFaceError(ValueError):
|
||||
"""The portrait landmarker could not find a face."""
|
||||
|
||||
|
||||
def prepare_portrait(photo):
|
||||
"""Shared interface-52 preprocessing, including the same landmark model/profile."""
|
||||
from .face_landmarks import get_landmarker
|
||||
landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True)
|
||||
if landmarks is None:
|
||||
raise NoPortraitFaceError("未检出人脸")
|
||||
return normalize_portrait(photo, landmarks)
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
|
||||
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
|
||||
"reference_image_url": "/static/framing_reference.png",
|
||||
"reference_size": {
|
||||
"width": 786,
|
||||
"height": 1048
|
||||
},
|
||||
"source_size": {
|
||||
"width": 792,
|
||||
"height": 1056
|
||||
},
|
||||
"brow_9": {
|
||||
"x": 385.1878369059212,
|
||||
"y": 360.65437126595765
|
||||
},
|
||||
"chin_152": {
|
||||
"x": 385.18783690592113,
|
||||
"y": 725.8251047982238
|
||||
},
|
||||
"face_axis_length_px": 365.1707335322661,
|
||||
"brow_x_ratio": 0.4900608612034621,
|
||||
"brow_y_ratio": 0.3441358504446161,
|
||||
"reference_width_per_face_length": 2.152417835890317,
|
||||
"reference_height_per_face_length": 2.8698904478537566,
|
||||
"width_per_face_length": 2.5,
|
||||
"height_per_face_length": 3.333333333333333,
|
||||
"rotation_degrees": 0.2596248408989324,
|
||||
"axis_x_error_px": 5.684341886080802e-14,
|
||||
"source_to_reference": [
|
||||
[
|
||||
0.9999897336401403,
|
||||
0.004531292786961665,
|
||||
-5.386196599797756
|
||||
],
|
||||
[
|
||||
-0.004531292786961665,
|
||||
0.9999897336401403,
|
||||
-2.202458197930582
|
||||
]
|
||||
],
|
||||
"landmark_source": "source_landmarks_transformed_without_redetection"
|
||||
}
|
||||
Reference in New Issue
Block a user