Files
camera-extrinsic-play/src/Boxing.tsx
2025-07-11 15:11:09 +08:00

635 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Grid, useBVH, useGLTF, CameraControls, AccumulativeShadows, OrbitControls, Stats } from '@react-three/drei'
import { Camera, Canvas, useFrame, useThree, useLoader, RenderCallback, RootState } from '@react-three/fiber'
import { Text as DreiText, TextProps as DreiTextProps } from '@react-three/drei';
import * as THREE from 'three'
import { FontLoader } from 'three/addons/loaders/FontLoader.js'
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js'
import HelvetikerRegular from "three/examples/fonts/helvetiker_regular.typeface.json"
import { useEffect, useRef, useState, JSX, forwardRef } from 'react'
import Boxing from "./assets/Test_WeiHua_Segment_1.json"
// 133, 3
type PosePoints3D = [number, number, number][]
// F, 133, 3
type AnimePosePoints3D = PosePoints3D[]
interface Skeleton {
"1": PosePoints3D[],
"2": PosePoints3D[],
"3": PosePoints3D[],
}
// 扩展 Drei 的 TextProps 接口
interface TextProps extends DreiTextProps {
position?: [number, number, number];
fontSize?: number;
color?: string;
billboard?: boolean;
}
// 创建类型安全的 Text 组件
const Text = forwardRef<THREE.Mesh, TextProps>((props, ref) => {
return <DreiText {...props} ref={ref} />;
});
// const POSE_3D = POSE_3D_ as AnimePosePoints3D
// const POSE_3D_MANY = POSE_3D_MANY_ as AnimePosePoints3D[] // N F 133 3
const BOXING = Boxing as Skeleton
const THREE_ADDONS = {
FontLoader,
TextGeometry,
} as const
// Create OpenCV to OpenGL conversion matrix
// OpenCV: X right, Y down, Z forward
// OpenGL: X right, Y up, Z backward
const CV_TO_GL_MAT = new THREE.Matrix4().set(
1, 0, 0, 0,
0, -1, 0, 0,
0, 0, -1, 0,
0, 0, 0, 1
)
// Z-up to Y-up conversion matrix
// Rotate -90 degrees around X axis to convert from Z-up to Y-up
const Z_UP_TO_Y_UP = new THREE.Matrix4().set(
-1, 0, 0, 0,
0, 0, -1, 0,
0, -1, 0, 0,
0, 0, 0, 1
)
const Z_UP_TO_Y_UP_PRIME = new THREE.Matrix4().set(
1, 0, 0, 0,
0, 0, 1, 0,
0, 1, 0, 0,
0, 0, 0, 1
)
// Color definitions for different body parts
const COLOR_SPINE = new THREE.Color(138 / 255, 201 / 255, 38 / 255) // green, spine & head
const COLOR_ARMS = new THREE.Color(255 / 255, 202 / 255, 58 / 255) // yellow, arms & shoulders
const COLOR_LEGS = new THREE.Color(25 / 255, 130 / 255, 196 / 255) // blue, legs & hips
const COLOR_FINGERS = new THREE.Color(255 / 255, 0, 0) // red, fingers
const COLOR_FACE = new THREE.Color(255 / 255, 200 / 255, 0) // yellow, face
const COLOR_FOOT = new THREE.Color(255 / 255, 128 / 255, 0) // orange, foot
const COLOR_HEAD = new THREE.Color(255 / 255, 0, 255 / 255) // purple, head
// Body bone connections
const BODY_BONES = [
// legs
[15, 13], [13, 11], [16, 14], [14, 12], [11, 12], // legs
[5, 11], [6, 12], [5, 6], // torso
[5, 7], [7, 9], [6, 8], [8, 10], // arms
[1, 2], [0, 1], [0, 2], [1, 3], [2, 4], // head
[15, 17], [15, 18], [15, 19], // left foot
[16, 20], [16, 21], [16, 22], // right foot
] as const
// Body bone colors
const BODY_BONE_COLORS = [
COLOR_LEGS, COLOR_LEGS, COLOR_LEGS, COLOR_LEGS, COLOR_LEGS,
COLOR_SPINE, COLOR_SPINE, COLOR_SPINE,
COLOR_ARMS, COLOR_ARMS, COLOR_ARMS, COLOR_ARMS,
COLOR_HEAD, COLOR_HEAD, COLOR_HEAD, COLOR_HEAD, COLOR_HEAD,
COLOR_FOOT, COLOR_FOOT, COLOR_FOOT,
COLOR_FOOT, COLOR_FOOT, COLOR_FOOT,
] as const
// Hand bone connections (in pairs of [start, end] indices)
const HAND_BONES = [
// right hand
[91, 92], [92, 93], [93, 94], [94, 95], // right thumb
[91, 96], [96, 97], [97, 98], [98, 99], // right index
[91, 100], [100, 101], [101, 102], [102, 103], // right middle
[91, 104], [104, 105], [105, 106], [106, 107], // right ring
[91, 108], [108, 109], [109, 110], [110, 111], // right pinky
// left hand
[112, 113], [113, 114], [114, 115], [115, 116], // left thumb
[112, 117], [117, 118], [118, 119], [119, 120], // left index
[112, 121], [121, 122], [122, 123], [123, 124], // left middle
[112, 125], [125, 126], [126, 127], [127, 128], // left ring
[112, 129], [129, 130], [130, 131], [131, 132] // left pinky
] as const
const DEFAULT_TRANSFORMATION_MATRIX = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
] as const
const DEFAULT_NEAR = 0.05
const DEFAULT_FAR = 1
const CAMERA_EXTRINSIC_MATRIX_MAP: Record<string, number[]> = {
"5602": [
-0.76138617, 0.16195241, 0.62774399, -0.73832425, -0.40758532,
-0.87257285, -0.26924121, 0.55561231, 0.5041481, -0.46085577,
0.73037432, 3.28663985, 0., 0., 0.,
1.
] as const,
"5603": [
-0.44966325, -0.02806161, -0.89275725, -0.24260155, 0.21789368,
-0.97275592, -0.07917234, 0.41197614, -0.8662132, -0.23012705,
0.44352704, 4.37769458, 0., 0., 0.,
1.
] as const,
"5604": [
0.9551736, -0.02735669, 0.29477958, 0.5908996, -0.06148677,
-0.99234061, 0.10714238, 0.87629594, 0.28959069, -0.12046462,
-0.94953963, 4.1617598, 0., 0., 0.,
1.
] as const,
"5605": [
0.57140284, 0.03118154, -0.82007713, -0.22679438, 0.11021058,
-0.99314166, 0.0390292, 0.83904835, -0.81323577, -0.11268257,
-0.5709205, 4.17730229, 0., 0., 0.,
1.
] as const,
}
const CAMERA_INTRINSIC_MATRIX_MAP: Record<string, number[]> = {
"5602": [
2686.00393399, 0., 1470.49106388, 0.,
2699.92688641, 765.51266883, 0., 0.,
1.
] as const,
"5603": [
2791.38378418, 0., 1258.11161208, 0.,
2790.67070205, 788.14860021, 0., 0.,
1.
] as const,
"5604": [
2789.25680621, 0., 1231.13101573, 0.,
2787.08458106, 677.69385417, 0., 0.,
1.
] as const,
"5605": [
2644.68145454, 0., 1285.34889127, 0.,
2644.9700955, 627.20808584, 0., 0.,
1.
] as const,
}
const IMAGE_WIDTH = 2560
const IMAGE_HEIGHT = 1440
const intrinsicToFov = (intrinsic: number[], image_size: { width: number, height: number }) => {
console.assert(intrinsic.length === 9, "intrinsic must be a 3x3 matrix")
const fx = intrinsic[0]
const fy = intrinsic[4]
const cx = intrinsic[2]
const cy = intrinsic[5]
// in degrees
const fov_x = 2 * Math.atan(image_size.width / (2 * fx)) * (180 / Math.PI)
const fov_y = 2 * Math.atan(image_size.height / (2 * fy)) * (180 / Math.PI)
return { fov_x, fov_y }
}
const calculaterCubeVersices = (position: number[], dimensions: number[]) => {
const [cx, cy, cz] = position
const [width, height, depth] = dimensions
const halfWidth = width / 2
const halfHeight = height / 2
const halfDepth = depth / 2
return [
[cx - halfWidth, cy - halfHeight, cz - halfDepth],
[cx + halfWidth, cy - halfHeight, cz - halfDepth],
[cx + halfWidth, cy + halfHeight, cz - halfDepth],
[cx - halfWidth, cy + halfHeight, cz - halfDepth],
[cx - halfWidth, cy - halfHeight, cz + halfDepth],
[cx + halfWidth, cy - halfHeight, cz + halfDepth],
[cx + halfWidth, cy + halfHeight, cz + halfDepth],
[cx - halfWidth, cy + halfHeight, cz + halfDepth]
]
}
const Scene = () => {
// 定义立方体绘制组件
const Cube = () => {
const vertices = calculaterCubeVersices([0, -0.205 + 0.9, 0.90], [0.5, 1.8, 0.5])
return (
<>
<mesh position={[0, -0.205 + 0.9, 0.90]}> {/** 原点位置,相对于六面体中心偏移 */}
<boxGeometry args={[0.5, 1.8, 0.5]} /> {/** 边长0.5米1.8米深度0.5米 */}
<meshStandardMaterial
color="#007BFF" // 蓝色
opacity={0.2} // 半透明
transparent={true}
wireframe={true} // 线框模式,仅显示边框
/>
</mesh>
{/* 顶点标记(带坐标系和文本) */}
{vertices.map(([x, y, z], index) => (
<group key={index} position={[x, y, z]}>
{/* 顶点处的小标记点 */}
<mesh>
<sphereGeometry args={[0.0, 16, 16]} />
<meshBasicMaterial color="#ff0000" />
</mesh>
{/* 坐标轴X:红, Y:绿, Z:蓝) */}
<axesHelper args={[0.2]} />
{/* 沙袋上的坐标文本(始终面向相机) */}
{/* <Text
position={[0, 0.1, 0]} // 文本在顶点上方
fontSize={0.08}
color="#007Bff"
billboard
>
{`P${index}: (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`}
</Text> */}
</group>
))}
</>
)
}
function Floor() {
return (
<mesh rotation-x={-Math.PI / 2} position-y={-0.5} receiveShadow>
<planeGeometry args={[15, 15]} />
<meshStandardMaterial color="#ccc" />
</mesh>
)
}
const Axes = () => {
return <axesHelper args={[15]} />
}
interface CameraViewFromExtrinsicProps {
extrinsic: number[] | THREE.Matrix4
aspect?: number
name?: string
near?: number
far?: number
fov?: number
textSize?: number
}
// https://threejs.org/docs/#examples/en/loaders/FontLoader
// https://www.ilyameerovich.com/simple-3d-text-meshes-in-three-js/
const CameraViewFromExtrinsic = ({ extrinsic, name, near, far, fov, textSize, aspect }: CameraViewFromExtrinsicProps) => {
let Rt: THREE.Matrix4
if (extrinsic instanceof THREE.Matrix4) {
Rt = extrinsic
} else if (Array.isArray(extrinsic)) {
console.assert(extrinsic.length === 16, "extrinsic must be a 4x4 matrix")
Rt = new THREE.Matrix4()
// @ts-expect-error 16 elements
Rt.set(...extrinsic)
} else {
throw new Error("extrinsic must be a 4x4 matrix or an array of 16 elements")
}
const font = new FontLoader().parse(HelvetikerRegular)
const camera = new THREE.PerspectiveCamera(fov ?? 60, aspect ?? 4 / 3, near ?? DEFAULT_NEAR, far ?? DEFAULT_FAR)
const helper = <cameraHelper args={[camera]} />
camera.applyMatrix4(Rt)
const textRef = useRef<THREE.Mesh>(null)
const { camera: viewCamera } = useThree()
useFrame(() => {
if (textRef.current) {
textRef.current.lookAt(viewCamera.position)
}
})
let text: JSX.Element | null = null
if (name) {
const geo = new THREE_ADDONS.TextGeometry(name ?? "", { font, size: textSize ?? 0.1, depth: 0.001 })
const position = new THREE.Vector3()
position.setFromMatrixPosition(Rt)
text = (
<mesh ref={textRef} position={position}>
<primitive object={geo} />
<meshStandardMaterial color="black" />
</mesh>
)
}
return (
<group>
{text}
<primitive object={camera} />
{helper}
</group>
)
}
const preProcessExtrinsic = (extrinsic: number[] | THREE.Matrix4) => {
let Rt: THREE.Matrix4
if (extrinsic instanceof THREE.Matrix4) {
Rt = extrinsic
} else if (Array.isArray(extrinsic)) {
console.assert(extrinsic.length === 16, "extrinsic must be a 4x4 matrix")
Rt = new THREE.Matrix4()
// @ts-expect-error 16 elements
Rt.set(...extrinsic)
} else {
throw new Error("extrinsic must be a 4x4 matrix or an array of 16 elements")
}
// Then handle OpenCV to OpenGL camera convention
const cameraCvt = CV_TO_GL_MAT.clone()
// Convert from Z-up to Y-up first (this affects world coordinates)
// const worldCvt = Z_UP_TO_Y_UP.clone()
// Final transformation:
// 1. Convert world from Z-up to Y-up
// 2. Apply the camera transform
// 3. Convert camera coordinates from OpenCV to OpenGL
const final = new THREE.Matrix4()
final
.multiply(cameraCvt)
.multiply(Rt)
// .multiply(worldCvt)
// Invert to get the camera-to-world transform
final.invert()
return final
}
interface Human3DSkeletonProps {
skeleton: AnimePosePoints3D
startFrame?: number
jointRadius?: number
boneRadius?: number
showJoints?: boolean
showBones?: boolean
frameRate?: number
}
/**
* 3D人体骨架动画组件
* @param skeleton 动画帧序列每帧为133个关节点的三维坐标
* @param startFrame 起始帧
* @param jointRadius 关节点球体半径
* @param boneRadius 骨骼圆柱体半径
* @param showJoints 是否显示关节点
* @param showBones 是否显示骨骼
* @param frameRate 动画帧率
*/
const Human3DSkeleton = ({
skeleton,
startFrame = 0,
jointRadius = 0.01,
boneRadius = 0.005,
showJoints = true,
showBones = true,
frameRate = 24
}: Human3DSkeletonProps) => {
// 当前动画帧索引
const [frameIndex, setFrameIndex] = useState(startFrame)
const totalFrames = skeleton.length
// 动画帧推进函数按frameRate自动推进
const onFrame: RenderCallback = (totalFrames === 0) ? (state, delta) => { } : (state: RootState, delta: number) => {
// 根据帧率和delta推进帧索引实现动画
setFrameIndex(prevFrame => {
const nextFrame = prevFrame + frameRate * delta
// 到末尾自动循环
return nextFrame >= totalFrames ? 0 : nextFrame
})
return null
}
// 注册动画帧推进
useFrame(onFrame)
// 当前帧的133个关节点坐标
const currentFrame = Math.floor(frameIndex) % totalFrames
const joints = skeleton[currentFrame]
/**
* 右腕关键点10速度计算
* 速度 = (当前帧位置 - 上一帧位置) / (1 / frameRate)
* 单位:与坐标单位一致/秒
*/
let wristSpeed: number | null = null;
if (currentFrame > 0 && joints && joints.length > 16) {
const prevJoints = skeleton[(currentFrame - 1 + totalFrames) % totalFrames];
if (prevJoints && prevJoints.length > 16) {
const wNow = joints[10];
const wPrev = prevJoints[10];
if (wNow && wPrev) {
const dx = wNow[0] - wPrev[0];
const dy = wNow[1] - wPrev[1];
const dz = wNow[2] - wPrev[2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
wristSpeed = dist * frameRate; // 单位/秒
}
}
}
/**
* 右手大臂(右肩-右肘)和小臂(右肘-右腕)夹角计算
* COCO/BlazePose: 右肩=6, 右肘=8, 右腕=10
* 夹角为两向量的夹角,单位度
*/
let rightUpperArmAngle: number | null = null;
if (joints && joints.length > 16) {
const shoulder = joints[6]; // 右肩
const elbow = joints[8]; // 右肘
const wrist = joints[10]; // 右腕
if (shoulder && elbow && wrist) {
// 大臂向量(肩->肘)
const v1 = new THREE.Vector3(
shoulder[0] - elbow[0],
shoulder[1] - elbow[1],
shoulder[2] - elbow[2]
);
// 小臂向量(腕->肘)
const v2 = new THREE.Vector3(
wrist[0] - elbow[0],
wrist[1] - elbow[1],
wrist[2] - elbow[2]
);
// 计算夹角
const dot = v1.dot(v2);
const len1 = v1.length();
const len2 = v2.length();
if (len1 > 1e-6 && len2 > 1e-6) {
const angle = Math.acos(Math.max(-1, Math.min(1, dot / (len1 * len2))));
rightUpperArmAngle = angle * 180 / Math.PI;
}
}
}
/**
* 获取关节点颜色
* @param idx 关节点索引
*/
const getJointColor = (idx: number) => {
// 脸部
if (idx >= 23 && idx <= 90) return COLOR_FACE
// 手指
if (idx >= 91 && idx <= 132) return COLOR_FINGERS
// 脚
if (idx >= 17 && idx <= 22) return COLOR_FOOT
// 头部
if (idx <= 4) return COLOR_HEAD
// 手臂
if (idx >= 5 && idx <= 10) return COLOR_ARMS
// 腿
if (idx >= 11 && idx <= 16) return COLOR_LEGS
// 躯干
return COLOR_SPINE
}
/**
* 关节点坐标转换(如需坐标系变换可在此处理)
*/
const transformJointPosition = (j: [number, number, number]) => {
const [x, y, z] = j
const V = new THREE.Vector3(x, y, z)
return V
}
// 生成关节点球体
const jointMeshes = showJoints ? joints.map((j, idx) => {
const position = transformJointPosition(j)
const color = getJointColor(idx)
return (
<mesh key={`joint-${idx}`} position={position}>
<sphereGeometry args={[jointRadius, 16, 16]} />
<meshStandardMaterial color={color} />
</mesh>
)
}) : null
// 生成骨骼圆柱体
const boneMeshes = showBones ? (
<>
{/* 身体主骨骼 */}
{BODY_BONES.map((bone, idx) => {
const [startIdx, endIdx] = bone
if (startIdx >= joints.length || endIdx >= joints.length) return null
// 起止点
const startPos = transformJointPosition(joints[startIdx])
const endPos = transformJointPosition(joints[endIdx])
const color = BODY_BONE_COLORS[idx]
// 骨骼中点和长度
const midpoint = new THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5)
const length = startPos.distanceTo(endPos)
// 旋转对齐
const direction = new THREE.Vector3().subVectors(endPos, startPos).normalize()
const quaternion = new THREE.Quaternion()
const up = new THREE.Vector3(0, 1, 0)
quaternion.setFromUnitVectors(up, direction)
return (
<mesh key={`bone-body-${idx}`} position={midpoint} quaternion={quaternion}>
<cylinderGeometry args={[boneRadius, boneRadius, length, 8]} />
<meshStandardMaterial color={color} />
</mesh>
)
})}
{/* 手部骨骼 */}
{HAND_BONES.map((bone, idx) => {
const [startIdx, endIdx] = bone
if (startIdx >= joints.length || endIdx >= joints.length) return null
const startPos = transformJointPosition(joints[startIdx])
const endPos = transformJointPosition(joints[endIdx])
const midpoint = new THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5)
const length = startPos.distanceTo(endPos)
const direction = new THREE.Vector3().subVectors(endPos, startPos).normalize()
const quaternion = new THREE.Quaternion()
const up = new THREE.Vector3(0, 1, 0)
quaternion.setFromUnitVectors(up, direction)
return (
<mesh key={`bone-hand-${idx}`} position={midpoint} quaternion={quaternion}>
<cylinderGeometry args={[boneRadius, boneRadius, length, 8]} />
<meshStandardMaterial color={COLOR_FINGERS} />
</mesh>
)
})}
</>
) : null
// 渲染骨架、关节点、骨骼、右臂夹角和右腕速度文本
return (
<group>
{jointMeshes}
{boneMeshes}
{/* 实时显示右手大臂和小臂夹角,文本位于右肘上方 */}
{rightUpperArmAngle !== null && joints[8] && (
<Text
position={[1.5, 2.5, 0]}
fontSize={0.3}
color="#ff00ff"
billboard
>
{`右臂夹角: ${rightUpperArmAngle.toFixed(1)}°`}
</Text>
)}
{/* 实时显示右腕速度,文本位于右腕上方 */}
{wristSpeed !== null && joints[10] && (
<Text
position={[-1.5, 2.5, 0]}
fontSize={0.3}
color="#00bfff"
billboard
>
{`右腕速度: ${wristSpeed.toFixed(3)}`}
</Text>
)}
</group>
)
}
// 调小关节点和骨骼的半径
const skeletons = [
<Human3DSkeleton jointRadius={0.03} boneRadius={0.014} frameRate={24} skeleton={BOXING[1]} />,
]
const cameras = Object.entries(CAMERA_EXTRINSIC_MATRIX_MAP).map(([key, value]) => {
const intrinsic = CAMERA_INTRINSIC_MATRIX_MAP[key]
const { fov_x, fov_y } = intrinsicToFov(intrinsic, { width: IMAGE_WIDTH, height: IMAGE_HEIGHT })
// make the far reverse proportional to the fov
const far = (1 / fov_x) * 20
return <CameraViewFromExtrinsic key={key} name={`${key}(${fov_x.toFixed(1)})`} extrinsic={preProcessExtrinsic(value)} fov={fov_x} aspect={IMAGE_WIDTH / IMAGE_HEIGHT} far={far} />
})
// 在场景中添加立方体
const scene = (<group>
{/* <OrbitControls /> */}
<ambientLight intensity={0.05} />
<directionalLight castShadow position={[3.3, 6, 4.4]} intensity={5} />
{/* <Floor /> */}
{ }
<Axes />
<Cube /> {/** 新增立方体 */}
{cameras}
{skeletons}
</group>)
return (
// Note that we don't need to import anything, All three.js objects will be treated
// as native JSX elements, just like you can just write <div /> or <span /> in
// regular ReactDOM. The general rule is that Fiber components are available under
// the camel-case version of their name in three.js.
<>
<CameraControls />
<Stats />
{scene}
</>
)
}
function App() {
return (
<Canvas shadows style={{ background: "#e9e9e9", width: "100vw", height: "100vh" }}>
<Scene />
</Canvas>
)
}
export default App