save code

This commit is contained in:
xsl
2026-04-26 00:12:56 +08:00
parent 50680ee854
commit c5ec7c7dc5
22 changed files with 1170 additions and 216 deletions
+1 -2
View File
@@ -13,7 +13,7 @@ android {
compileSdk 36
defaultConfig {
//applicationId "com.hmwl.face_sdk"
minSdk 30
minSdk 29
targetSdk 36
versionCode 1
versionName "1.0"
@@ -117,7 +117,6 @@ dependencies {
//implementation fileTree(dir: 'libs/tasks-vision', include: ['*.jar'])
//implementation fileTree(dir: 'libs/tasks-core', include: ['*.jar'])
def camerax_version = '1.4.2'
implementation "androidx.camera:camera-core:$camerax_version"
implementation "androidx.camera:camera-camera2:$camerax_version"
+7 -1
View File
@@ -16,7 +16,13 @@
<activity
android:name=".FaceActivity"
android:exported="true"
tools:ignore="MissingClass"> <!-- 如果IDE报类找不到,可以添加这个 -->
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|smallestScreenSize"
tools:ignore="MissingClass,LockedOrientationActivity">
<!-- 锁定竖屏:SDK 渲染按"屏幕中央正方形画布 + 上下黑边 letterbox"
的方式适配任意分辨率,目前只验证过竖屏。
configChanges 全包:避免软键盘/导航栏变化触发 Activity 重建——SDK
自己会通过 onWindowLost/onWindowInit 重建 swapchain,性能更好。 -->
<!-- 重要:移除 MAIN/LAUNCHER intent-filter -->
<!-- 让使用库的应用来决定哪个是主Activity -->
+29 -24
View File
@@ -1,41 +1,46 @@
#version 450
// 输入变量(与顶点着色器输出对应)
layout(location = 0) in vec2 inTexCoord;
// 输出颜色
layout(location = 0) out vec4 outColor;
// 纹理采样器
layout(binding = 0) uniform sampler2D texSampler;
// ★ PushConstants 必须和 bg.vert / FaceApp.h 完全一致(包括我们没用到的
// 字段 zoom/ux/uy/offset_x/offset_y,因为 push constant 是按 offset 取的,
// 缺字段会导致后面 radius/screen_w/screen_h 落到错误偏移)。
layout(push_constant) uniform PushConstants {
float zoom;
float r;
float g;
float b;
float radius;
float ux;
float uy;
} pushConstants;
float r;
float g;
float b;
float radius;
float ux;
float uy;
float offset_x;
float offset_y;
float canvas_size;
float screen_w;
float screen_h;
// 相机帧元信息(fragment 不用,仅为对齐 push constant layout
float camera_aspect;
float camera_rotation;
float mirror_x;
} pc;
void main() {
// outFragColor = color;
// 获取当前片元的屏幕坐标(左下角为原点)
// gl_FragCoord 是屏幕 framebuffer 里的像素坐标(左下原点)。圆形 mask 中心
// 锚定在屏幕真实中心 = (screen_w, screen_h)/2。这里不再用画布中心,因为
// letterbox 后画布中心和屏幕中心其实是同一个点(min 边居中),但用屏幕坐标
// 写法更直观也对 cover 模式更友好。
vec2 fragCoord = gl_FragCoord.xy;
// 屏幕中心(480x480 的中心是 (240, 240)
vec2 center = vec2(240.0, 240.0);
// 计算到中心的距离(欧氏距离,单位:像素)
vec2 center = vec2(pc.screen_w, pc.screen_h) * 0.5;
float dist = length(fragCoord - center);
// 判断是否在半径之外
if (dist > pushConstants.radius) {
// 使用 push constant 中的 RGB 颜色(注意 alpha 设为 1.0
outColor = vec4(pushConstants.r, pushConstants.g, pushConstants.b, 1.0);
if (dist > pc.radius) {
// 画布外(含 letterbox 黑边)+ 圆外区域统一用业务给的纯色覆盖。
// 业务一般给 r=g=0 / b=1 之类,与原版语义保持一致。
outColor = vec4(pc.r, pc.g, pc.b, 1.0);
} else {
// 采样纹理
outColor = texture(texSampler, inTexCoord);
}
}
}
Binary file not shown.
+118 -55
View File
@@ -1,68 +1,131 @@
#version 450
// 硬编码的顶点数据 - 4个顶点组成三角形带覆盖整个屏幕
// const vec2 positions[6] = vec2[6](
// vec2( 1.0, 1.0), // 右上 - 三角形1
// vec2(-1.0, 1.0), // 左上 - 三角形1
// vec2(-1.0, -1.0), // 左下 - 三角形1
// vec2(-1.0, -1.0), // 左下 - 三角形2
// vec2( 1.0, -1.0), // 右下 - 三角形2
// vec2( 1.0, 1.0) // 右上 - 三角形2
// );
const float offset = (640-480)/(480.0);
// ★ bg quad 顶点放大到 ±10(而不是 ±1)的原因:
//
// bg 与 face mesh 的对齐依赖一个关键不变量——
// "屏幕上同一像素位置,bg 显示的 raw UV = 把这个屏幕位置反算回 mediapipe
// landmark 坐标"。要保住这个不变量,bg quad 必须经过和 face mesh 完全相
// 同的 zoom × offset × letterbox × mirror 链路 (见下方 gl_Position 计算)
// 而 outTexCoord 必须基于"未变换的 quad 顶点 pos"算 raw UV。这样屏幕 fragment
// 反算出的 quad pos 就等价于"对应的 landmark NDC"UV 完美对齐。
//
// 但是 zoom (>1) + 非零 offset + screen_h > screen_w 的 letterbox 会让 quad
// 在屏幕上呈非对称分布——之前 quad 顶点 ±1 时实测在 zoom=1.5 offset=(450,-450)
// 屏幕 1080×2340 下,quad 在 NDC.y 只覆盖 [-0.885, 0.500],屏幕底部
// [0.500, 1.0] 大片 letterbox 没有被 bg 覆盖,露出 RenderPass 的 clearColor
// 黑色——表现为"上面有蓝色填充,下面没有"。
//
// 解法:把 quad 顶点扩大到 ±10。GPU 会把超出 NDC [-1, 1] 的部分硬件 clip 掉,
// 但 clip 之后留在屏幕内的 fragment 上,outTexCoord 是顶点的"线性插值"**屏幕
// 内 fragment 反算出的 pos 与 quad 顶点取值范围无关**——所以 face mesh 对齐
// 完全不受影响。同时 ±10 在任何合理的 zoom/offset/letterbox 下都能保证 quad
// 在屏幕上完全覆盖 [-1, 1]² 含 letterbox。
//
// Worst-case 验证:zoom=1.5, offset=(450,-450), canvas=1080, screen=1080×2340
// pos.y = -10 → screen_ndc.y = (-10*1.5 + (-450/1080)) * 1080/2340 = -7.12 ✓
// pos.y = +10 → screen_ndc.y = (+10*1.5 + (-450/1080)) * 1080/2340 = +6.73 ✓
// 远超屏幕范围,clip 后保证整屏 letterbox 都被 bg.frag 的 r/g/b 蓝色覆盖。
const vec2 positions[6] = vec2[6](
vec2( 1.0, -1.0 -offset), // 右下
vec2(1.0, 1.0 + offset), // 右上 - 三角形1
vec2(-1.0, 1.0 + offset), // 左上 - 三角形1
vec2( 10.0, -10.0),
vec2( 10.0, 10.0),
vec2(-10.0, 10.0),
vec2(-1.0, 1.0 + offset), // 左上 - 三角形2
vec2( -1.0, -1.0-offset), // 左下 - 三角形2
vec2( 1.0, -1.0-offset) // 右下 - 三角形2
vec2(-10.0, 10.0),
vec2(-10.0, -10.0),
vec2( 10.0, -10.0)
);
const vec2 texCoords[6] = vec2[6](
vec2(1.0, 1.0), // 右上
vec2(0.0, 1.0), // 左上
vec2(0.0, 0.0), // 左下
vec2(0.0, 0.0), // 左下
vec2(1.0, 0.0), // 右下
vec2(1.0, 1.0) // 右上
);
// 定义 Push Constant,只有一个 float
// ★ PushConstants 必须和 C++ FaceApp.h 严格一致。前 9 个 float 是业务原始值
// SDK 已按 canvas_size/480 缩放到屏幕物理像素),后 5 个 float 是当前帧
// 的画布几何 + 相机帧元信息,由 FaceApp::render() 每帧写入。
layout(push_constant) uniform PushConstants {
float zoom;
float r;
float g;
float b;
float radius;
float ux;
float uy;
float r;
float g;
float b;
float radius;
float ux;
float uy;
float offset_x;
float offset_y;
} pushConstants;
float offset_y;
// 画布几何(每帧写入)
float canvas_size;
float screen_w;
float screen_h;
// 相机帧元信息(每次相机帧上传时写入)
float camera_aspect; // raw width / height(典型 4:3 = 1.333…)
float camera_rotation; // CameraX rotationDegrees: 0/90/180/270
// 镜子效果开关:1.0 = 前置摄像头,最终 NDC.x 翻转一次。
// 这与 FaceLandmarkerHelper 在前置时对 bitmap 做 postScale(-1,1) 的语义对偶:
// ① mediapipe 输入是"用户视角"图像 → landmark 也是用户视角;
// ② bg.vert 算出的 outTexCoord 还是 raw 帧 UV(旋转矩阵把 user 视角→raw);
// ③ 最后我们把 NDC.x 翻转,bg 在屏幕上呈现为水平镜像 = 镜子效果;
// ④ texture.vert 同样翻转一次,face 贴图与 bg 完美对齐。
float mirror_x;
} pc;
// 输出变量
layout(location = 0) out vec2 outTexCoord;
void main() {
// 获取顶点位置
vec2 position = positions[gl_VertexIndex];
position = position * pushConstants.zoom;
position.x = position.x + (pushConstants.offset_x/480);
position.y = position.y + (pushConstants.offset_y/480);
// 设置输出位置(Vulkan使用不同的坐标系)
gl_Position = vec4(position, 0.0, 1.0);
// 输出纹理坐标
outTexCoord = texCoords[gl_VertexIndex];
}
vec2 pos = positions[gl_VertexIndex];
// ===== outTexCoord:基于"未变换的 quad pos" 算 raw UV =====
//
// 重点:这里必须用未做 zoom/offset/letterbox/mirror 变换的原始 pos 来计算
// outTexCoord,而 gl_Position 在下面才做这些变换。这样:
// - 屏幕 fragment X 处对应的 quad pos = (X 反过 mirror、letterbox、offset、zoom)
// - face mesh 顶点的 mediapipe landmark 在屏幕上的位置 X' 经过相同链路
// - 当 X = X' 时(face mesh 顶点重叠某个屏幕像素),fragment 反算的 pos
// 刚好等于 face landmark 的画布 NDCbg 该 fragment 显示 landmark 对应
// raw UV → 与 face mesh 完美贴合。
//
// 公式链:画布 NDC ─[逆时针 rotation]→ 中央方形 NDC ─[scale & shift]→ raw UV
//
// 关于旋转方向:
// Android Matrix.postRotate(deg) 在 Bitmap canvasy 朝下)里是「视觉顺时针」。
// Vulkan NDC 也是 y 朝下。所以画布 NDC → 中央方形 NDC 是「视觉逆时针 rotation」。
// GLSL column-major mat2(c, -s, s, c) 对应矩阵 [c, s; -s, c]。
// 验证 rotation=0 时矩阵 = identity ✓
// 验证 rotation=90 时矩阵 = [0, 1; -1, 0]apply 到画布右上 (1,-1)
// (0*1 + 1*(-1), -1*1 + 0*(-1)) = (-1, -1) → 中央方形左上 ✓
// 物理:mediapipe 输入右上像素 = 中央方形顺时针 90° 后的右上 = 中央方形左上 ✓
float ang = radians(pc.camera_rotation);
float c = cos(ang);
float s = sin(ang);
mat2 R = mat2(c, -s, s, c); // 画布 NDC → 中央方形 NDC(视觉逆时针 rotation
vec2 cm = R * pos;
// 中央方形 NDC ∈ [-1, +1]² → raw 帧 UV。中央方形在 raw 帧 UV 上是中央
// min(W, H) x min(W, H) 的正方形,对应的 UV 半径:
// raw 横向长(W >= H):halfU = H/(2W) = 1/(2 * aspect)halfV = 0.5
// raw 纵向长(H > W):halfU = 0.5halfV = W/(2H) = aspect/2
float halfU, halfV;
if (pc.camera_aspect >= 1.0) {
halfU = 0.5 / pc.camera_aspect;
halfV = 0.5;
} else {
halfU = 0.5;
halfV = 0.5 * pc.camera_aspect;
}
outTexCoord = vec2(0.5 + halfU * cm.x, 0.5 + halfV * cm.y);
// ===== gl_Position:业务 zoom/offset + letterbox + mirror,与 texture.vert 严格一致 =====
//
// pos 仍按 quad 顶点 ±10 参与位置计算,pc.offset_x / pc.offset_y 已是屏幕物理
// 像素(SDK 端做过 canvas_size/480 缩放),/canvas_size 后是画布 NDC 单位;
// 最后 *canvas_size/screen_{w,h} 把画布缩到屏幕中央正方形,长边方向自然
// letterbox。quad 顶点放大到 ±10 让 GPU clip 后整屏覆盖。
pos *= pc.zoom;
pos.x += pc.offset_x / pc.canvas_size;
pos.y += pc.offset_y / pc.canvas_size;
pos.x *= pc.canvas_size / pc.screen_w;
pos.y *= pc.canvas_size / pc.screen_h;
// 镜子效果:前置时 mirror_x=1.0,把 NDC.x 翻转一次。无分支:
// 后置:(1 - 2*0) = +1 → 不变;
// 前置:(1 - 2*1) = -1 → 水平翻转。
// texture.vert 也做同样翻转,bg + face 整体水平镜像,face 贴图与 bg 完美对齐。
pos.x *= (1.0 - 2.0 * pc.mirror_x);
gl_Position = vec4(pos, 0.0, 1.0);
}
Binary file not shown.
+31 -23
View File
@@ -5,40 +5,48 @@ layout (binding = 1) uniform sampler2D samplerColor;
layout (location = 0) in vec2 inUV;
layout (location = 1) in vec3 inNormal;
// ★ PushConstants 必须和 C++ FaceApp.h / texture.vert 严格一致。
// 关键:原版这里只声明到 uy,缺了 offset_x 之后的字段,那时之所以没报错是因为
// fragment 没读到那些字段。本次重构 fragment 要读 screen_w/screen_h,所以必须
// 把整个块声明完整,否则 layout offset 会错位。
layout(push_constant) uniform PushConstants {
float zoom;
float r;
float g;
float b;
float radius;
float ux;
float uy;
} pushConstants;
float r;
float g;
float b;
float radius;
float ux;
float uy;
float offset_x;
float offset_y;
float canvas_size;
float screen_w;
float screen_h;
// 相机帧元信息(fragment 不用,仅为对齐 push constant layout
float camera_aspect;
float camera_rotation;
float mirror_x;
} pc;
layout (location = 0) out vec4 outFragColor;
void main()
{
// outFragColor = color;
// 获取当前片元的屏幕坐标(左下角为原点)
// gl_FragCoord 是屏幕 framebuffer 像素坐标。圆形 mask 中心锚定在屏幕真实中心,
// 跟随分辨率自动适配;不再写死 (240,240)。
vec2 fragCoord = gl_FragCoord.xy;
// 屏幕中心(480x480 的中心是 (240, 240)
vec2 center = vec2(240.0, 240.0);
// 计算到中心的距离(欧氏距离,单位:像素)
vec2 center = vec2(pc.screen_w, pc.screen_h) * 0.5;
float dist = length(fragCoord - center);
// 判断是否在半径之外
if (dist > pushConstants.radius) {
// 使用 push constant 中的 RGB 颜色(注意 alpha 设为 1.0
outFragColor = vec4(pushConstants.r, pushConstants.g, pushConstants.b, 1.0);
if (dist > pc.radius) {
// 画布外(含 letterbox 黑边)+ 圆外区域用业务纯色覆盖,与 bg.frag 一致。
outFragColor = vec4(pc.r, pc.g, pc.b, 1.0);
} else {
vec2 pos = vec2(pushConstants.ux/4096.f, pushConstants.uy/2048.f);
vec2 uv = pos + vec2(inUV.x/8.f, inUV.y/4.f);
// out.png 是 4096x2048 的 8x4 子图图集;ux/uy 选定当前 motion 帧子图,
// inUV 在子图内插值。这里和分辨率重构无关,保持原版逻辑不动。
vec2 pos = vec2(pc.ux / 4096.0, pc.uy / 2048.0);
vec2 uv = pos + vec2(inUV.x / 8.0, inUV.y / 4.0);
vec4 color = texture(samplerColor, uv);
outFragColor = color;
}
}
}
Binary file not shown.
+50 -17
View File
@@ -1,6 +1,5 @@
#version 450
layout (location = 0) in vec3 inPos;
layout (location = 1) in vec2 inUV;
layout (location = 2) in vec3 inNormal;
@@ -16,17 +15,31 @@ layout (binding = 0) uniform UBO
layout (location = 0) out vec2 outUV;
layout (location = 1) out vec3 outNormal;
// ★ PushConstants 必须和 C++ FaceApp.h / bg.vert / bg.frag 严格一致。
// 前 9 个 float 是业务原始值(已被 SDK 在 render() 里按 canvas_size/480 缩放成
// 屏幕物理像素);后 3 个 float 是当前帧画布几何,由 SDK 每帧写入。
layout(push_constant) uniform PushConstants {
float zoom;
float r;
float g;
float b;
float radius;
float ux;
float uy;
float offset_x;
float offset_y;
} pushConstants;
float r;
float g;
float b;
float radius;
float ux;
float uy;
float offset_x;
float offset_y;
float canvas_size;
float screen_w;
float screen_h;
// 相机帧元信息:camera_aspect / camera_rotation 在 face 渲染中不用——face
// landmark 已经是"正立中央方形"的归一化坐标,mediapipe 端转过;保留只是
// 为了对齐 push constant layout。
float camera_aspect;
float camera_rotation;
// mirror_x:1.0 表示前置摄像头镜子效果——把最终 gl_Position.x 翻转一次。
// bg.vert 也做同样翻转,所以 bg 与 face 同步水平镜像、互相对齐。
float mirror_x;
} pc;
out gl_PerVertex
{
@@ -37,13 +50,33 @@ void main()
{
outUV = inUV;
vec2 position = vec2(inPos.xy*2 -1);
position = position * pushConstants.zoom;
position.x = position.x + (pushConstants.offset_x/480);
position.y = position.y + (pushConstants.offset_y/480);
gl_Position = vec4(position, 0.5, 1.0);
// 模型顶点坐标 inPos.xy 来自 OBJ,已经是 [0,1]² 归一化范围。先映射到
// "画布 NDC ∈ [-1,+1]"。
vec2 position = vec2(inPos.xy * 2.0 - 1.0);
vec4 pos = ubo.model * vec4(inPos, 1.0);
// 画布坐标系:缩放 + 业务像素偏移。
// pc.offset_x / pc.offset_y 是屏幕物理像素(SDK 已做过 canvas_size/480 缩放),
// 除以 canvas_size 转成画布 NDC,跟原版 "/480" 在 480 屏上完全等价。
position = position * pc.zoom;
position.x = position.x + (pc.offset_x / pc.canvas_size);
position.y = position.y + (pc.offset_y / pc.canvas_size);
// 画布 NDC → 屏幕 NDC:屏幕长边方向乘以 canvas_size/screen_long < 1.0
// 让模型只占据屏幕中央正方形画布区域,长边方向的画布外是 letterbox。
position.x = position.x * (pc.canvas_size / pc.screen_w);
position.y = position.y * (pc.canvas_size / pc.screen_h);
// 镜子效果:前置摄像头时 mirror_x=1.0,把 NDC.x 翻转一次。
// 后置:(1 - 2*0) = +1 → 不变
// 前置:(1 - 2*1) = -1 → 水平翻转
// 无分支写法,配合 bg.vert 同样的翻转,让 bg + face 整体镜像、保持对齐。
position.x *= (1.0 - 2.0 * pc.mirror_x);
gl_Position = vec4(position, 0.5, 1.0);
// 注意:ubo.projection / ubo.model 在当前管线里没有真正参与 gl_Position 的
// 计算(顶点位置已直接给出 NDC)。这里仍按原版保留 normal 的世界变换,避免
// 影响其它依赖 outNormal 的 fragment 逻辑(例如 thick 版本)。
vec4 pos = ubo.model * vec4(inPos, 1.0);
outNormal = mat3(inverse(transpose(ubo.model))) * inNormal;
}
Binary file not shown.
+58 -19
View File
@@ -191,19 +191,35 @@ void android_main(struct android_app *pApp) {
}
} while (!pApp->destroyRequested);
DebugLog::log("android_main: destroyRequested, running cleanup");
//application.cleanup();
g_Application->cleanupSecondInit();
// 关键:这里只复位 FaceApp 的"second-init" 状态机标记,**不销毁**任何
// Vulkan 资源。
//
// - 窗口相关资源(surface/swapchain/framebuffers/imageViews/
// commandBuffers/sync)已经在 APP_CMD_TERM_WINDOW → onWindowLost()
// 里被 cleanupForWindowLost() 干净销毁;
// - renderPass / graphicsPipeline / pipelineLayout / VkDevice /
// VkInstance / VMA / 所有 textures / FaceApp 自身 pipelines 是 g_Application
// 单例的"全局资源",跨 NativeActivity 实例复用。下次 Activity 启动
// onWindowInit 会走 reinitForNewWindow() 重建窗口资源、复用全局资源。
//
// 历史教训:之前这里调过 g_Application->cleanupSecondInit(),那个函数会
// 把 renderPass / graphicsPipeline / pipelineLayout 全部 destroy 掉但既
// 不置 VK_NULL_HANDLE 也不复位 _applicationInited。结果用户从主界面切镜
// 头再进 MakeupActivity 时,onWindowInit 看到 _applicationInited=1 走 reinit
// 复用路径,引用悬空的 renderPass 调 vkCreateFramebuffervalidation
// layer 检测出 use-after-free 直接 SEGV。
g_Application->clearnSecondFaceApp();
DebugLog::log("android_main: exit");
}
}
extern void TextureLoadProcessWithVulkan(uint8_t* data, int width, int height, int rowStride, size_t dataSize);
extern void TextureLoadProcessWithVulkan(uint8_t* data, int width, int height, int rowStride, size_t dataSize, int rotation, bool mirrorX);
extern void ReceiveFacePoint(float* pos, int count, int width, int height);
void processWithVulkan(uint8_t* data, int width, int height, int format,
int rowStride, size_t dataSize) {
TextureLoadProcessWithVulkan(data, width, height, rowStride, dataSize);
int rowStride, size_t dataSize, int rotation, bool mirrorX) {
TextureLoadProcessWithVulkan(data, width, height, rowStride, dataSize, rotation, mirrorX);
}
@@ -213,14 +229,13 @@ JNIEXPORT void JNICALL
Java_com_hmwl_face_1sdk_FaceActivity_processImageNative(JNIEnv *env, jobject thiz, jobject buffer,
jint width, jint height, jint format,
jint row_stride, jint pixel_stride,
jint rotation) {
jint rotation, jboolean mirror_x) {
static std::atomic<uint64_t> s_imgCount{0};
uint64_t n = s_imgCount.fetch_add(1) + 1;
if (n == 1 || n % 300 == 0) {
DebugLog::log("processImageNative #%llu tid=%d w=%d h=%d",
(unsigned long long)n, dbg_tid(), width, height);
DebugLog::log("processImageNative #%llu tid=%d w=%d h=%d mirror=%d",
(unsigned long long)n, dbg_tid(), width, height, (int)mirror_x);
}
// TODO: implement processImageNative()
uint8_t* imageData = static_cast<uint8_t*>(env->GetDirectBufferAddress(buffer));
jlong capacity = env->GetDirectBufferCapacity(buffer);
@@ -228,9 +243,12 @@ Java_com_hmwl_face_1sdk_FaceActivity_processImageNative(JNIEnv *env, jobject thi
return;
}
// 在这里将图像数据传递给 Vulkan
// 创建 Vulkan 图像或更新现有图像
processWithVulkan(imageData, width, height, format, row_stride, capacity);
// rotation = ImageInfo.getRotationDegrees(),给 shader 用来做 UV 旋转,
// 让不同 sensor orientation 的设备显示方向一致。
// mirror_x = true 表示前置摄像头:所有顶点 shader 会把 gl_Position.x 翻转
// 一次,达到"镜子效果"——这是化妆/美妆类 app 的标准体验。
processWithVulkan(imageData, width, height, format, row_stride, capacity,
(int)rotation, mirror_x == JNI_TRUE);
}
extern "C"
JNIEXPORT void JNICALL
@@ -238,19 +256,21 @@ Java_com_hmwl_face_1sdk_FaceActivity_passDataToNative(JNIEnv *env, jobject thiz,
jint point_count, jint width, jint height) {
static std::atomic<uint64_t> s_ptCount{0};
uint64_t n = s_ptCount.fetch_add(1) + 1;
if (n == 1 || n % 300 == 0) {
DebugLog::log("passDataToNative #%llu tid=%d point_count=%d w=%d h=%d",
(unsigned long long)n, dbg_tid(), point_count, width, height);
}
// TODO: implement passDataToNative()
float* pos = static_cast<float*>(env->GetDirectBufferAddress(buffer));
if (pos == nullptr) {
// 处理错误
DebugLog::log_throttled("passDataToNative.nullBuffer",
"passDataToNative #%llu got NULL DirectBufferAddress!",
(unsigned long long)n);
return;
}
// 节流:每 120 次实际数据通路打一行(约每 4 秒一次,足够确认通路活着即可)。
if (n == 1 || n % 120 == 0) {
DebugLog::log("passDataToNative #%llu point_count=%d w=%d h=%d p0=(%.3f,%.3f,%.3f)",
(unsigned long long)n, point_count, width, height,
pos[0], pos[1], pos[2]);
}
// 或者直接将指针传递给Vulkan
ReceiveFacePoint(pos, point_count, width, height);
}
@@ -491,4 +511,23 @@ JNIEXPORT void JNICALL
Java_com_hmwl_face_1sdk_FaceActivity_ResumeMotionNative(JNIEnv *env, jobject thiz) {
DebugLog::log("JNI ResumeMotionNative tid=%d", dbg_tid());
g_Application->ResumeMotion();
}
// 给 Java/Kotlin 侧用的日志桥:把 Java 端关键事件写进 native 的
// face_sdk_debug.log,使 Java + native + Vulkan 渲染日志能在同一个
// 文件里按时间戳排好序,一次 adb pull 就能拿到完整时间线。
extern "C"
JNIEXPORT void JNICALL
Java_com_hmwl_face_1sdk_DebugLog_nativeLog(JNIEnv *env, jclass clazz,
jstring jtag, jstring jmsg) {
if (jmsg == nullptr) return;
const char* tag = jtag ? env->GetStringUTFChars(jtag, nullptr) : nullptr;
const char* msg = env->GetStringUTFChars(jmsg, nullptr);
if (tag) {
DebugLog::log("[J][%s] %s", tag, msg);
} else {
DebugLog::log("[J] %s", msg);
}
if (tag) env->ReleaseStringUTFChars(jtag, tag);
env->ReleaseStringUTFChars(jmsg, msg);
}
@@ -0,0 +1,38 @@
package com.hmwl.face_sdk;
import android.util.Log;
/**
* Java/Kotlin 端日志桥。所有调用最终通过 JNI 写到 native 端的
* face_sdk_debug.log 文件(与 native DebugLog::log 共用一份),
* 同时镜像到 logcat(tag = 调用方 tag),方便联机调试时一次 adb pull
* 就能拿到完整时间线(Java + native + Vulkan 渲染)。
*
* 使用约定:
* - 业务低频事件用 i(tag, msg)
* - 错误用 e(tag, msg)
* - 高频帧级日志请自行节流,本类不再做二次节流
*/
public final class DebugLog {
private DebugLog() {}
public static void i(String tag, String msg) {
Log.i(tag, msg);
try {
nativeLog(tag, msg);
} catch (UnsatisfiedLinkError e) {
// native 还没 attach 的极早期阶段,吞掉避免崩溃
}
}
public static void e(String tag, String msg) {
Log.e(tag, msg);
try {
nativeLog("[E]" + tag, msg);
} catch (UnsatisfiedLinkError e) {
// 同上
}
}
private static native void nativeLog(String tag, String msg);
}
@@ -7,7 +7,8 @@ import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.camera.core.AspectRatio;
import android.util.Size;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.ImageProxy;
@@ -38,6 +39,27 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L
System.loadLibrary("face_sdk");
}
/**
* Intent extra key:调用方(example/MainActivity 等)通过它告诉 FaceActivity
* 启动哪个朝向的摄像头。值类型 int,取自 androidx.camera.core.CameraSelector
* - CameraSelector.LENS_FACING_BACK (0)
* - CameraSelector.LENS_FACING_FRONT (1)
* 缺省(未提供 extra)时走 LENS_FACING_BACK,保留旧调用方的行为。
*
* 该值同时决定两件事:
* ① CameraSelector 朝向;
* ② mirrorX 镜像标志:前置摄像头时 sensor 帧是"用户右手在画面右侧",
* 为了显示成"镜子效果"(用户右手在屏幕左侧),所有顶点 shader 在
* gl_Position 阶段对 NDC.x 做一次水平翻转。这件事通过 push constant
* 的 mirror_x 字段透传到 GPU;对应地 FaceLandmarkerHelper 也会对
* bitmap 做 postScale(-1, 1) 让 mediapipe 输入与显示画面方向一致,
* 确保 face 贴图与 bg 完美对齐。
*/
public static final String EXTRA_LENS_FACING = "com.hmwl.face_sdk.LENS_FACING";
/** 当前 session 使用的摄像头朝向。在 onCreate 里从 Intent extra 读取并固定。 */
private int lensFacing = CameraSelector.LENS_FACING_BACK;
private ProcessCameraProvider cameraProvider;
private void initCamera(){
backgroundExecutor = Executors.newSingleThreadExecutor();
@@ -72,11 +94,28 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L
cameraProvider = cameraProviderFuture.get();
CameraSelector cameraSelector = new CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.requireLensFacing(lensFacing)
.build();
// 显式锁定分析帧分辨率为 480x640(CameraX UI 坐标系,竖屏:短边 x 长边)。
// 不同手机的相机硬件原生支持的分辨率差异很大(旗舰机随便给 1920x1440、
// 入门机可能是 320x240),CameraX 默认会按"最贴近 setTargetAspectRatio
// 的 supported size"挑一个,结果在不同设备上拿到的分析帧大小都不一样。
// 这会带来三个麻烦:
// ① bg 纹理 GPU 内存占用波动很大(高端机一帧 ~10MB staging buffer);
// ② mediapipe 推理耗时随分辨率非线性升高(Pixel 上 1920x1440 比
// 640x480 慢 4-6 倍),帧率掉得明显;
// ③ 我们的 SDK 用归一化 landmark + 中央方形 crop,本来就不需要更高
// 分辨率,多出来的像素是纯浪费。
//
// 锁定 480x640 后:sensor 坐标系下分析帧固定 640x480 (rotation=90 时)
// FaceLandmarkerHelper 中央方形裁切固定 480x480,bg 上传纹理也固定,
// 不同手机上行为一致。
//
// 实际尺寸由 CameraX 决定(会找最接近的硬件支持档位),但设备实测
// 几乎都能精确给出 640x480。
imageAnalyzer = new ImageAnalysis.Builder()
.setTargetAspectRatio(AspectRatio.RATIO_4_3)
.setTargetResolution(new Size(480, 640))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
.build();
@@ -114,24 +153,24 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L
}
private void processImageForVulkan(ImageProxy image) {
// 获取图像信息
int width = image.getWidth();
int height = image.getHeight();
int format = image.getFormat();
// 获取图像数据平面
ImageProxy.PlaneProxy[] planes = image.getPlanes();
// 对于 RGBA_8888 格式,通常只有一个平面
if (planes.length > 0) {
ImageProxy.PlaneProxy plane = planes[0];
ByteBuffer buffer = plane.getBuffer();
int rowStride = plane.getRowStride();
int pixelStride = plane.getPixelStride();
// 调用 Native 方法处理图像
// mirrorX 为 true 时所有顶点 shader 会把 gl_Position.x 翻转一次,达到
// "镜子效果"——前置摄像头唯一需要的额外处理。后置不开镜像。
boolean mirrorX = (lensFacing == CameraSelector.LENS_FACING_FRONT);
processImageNative(buffer, width, height, format,
rowStride, pixelStride, image.getImageInfo().getRotationDegrees());
rowStride, pixelStride, image.getImageInfo().getRotationDegrees(),
mirrorX);
}
}
@@ -154,11 +193,16 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L
// Native 方法
private native void processImageNative(ByteBuffer buffer, int width, int height,
int format, int rowStride, int pixelStride,
int rotation);
int rotation, boolean mirrorX);
private void detectFace(ImageProxy imageProxy) {
// 前置摄像头:FaceLandmarkerHelper 内部会对 bitmap 做 postScale(-1, 1)
// 让 mediapipe 看到的是"用户视角"的图像。这样 landmark 输出就是用户视角
// 归一化坐标,跟 shader 里 gl_Position.x 翻转一次后的 bg 画面方向一致,
// face 贴图位置才能精确对齐。
boolean isFrontCamera = (lensFacing == CameraSelector.LENS_FACING_FRONT);
faceLandmarkerHelper.detectLiveStream(
imageProxy,false
imageProxy, isFrontCamera
);
}
@@ -181,6 +225,15 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L
@Override
protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
// 必须在 initCamera() 之前读,CameraSelector 的朝向就靠这个 lensFacing。
// 兼容旧调用方:未提供 EXTRA_LENS_FACING 时默认后置。
if (getIntent() != null) {
lensFacing = getIntent().getIntExtra(
EXTRA_LENS_FACING, CameraSelector.LENS_FACING_BACK);
}
Log.d(TAG, "onCreate lensFacing=" + lensFacing
+ " (front=" + CameraSelector.LENS_FACING_FRONT
+ " back=" + CameraSelector.LENS_FACING_BACK + ")");
initCamera();
backgroundExecutor.execute(new Runnable() {
@@ -280,11 +333,18 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L
FloatBuffer floatBuffer = nativeBuffer.asFloatBuffer();
floatBuffer.put(points);
floatBuffer.position(0);
long cur_time = System.currentTimeMillis();
Log.i("TimeLatency","Result Data ProcessTime:" + (cur_time-start_time));
// 节流:每 30 次调用打一次到 native 文件,方便和 native 那边的
// passDataToNative.tick 对账。
onResultsCallCount++;
if (onResultsCallCount % 30 == 1) {
DebugLog.i("FaceActivity",
"onResults call#" + onResultsCallCount
+ " index=" + index + " input=" + width + "x" + height
+ " p0=(" + points[0] + "," + points[1] + "," + points[2] + ")");
}
passDataToNative(nativeBuffer, index, width, height);
Log.i("TimeLatency","passDataToNative ProcessTime:" + (System.currentTimeMillis() - cur_time));
}
private long onResultsCallCount = 0;
public native void passDataToNative(ByteBuffer buffer, int pointCount, int width, int height);
@@ -173,6 +173,13 @@ class FaceLandmarkerHelper(
" while not using RunningMode.LIVE_STREAM"
)
}
// 节流日志:每 ~30 次调用打一次(detectFace 在 FaceActivity 里只在
// frameCount % 3 == 0 时调用,所以 30 次大约对应 90 帧 ≈ 3 秒)。用来
// 确认 mediapipe 入口活着、isFrontCamera/rotation/分辨率有没有跑偏。
if ((frameId % 30L) == 0L) {
Log.i(TAG, "detectLiveStream tick frameId=$frameId src=${imageProxy.width}x${imageProxy.height}" +
" rot=${imageProxy.imageInfo.rotationDegrees} isFrontCamera=$isFrontCamera")
}
val frameTime = SystemClock.uptimeMillis()
val currentFrameId = frameId++
@@ -194,22 +201,40 @@ class FaceLandmarkerHelper(
imageProxy.use { bitmapBuffer.copyPixelsFromBuffer(imageProxy.planes[0].buffer) }
//imageProxy.close()
// 重要:这里 *不* 对前置摄像头做 postScale(-1, 1)!理由如下:
//
// 我们的渲染策略是 —— bg 与 face 顶点 shader 末尾统一对 gl_Position.x
// 乘 (1 - 2*mirror_x) 做镜子翻转。bg 是直接采样 sensor 帧、然后 shader
// 末尾翻一次;face 顶点用的是 mediapipe 输出的归一化坐标,shader 末尾
// 也翻一次。两者只有同时基于"sensor 视角"做输入、再一起在 shader 末尾
// 翻一次,face 才能精确对齐 bg 上的脸。
//
// 如果在这里给 mediapipe 喂"已镜像"的 bitmap(旧实现),mediapipe 输出
// 的 landmark 就是"用户视角"坐标,再被 shader 翻一次 → face 实际上被
// 翻了两次,跟只翻一次的 bg 错位(通常会在屏幕另一半,看起来就是"face
// 完全没渲染")。这正是我们最近调试看到的现象。
//
// mediapipe 对左右镜像不敏感(训练集本身覆盖各角度),sensor 视角下也
// 能稳定检测出 478 个 landmark,无需迎合"镜子视角"。因此 isFrontCamera
// 参数保留以备扩展,但当前不再据此修改输入图。
val matrix = Matrix().apply {
// Rotate the frame received from the camera to be in the same direction as it'll be shown
postRotate(imageProxy.imageInfo.rotationDegrees.toFloat())
// flip image if user use front camera
if (isFrontCamera) {
postScale(
-1f,
1f,
imageProxy.width.toFloat(),
imageProxy.height.toFloat()
)
}
}
// 旧代码硬编码了 (640-480)/2 = 80 和 bitmapBuffer.height=480),假设
// 相机帧是 640x480。在不同手机/不同 CameraX 配置下相机分辨率会变(比如
// 480x640 portrait sensor、1280x720 等),硬编码会越界裁切或丢一半画面。
//
// 改成:根据 imageProxy 的实际尺寸取中央 min(W,H) x min(W,H) 正方形作为
// mediapipe 输入。这与 bg.vert 的 UV 计算严格对齐 —— shader 那边也是从
// raw 帧的中央 min(W,H) 正方形采样,再按 rotation 旋转。两端语义一致才能
// 让 FaceLandmark 输出的归一化坐标 [0,1] 直接对应屏幕画布 NDC。
val srcW = imageProxy.width
val srcH = imageProxy.height
val side = minOf(srcW, srcH)
val cropX = (srcW - side) / 2
val cropY = (srcH - side) / 2
val rotatedBitmap = Bitmap.createBitmap(
bitmapBuffer, (640-480)/2, 0, bitmapBuffer.height, bitmapBuffer.height,
bitmapBuffer, cropX, cropY, side, side,
matrix, true
)
@@ -270,9 +295,14 @@ class FaceLandmarkerHelper(
//{
val finishTimeMs = SystemClock.uptimeMillis()
val inferenceTime = finishTimeMs - result.timestampMs()
Log.i("TimeLatency",
"总延时: ${inferenceTime}ms | "
)
hitResultCount++
if (hitResultCount % 30L == 1L) {
// 节流:检测到人脸的频次。和 emptyResultCount 配合看就能知道
// 命中率(hitResultCount / (hitResultCount + emptyResultCount))。
Log.i(TAG, "returnLivestreamResult: HIT count=$hitResultCount" +
" landmarks=${result.faceLandmarks().firstOrNull()?.size}" +
" input=${input.width}x${input.height} infer=${inferenceTime}ms")
}
faceLandmarkerHelperListener?.onResults(
ResultBundle(
result,
@@ -284,9 +314,19 @@ class FaceLandmarkerHelper(
//}
}
else {
// 节流:mediapipe 拿到 frame 但没识别出人脸时,每 30 次空结果打一行。
// 只要这条日志在刷,就说明 mediapipe pipeline 正常活着,断点在"喂进去
// 的图本身没有可识别的脸"——常见于 isFrontCamera 路径下输入图被翻坏。
emptyResultCount++
if (emptyResultCount % 30L == 1L) {
Log.w(TAG, "returnLivestreamResult: faceLandmarks empty (count=$emptyResultCount)" +
" input=${input.width}x${input.height} ts=$frameId")
}
faceLandmarkerHelperListener?.onEmpty()
}
}
private var emptyResultCount: Long = 0
private var hitResultCount: Long = 0
// Return errors thrown during detection to this FaceLandmarkerHelper's
// caller