@primestyleai/tryon 5.8.38 → 5.8.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/face-detect.d.ts +54 -0
- package/dist/react/index.js +213 -9
- package/dist/storefront/primestyle-tryon.js +213 -9
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Face landmark detection using MediaPipe FaceLandmarker.
|
|
3
|
+
*
|
|
4
|
+
* Runs entirely client-side. Uses iris diameter (~11.7 mm — biologically
|
|
5
|
+
* stable across adults) as the pixel→mm scale anchor, then derives the
|
|
6
|
+
* measurements that eyewear + headwear size guides ask for.
|
|
7
|
+
*
|
|
8
|
+
* Shares the same `@mediapipe/tasks-vision` bundle loaded by
|
|
9
|
+
* `pose-detect.ts`, so the only extra cost is the ~3 MB `.task` file.
|
|
10
|
+
*/
|
|
11
|
+
export interface Pt {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
z?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface FaceLandmarks {
|
|
17
|
+
noseTip: Pt;
|
|
18
|
+
noseBridge: Pt;
|
|
19
|
+
leftInnerEye: Pt;
|
|
20
|
+
rightInnerEye: Pt;
|
|
21
|
+
leftOuterEye: Pt;
|
|
22
|
+
rightOuterEye: Pt;
|
|
23
|
+
leftIrisCenter: Pt;
|
|
24
|
+
rightIrisCenter: Pt;
|
|
25
|
+
leftIrisRing: Pt[];
|
|
26
|
+
rightIrisRing: Pt[];
|
|
27
|
+
leftTragus: Pt;
|
|
28
|
+
rightTragus: Pt;
|
|
29
|
+
forehead: Pt;
|
|
30
|
+
chin: Pt;
|
|
31
|
+
leftMouth: Pt;
|
|
32
|
+
rightMouth: Pt;
|
|
33
|
+
}
|
|
34
|
+
export interface FaceMeasurementsMm {
|
|
35
|
+
irisDiameter: number;
|
|
36
|
+
pd: number;
|
|
37
|
+
bridgeWidth: number;
|
|
38
|
+
faceWidth: number;
|
|
39
|
+
templeLengthLeft: number;
|
|
40
|
+
templeLengthRight: number;
|
|
41
|
+
templeLength: number;
|
|
42
|
+
headWidth: number;
|
|
43
|
+
headDepth: number;
|
|
44
|
+
headCircumference: number;
|
|
45
|
+
}
|
|
46
|
+
export interface FaceDetectResult {
|
|
47
|
+
landmarks: FaceLandmarks;
|
|
48
|
+
measurementsMm: FaceMeasurementsMm;
|
|
49
|
+
irisConfidence: number;
|
|
50
|
+
imageWidth: number;
|
|
51
|
+
imageHeight: number;
|
|
52
|
+
}
|
|
53
|
+
/** Detect face landmarks and compute eyewear/headwear measurements in mm. */
|
|
54
|
+
export declare function detectFaceMeasurements(imageSrc: string | HTMLImageElement): Promise<FaceDetectResult | null>;
|
package/dist/react/index.js
CHANGED
|
@@ -17,11 +17,11 @@ const LEFT_ANKLE = 27;
|
|
|
17
17
|
const RIGHT_ANKLE = 28;
|
|
18
18
|
const NOSE = 0;
|
|
19
19
|
let poseLandmarker = null;
|
|
20
|
-
let loadingPromise = null;
|
|
20
|
+
let loadingPromise$1 = null;
|
|
21
21
|
async function loadMediaPipe() {
|
|
22
22
|
if (poseLandmarker) return;
|
|
23
|
-
if (loadingPromise) return loadingPromise;
|
|
24
|
-
loadingPromise = (async () => {
|
|
23
|
+
if (loadingPromise$1) return loadingPromise$1;
|
|
24
|
+
loadingPromise$1 = (async () => {
|
|
25
25
|
const vision = await import(
|
|
26
26
|
/* webpackIgnore: true */
|
|
27
27
|
// @ts-ignore dynamic CDN import
|
|
@@ -40,12 +40,12 @@ async function loadMediaPipe() {
|
|
|
40
40
|
numPoses: 1
|
|
41
41
|
});
|
|
42
42
|
})();
|
|
43
|
-
return loadingPromise;
|
|
43
|
+
return loadingPromise$1;
|
|
44
44
|
}
|
|
45
45
|
async function detectMeasurementLines(imageSrc) {
|
|
46
46
|
try {
|
|
47
47
|
await loadMediaPipe();
|
|
48
|
-
const img = await loadImage(imageSrc);
|
|
48
|
+
const img = await loadImage$1(imageSrc);
|
|
49
49
|
const result = poseLandmarker.detect(img);
|
|
50
50
|
if (!result?.landmarks?.length || result.landmarks[0].length < 25) {
|
|
51
51
|
return null;
|
|
@@ -80,7 +80,7 @@ async function detectMeasurementLines(imageSrc) {
|
|
|
80
80
|
return null;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
function loadImage(src) {
|
|
83
|
+
function loadImage$1(src) {
|
|
84
84
|
return new Promise((resolve, reject) => {
|
|
85
85
|
const img = new Image();
|
|
86
86
|
img.crossOrigin = "anonymous";
|
|
@@ -99,7 +99,7 @@ async function detectBodyLandmarks(imageSrc) {
|
|
|
99
99
|
await loadMediaPipe();
|
|
100
100
|
let img;
|
|
101
101
|
if (typeof imageSrc === "string") {
|
|
102
|
-
img = await loadImage(imageSrc);
|
|
102
|
+
img = await loadImage$1(imageSrc);
|
|
103
103
|
} else {
|
|
104
104
|
img = imageSrc;
|
|
105
105
|
}
|
|
@@ -128,6 +128,165 @@ async function detectBodyLandmarks(imageSrc) {
|
|
|
128
128
|
return null;
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
const IDX = {
|
|
132
|
+
noseTip: 1,
|
|
133
|
+
noseBridge: 168,
|
|
134
|
+
leftInnerEye: 133,
|
|
135
|
+
rightInnerEye: 362,
|
|
136
|
+
leftOuterEye: 33,
|
|
137
|
+
rightOuterEye: 263,
|
|
138
|
+
// Iris landmarks (478-point model with iris refinement)
|
|
139
|
+
leftIrisCenter: 468,
|
|
140
|
+
leftIrisRing: [469, 470, 471, 472],
|
|
141
|
+
rightIrisCenter: 473,
|
|
142
|
+
rightIrisRing: [474, 475, 476, 477],
|
|
143
|
+
// Tragus (ear attach point) — best approximations in the face mesh
|
|
144
|
+
leftTragus: 234,
|
|
145
|
+
rightTragus: 454,
|
|
146
|
+
forehead: 10,
|
|
147
|
+
chin: 152,
|
|
148
|
+
leftMouth: 61,
|
|
149
|
+
rightMouth: 291
|
|
150
|
+
};
|
|
151
|
+
const IRIS_DIAMETER_MM = 11.7;
|
|
152
|
+
let faceLandmarker = null;
|
|
153
|
+
let loadingPromise = null;
|
|
154
|
+
async function loadFaceLandmarker() {
|
|
155
|
+
if (faceLandmarker) return;
|
|
156
|
+
if (loadingPromise) return loadingPromise;
|
|
157
|
+
loadingPromise = (async () => {
|
|
158
|
+
const vision = await import(
|
|
159
|
+
/* webpackIgnore: true */
|
|
160
|
+
// @ts-ignore dynamic CDN import
|
|
161
|
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
|
|
162
|
+
);
|
|
163
|
+
const { FilesetResolver, FaceLandmarker } = vision;
|
|
164
|
+
const filesetResolver = await FilesetResolver.forVisionTasks(
|
|
165
|
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
|
|
166
|
+
);
|
|
167
|
+
faceLandmarker = await FaceLandmarker.createFromOptions(filesetResolver, {
|
|
168
|
+
baseOptions: {
|
|
169
|
+
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task",
|
|
170
|
+
delegate: "GPU"
|
|
171
|
+
},
|
|
172
|
+
runningMode: "IMAGE",
|
|
173
|
+
numFaces: 1,
|
|
174
|
+
outputFaceBlendshapes: false,
|
|
175
|
+
outputFacialTransformationMatrixes: false
|
|
176
|
+
});
|
|
177
|
+
})();
|
|
178
|
+
return loadingPromise;
|
|
179
|
+
}
|
|
180
|
+
function loadImage(src) {
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const img = new Image();
|
|
183
|
+
img.crossOrigin = "anonymous";
|
|
184
|
+
img.onload = () => resolve(img);
|
|
185
|
+
img.onerror = () => {
|
|
186
|
+
const img2 = new Image();
|
|
187
|
+
img2.onload = () => resolve(img2);
|
|
188
|
+
img2.onerror = () => reject(new Error("Failed to load image"));
|
|
189
|
+
img2.src = src;
|
|
190
|
+
};
|
|
191
|
+
img.src = src;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function irisDiameterPx(ring, imageWidth, imageHeight) {
|
|
195
|
+
if (ring.length < 4) return 0;
|
|
196
|
+
const toPx = (p) => ({ x: p.x * imageWidth, y: p.y * imageHeight });
|
|
197
|
+
const [p0, p1, p2, p3] = ring.map(toPx);
|
|
198
|
+
const d1 = Math.hypot(p0.x - p2.x, p0.y - p2.y);
|
|
199
|
+
const d2 = Math.hypot(p1.x - p3.x, p1.y - p3.y);
|
|
200
|
+
return (d1 + d2) / 2;
|
|
201
|
+
}
|
|
202
|
+
function extractLandmarks(raw) {
|
|
203
|
+
if (raw.length < 478) return null;
|
|
204
|
+
const at = (i) => ({ x: raw[i].x, y: raw[i].y, z: raw[i].z });
|
|
205
|
+
return {
|
|
206
|
+
noseTip: at(IDX.noseTip),
|
|
207
|
+
noseBridge: at(IDX.noseBridge),
|
|
208
|
+
leftInnerEye: at(IDX.leftInnerEye),
|
|
209
|
+
rightInnerEye: at(IDX.rightInnerEye),
|
|
210
|
+
leftOuterEye: at(IDX.leftOuterEye),
|
|
211
|
+
rightOuterEye: at(IDX.rightOuterEye),
|
|
212
|
+
leftIrisCenter: at(IDX.leftIrisCenter),
|
|
213
|
+
rightIrisCenter: at(IDX.rightIrisCenter),
|
|
214
|
+
leftIrisRing: IDX.leftIrisRing.map(at),
|
|
215
|
+
rightIrisRing: IDX.rightIrisRing.map(at),
|
|
216
|
+
leftTragus: at(IDX.leftTragus),
|
|
217
|
+
rightTragus: at(IDX.rightTragus),
|
|
218
|
+
forehead: at(IDX.forehead),
|
|
219
|
+
chin: at(IDX.chin),
|
|
220
|
+
leftMouth: at(IDX.leftMouth),
|
|
221
|
+
rightMouth: at(IDX.rightMouth)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function computeMeasurements(lm, imageWidth, imageHeight) {
|
|
225
|
+
const leftPx = irisDiameterPx(lm.leftIrisRing, imageWidth, imageHeight);
|
|
226
|
+
const rightPx = irisDiameterPx(lm.rightIrisRing, imageWidth, imageHeight);
|
|
227
|
+
const irisPx = (leftPx + rightPx) / 2;
|
|
228
|
+
let irisConfidence = 1;
|
|
229
|
+
if (irisPx < 8) irisConfidence = 0.3;
|
|
230
|
+
else if (Math.abs(leftPx - rightPx) / irisPx > 0.3) irisConfidence = 0.5;
|
|
231
|
+
else if (Math.abs(leftPx - rightPx) / irisPx > 0.15) irisConfidence = 0.8;
|
|
232
|
+
const pxToMm = irisPx > 0 ? IRIS_DIAMETER_MM / irisPx : 0;
|
|
233
|
+
const mmBetween = (a2, b2) => {
|
|
234
|
+
const pxDist = Math.hypot(
|
|
235
|
+
(a2.x - b2.x) * imageWidth,
|
|
236
|
+
(a2.y - b2.y) * imageHeight
|
|
237
|
+
);
|
|
238
|
+
return pxDist * pxToMm;
|
|
239
|
+
};
|
|
240
|
+
const pd = mmBetween(lm.leftIrisCenter, lm.rightIrisCenter);
|
|
241
|
+
const bridgeWidth = mmBetween(lm.leftInnerEye, lm.rightInnerEye);
|
|
242
|
+
const faceWidth = mmBetween(lm.leftTragus, lm.rightTragus);
|
|
243
|
+
const templeLengthLeft = mmBetween(lm.leftTragus, lm.leftOuterEye);
|
|
244
|
+
const templeLengthRight = mmBetween(lm.rightTragus, lm.rightOuterEye);
|
|
245
|
+
const templeLength = (templeLengthLeft + templeLengthRight) / 2;
|
|
246
|
+
const headWidth = faceWidth * 1.07;
|
|
247
|
+
const zDepthNorm = Math.abs((lm.forehead.z ?? 0) - (lm.chin.z ?? 0));
|
|
248
|
+
const rawHeadDepthMm = zDepthNorm * imageWidth * pxToMm;
|
|
249
|
+
const headDepth = Math.max(170, Math.min(210, rawHeadDepthMm || 190));
|
|
250
|
+
const a = headWidth / 2;
|
|
251
|
+
const b = headDepth / 2;
|
|
252
|
+
const headCircumference = Math.PI * Math.sqrt(2 * (a * a + b * b));
|
|
253
|
+
return {
|
|
254
|
+
measurements: {
|
|
255
|
+
irisDiameter: IRIS_DIAMETER_MM,
|
|
256
|
+
pd: round1(pd),
|
|
257
|
+
bridgeWidth: round1(bridgeWidth),
|
|
258
|
+
faceWidth: round1(faceWidth),
|
|
259
|
+
templeLengthLeft: round1(templeLengthLeft),
|
|
260
|
+
templeLengthRight: round1(templeLengthRight),
|
|
261
|
+
templeLength: round1(templeLength),
|
|
262
|
+
headWidth: round1(headWidth),
|
|
263
|
+
headDepth: round1(headDepth),
|
|
264
|
+
headCircumference: round1(headCircumference)
|
|
265
|
+
},
|
|
266
|
+
irisConfidence
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function round1(n) {
|
|
270
|
+
return Math.round(n * 10) / 10;
|
|
271
|
+
}
|
|
272
|
+
async function detectFaceMeasurements(imageSrc) {
|
|
273
|
+
try {
|
|
274
|
+
await loadFaceLandmarker();
|
|
275
|
+
const img = typeof imageSrc === "string" ? await loadImage(imageSrc) : imageSrc;
|
|
276
|
+
const result = faceLandmarker.detect(img);
|
|
277
|
+
if (!result?.faceLandmarks?.length) return null;
|
|
278
|
+
const raw = result.faceLandmarks[0];
|
|
279
|
+
const landmarks = extractLandmarks(raw);
|
|
280
|
+
if (!landmarks) return null;
|
|
281
|
+
const imageWidth = img.naturalWidth || img.width;
|
|
282
|
+
const imageHeight = img.naturalHeight || img.height;
|
|
283
|
+
const { measurements, irisConfidence } = computeMeasurements(landmarks, imageWidth, imageHeight);
|
|
284
|
+
return { landmarks, measurementsMm: measurements, irisConfidence, imageWidth, imageHeight };
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error("[PS-SDK] Face detection failed:", err);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
131
290
|
function parseRange(s) {
|
|
132
291
|
const ns = s.replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n) => !isNaN(n));
|
|
133
292
|
return ns.length ? { min: Math.min(...ns), max: Math.max(...ns) } : { min: 0, max: 0 };
|
|
@@ -12118,7 +12277,6 @@ function HeadSizeView(props) {
|
|
|
12118
12277
|
title: "Headwear Measurements",
|
|
12119
12278
|
fields,
|
|
12120
12279
|
photoVariant: "close-up",
|
|
12121
|
-
disablePhotoUpload: true,
|
|
12122
12280
|
...rest
|
|
12123
12281
|
}
|
|
12124
12282
|
);
|
|
@@ -12178,7 +12336,6 @@ function FaceSizeView(props) {
|
|
|
12178
12336
|
fields,
|
|
12179
12337
|
unitOptions: EYEWEAR_UNIT_OPTIONS,
|
|
12180
12338
|
photoVariant: "close-up",
|
|
12181
|
-
disablePhotoUpload: true,
|
|
12182
12339
|
...rest
|
|
12183
12340
|
}
|
|
12184
12341
|
);
|
|
@@ -12866,6 +13023,53 @@ function PrimeStyleTryonInner({
|
|
|
12866
13023
|
setSizingLoading(true);
|
|
12867
13024
|
setEstimationDone(false);
|
|
12868
13025
|
setView("size-result");
|
|
13026
|
+
const measurementType = detectMeasurementType(productTitle);
|
|
13027
|
+
if (measurementType === "face" || measurementType === "head") {
|
|
13028
|
+
try {
|
|
13029
|
+
const faceResult = await detectFaceMeasurements(objUrl);
|
|
13030
|
+
const facePayload = {
|
|
13031
|
+
product: { title: productTitle },
|
|
13032
|
+
sizeGuide: sizeGuide ?? { found: false },
|
|
13033
|
+
sizingUnit: measurementType === "head" ? "cm" : "mm",
|
|
13034
|
+
category: measurementType,
|
|
13035
|
+
bodyImage: data.photoBase64
|
|
13036
|
+
};
|
|
13037
|
+
if (faceResult) {
|
|
13038
|
+
facePayload.faceMeasurementsMm = faceResult.measurementsMm;
|
|
13039
|
+
facePayload.faceLandmarks = faceResult.landmarks;
|
|
13040
|
+
facePayload.irisConfidence = faceResult.irisConfidence;
|
|
13041
|
+
}
|
|
13042
|
+
const recRes = await fetch(`${baseUrl}/api/v1/sizing/face-recommend`, {
|
|
13043
|
+
method: "POST",
|
|
13044
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
13045
|
+
body: JSON.stringify(facePayload)
|
|
13046
|
+
});
|
|
13047
|
+
if (recRes.ok) {
|
|
13048
|
+
const recData = await recRes.json();
|
|
13049
|
+
setSizingResult(recData);
|
|
13050
|
+
onComplete?.(recData);
|
|
13051
|
+
persistResultToProfile(
|
|
13052
|
+
{
|
|
13053
|
+
gender: data.gender,
|
|
13054
|
+
height: data.height,
|
|
13055
|
+
weight: data.weight,
|
|
13056
|
+
heightUnit: data.heightUnit,
|
|
13057
|
+
weightUnit: data.weightUnit,
|
|
13058
|
+
age: data.age,
|
|
13059
|
+
bodyImage: data.photoBase64
|
|
13060
|
+
},
|
|
13061
|
+
recData
|
|
13062
|
+
);
|
|
13063
|
+
} else {
|
|
13064
|
+
setEstimationDone(true);
|
|
13065
|
+
}
|
|
13066
|
+
} catch (err) {
|
|
13067
|
+
console.error("[ps-sdk] face-recommend failed:", err);
|
|
13068
|
+
setEstimationDone(true);
|
|
13069
|
+
}
|
|
13070
|
+
setSizingLoading(false);
|
|
13071
|
+
return;
|
|
13072
|
+
}
|
|
12869
13073
|
modelPoseRef.current = null;
|
|
12870
13074
|
setBodyLandmarks(null);
|
|
12871
13075
|
detectMeasurementLines(objUrl).then((lines) => {
|
|
@@ -9479,11 +9479,11 @@ const LEFT_ANKLE = 27;
|
|
|
9479
9479
|
const RIGHT_ANKLE = 28;
|
|
9480
9480
|
const NOSE = 0;
|
|
9481
9481
|
let poseLandmarker = null;
|
|
9482
|
-
let loadingPromise = null;
|
|
9482
|
+
let loadingPromise$1 = null;
|
|
9483
9483
|
async function loadMediaPipe() {
|
|
9484
9484
|
if (poseLandmarker) return;
|
|
9485
|
-
if (loadingPromise) return loadingPromise;
|
|
9486
|
-
loadingPromise = (async () => {
|
|
9485
|
+
if (loadingPromise$1) return loadingPromise$1;
|
|
9486
|
+
loadingPromise$1 = (async () => {
|
|
9487
9487
|
const vision = await import(
|
|
9488
9488
|
/* webpackIgnore: true */
|
|
9489
9489
|
// @ts-ignore dynamic CDN import
|
|
@@ -9502,12 +9502,12 @@ async function loadMediaPipe() {
|
|
|
9502
9502
|
numPoses: 1
|
|
9503
9503
|
});
|
|
9504
9504
|
})();
|
|
9505
|
-
return loadingPromise;
|
|
9505
|
+
return loadingPromise$1;
|
|
9506
9506
|
}
|
|
9507
9507
|
async function detectMeasurementLines(imageSrc) {
|
|
9508
9508
|
try {
|
|
9509
9509
|
await loadMediaPipe();
|
|
9510
|
-
const img = await loadImage(imageSrc);
|
|
9510
|
+
const img = await loadImage$1(imageSrc);
|
|
9511
9511
|
const result = poseLandmarker.detect(img);
|
|
9512
9512
|
if (!result?.landmarks?.length || result.landmarks[0].length < 25) {
|
|
9513
9513
|
return null;
|
|
@@ -9542,7 +9542,7 @@ async function detectMeasurementLines(imageSrc) {
|
|
|
9542
9542
|
return null;
|
|
9543
9543
|
}
|
|
9544
9544
|
}
|
|
9545
|
-
function loadImage(src) {
|
|
9545
|
+
function loadImage$1(src) {
|
|
9546
9546
|
return new Promise((resolve, reject) => {
|
|
9547
9547
|
const img = new Image();
|
|
9548
9548
|
img.crossOrigin = "anonymous";
|
|
@@ -9561,7 +9561,7 @@ async function detectBodyLandmarks(imageSrc) {
|
|
|
9561
9561
|
await loadMediaPipe();
|
|
9562
9562
|
let img;
|
|
9563
9563
|
if (typeof imageSrc === "string") {
|
|
9564
|
-
img = await loadImage(imageSrc);
|
|
9564
|
+
img = await loadImage$1(imageSrc);
|
|
9565
9565
|
} else {
|
|
9566
9566
|
img = imageSrc;
|
|
9567
9567
|
}
|
|
@@ -9590,6 +9590,165 @@ async function detectBodyLandmarks(imageSrc) {
|
|
|
9590
9590
|
return null;
|
|
9591
9591
|
}
|
|
9592
9592
|
}
|
|
9593
|
+
const IDX = {
|
|
9594
|
+
noseTip: 1,
|
|
9595
|
+
noseBridge: 168,
|
|
9596
|
+
leftInnerEye: 133,
|
|
9597
|
+
rightInnerEye: 362,
|
|
9598
|
+
leftOuterEye: 33,
|
|
9599
|
+
rightOuterEye: 263,
|
|
9600
|
+
// Iris landmarks (478-point model with iris refinement)
|
|
9601
|
+
leftIrisCenter: 468,
|
|
9602
|
+
leftIrisRing: [469, 470, 471, 472],
|
|
9603
|
+
rightIrisCenter: 473,
|
|
9604
|
+
rightIrisRing: [474, 475, 476, 477],
|
|
9605
|
+
// Tragus (ear attach point) — best approximations in the face mesh
|
|
9606
|
+
leftTragus: 234,
|
|
9607
|
+
rightTragus: 454,
|
|
9608
|
+
forehead: 10,
|
|
9609
|
+
chin: 152,
|
|
9610
|
+
leftMouth: 61,
|
|
9611
|
+
rightMouth: 291
|
|
9612
|
+
};
|
|
9613
|
+
const IRIS_DIAMETER_MM = 11.7;
|
|
9614
|
+
let faceLandmarker = null;
|
|
9615
|
+
let loadingPromise = null;
|
|
9616
|
+
async function loadFaceLandmarker() {
|
|
9617
|
+
if (faceLandmarker) return;
|
|
9618
|
+
if (loadingPromise) return loadingPromise;
|
|
9619
|
+
loadingPromise = (async () => {
|
|
9620
|
+
const vision = await import(
|
|
9621
|
+
/* webpackIgnore: true */
|
|
9622
|
+
// @ts-ignore dynamic CDN import
|
|
9623
|
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
|
|
9624
|
+
);
|
|
9625
|
+
const { FilesetResolver, FaceLandmarker } = vision;
|
|
9626
|
+
const filesetResolver = await FilesetResolver.forVisionTasks(
|
|
9627
|
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
|
|
9628
|
+
);
|
|
9629
|
+
faceLandmarker = await FaceLandmarker.createFromOptions(filesetResolver, {
|
|
9630
|
+
baseOptions: {
|
|
9631
|
+
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task",
|
|
9632
|
+
delegate: "GPU"
|
|
9633
|
+
},
|
|
9634
|
+
runningMode: "IMAGE",
|
|
9635
|
+
numFaces: 1,
|
|
9636
|
+
outputFaceBlendshapes: false,
|
|
9637
|
+
outputFacialTransformationMatrixes: false
|
|
9638
|
+
});
|
|
9639
|
+
})();
|
|
9640
|
+
return loadingPromise;
|
|
9641
|
+
}
|
|
9642
|
+
function loadImage(src) {
|
|
9643
|
+
return new Promise((resolve, reject) => {
|
|
9644
|
+
const img = new Image();
|
|
9645
|
+
img.crossOrigin = "anonymous";
|
|
9646
|
+
img.onload = () => resolve(img);
|
|
9647
|
+
img.onerror = () => {
|
|
9648
|
+
const img2 = new Image();
|
|
9649
|
+
img2.onload = () => resolve(img2);
|
|
9650
|
+
img2.onerror = () => reject(new Error("Failed to load image"));
|
|
9651
|
+
img2.src = src;
|
|
9652
|
+
};
|
|
9653
|
+
img.src = src;
|
|
9654
|
+
});
|
|
9655
|
+
}
|
|
9656
|
+
function irisDiameterPx(ring, imageWidth, imageHeight) {
|
|
9657
|
+
if (ring.length < 4) return 0;
|
|
9658
|
+
const toPx = (p4) => ({ x: p4.x * imageWidth, y: p4.y * imageHeight });
|
|
9659
|
+
const [p0, p1, p2, p3] = ring.map(toPx);
|
|
9660
|
+
const d1 = Math.hypot(p0.x - p2.x, p0.y - p2.y);
|
|
9661
|
+
const d2 = Math.hypot(p1.x - p3.x, p1.y - p3.y);
|
|
9662
|
+
return (d1 + d2) / 2;
|
|
9663
|
+
}
|
|
9664
|
+
function extractLandmarks(raw) {
|
|
9665
|
+
if (raw.length < 478) return null;
|
|
9666
|
+
const at = (i) => ({ x: raw[i].x, y: raw[i].y, z: raw[i].z });
|
|
9667
|
+
return {
|
|
9668
|
+
noseTip: at(IDX.noseTip),
|
|
9669
|
+
noseBridge: at(IDX.noseBridge),
|
|
9670
|
+
leftInnerEye: at(IDX.leftInnerEye),
|
|
9671
|
+
rightInnerEye: at(IDX.rightInnerEye),
|
|
9672
|
+
leftOuterEye: at(IDX.leftOuterEye),
|
|
9673
|
+
rightOuterEye: at(IDX.rightOuterEye),
|
|
9674
|
+
leftIrisCenter: at(IDX.leftIrisCenter),
|
|
9675
|
+
rightIrisCenter: at(IDX.rightIrisCenter),
|
|
9676
|
+
leftIrisRing: IDX.leftIrisRing.map(at),
|
|
9677
|
+
rightIrisRing: IDX.rightIrisRing.map(at),
|
|
9678
|
+
leftTragus: at(IDX.leftTragus),
|
|
9679
|
+
rightTragus: at(IDX.rightTragus),
|
|
9680
|
+
forehead: at(IDX.forehead),
|
|
9681
|
+
chin: at(IDX.chin),
|
|
9682
|
+
leftMouth: at(IDX.leftMouth),
|
|
9683
|
+
rightMouth: at(IDX.rightMouth)
|
|
9684
|
+
};
|
|
9685
|
+
}
|
|
9686
|
+
function computeMeasurements(lm, imageWidth, imageHeight) {
|
|
9687
|
+
const leftPx = irisDiameterPx(lm.leftIrisRing, imageWidth, imageHeight);
|
|
9688
|
+
const rightPx = irisDiameterPx(lm.rightIrisRing, imageWidth, imageHeight);
|
|
9689
|
+
const irisPx = (leftPx + rightPx) / 2;
|
|
9690
|
+
let irisConfidence = 1;
|
|
9691
|
+
if (irisPx < 8) irisConfidence = 0.3;
|
|
9692
|
+
else if (Math.abs(leftPx - rightPx) / irisPx > 0.3) irisConfidence = 0.5;
|
|
9693
|
+
else if (Math.abs(leftPx - rightPx) / irisPx > 0.15) irisConfidence = 0.8;
|
|
9694
|
+
const pxToMm = irisPx > 0 ? IRIS_DIAMETER_MM / irisPx : 0;
|
|
9695
|
+
const mmBetween = (a2, b2) => {
|
|
9696
|
+
const pxDist = Math.hypot(
|
|
9697
|
+
(a2.x - b2.x) * imageWidth,
|
|
9698
|
+
(a2.y - b2.y) * imageHeight
|
|
9699
|
+
);
|
|
9700
|
+
return pxDist * pxToMm;
|
|
9701
|
+
};
|
|
9702
|
+
const pd2 = mmBetween(lm.leftIrisCenter, lm.rightIrisCenter);
|
|
9703
|
+
const bridgeWidth = mmBetween(lm.leftInnerEye, lm.rightInnerEye);
|
|
9704
|
+
const faceWidth = mmBetween(lm.leftTragus, lm.rightTragus);
|
|
9705
|
+
const templeLengthLeft = mmBetween(lm.leftTragus, lm.leftOuterEye);
|
|
9706
|
+
const templeLengthRight = mmBetween(lm.rightTragus, lm.rightOuterEye);
|
|
9707
|
+
const templeLength = (templeLengthLeft + templeLengthRight) / 2;
|
|
9708
|
+
const headWidth = faceWidth * 1.07;
|
|
9709
|
+
const zDepthNorm = Math.abs((lm.forehead.z ?? 0) - (lm.chin.z ?? 0));
|
|
9710
|
+
const rawHeadDepthMm = zDepthNorm * imageWidth * pxToMm;
|
|
9711
|
+
const headDepth = Math.max(170, Math.min(210, rawHeadDepthMm || 190));
|
|
9712
|
+
const a = headWidth / 2;
|
|
9713
|
+
const b = headDepth / 2;
|
|
9714
|
+
const headCircumference = Math.PI * Math.sqrt(2 * (a * a + b * b));
|
|
9715
|
+
return {
|
|
9716
|
+
measurements: {
|
|
9717
|
+
irisDiameter: IRIS_DIAMETER_MM,
|
|
9718
|
+
pd: round1(pd2),
|
|
9719
|
+
bridgeWidth: round1(bridgeWidth),
|
|
9720
|
+
faceWidth: round1(faceWidth),
|
|
9721
|
+
templeLengthLeft: round1(templeLengthLeft),
|
|
9722
|
+
templeLengthRight: round1(templeLengthRight),
|
|
9723
|
+
templeLength: round1(templeLength),
|
|
9724
|
+
headWidth: round1(headWidth),
|
|
9725
|
+
headDepth: round1(headDepth),
|
|
9726
|
+
headCircumference: round1(headCircumference)
|
|
9727
|
+
},
|
|
9728
|
+
irisConfidence
|
|
9729
|
+
};
|
|
9730
|
+
}
|
|
9731
|
+
function round1(n2) {
|
|
9732
|
+
return Math.round(n2 * 10) / 10;
|
|
9733
|
+
}
|
|
9734
|
+
async function detectFaceMeasurements(imageSrc) {
|
|
9735
|
+
try {
|
|
9736
|
+
await loadFaceLandmarker();
|
|
9737
|
+
const img = typeof imageSrc === "string" ? await loadImage(imageSrc) : imageSrc;
|
|
9738
|
+
const result = faceLandmarker.detect(img);
|
|
9739
|
+
if (!result?.faceLandmarks?.length) return null;
|
|
9740
|
+
const raw = result.faceLandmarks[0];
|
|
9741
|
+
const landmarks = extractLandmarks(raw);
|
|
9742
|
+
if (!landmarks) return null;
|
|
9743
|
+
const imageWidth = img.naturalWidth || img.width;
|
|
9744
|
+
const imageHeight = img.naturalHeight || img.height;
|
|
9745
|
+
const { measurements, irisConfidence } = computeMeasurements(landmarks, imageWidth, imageHeight);
|
|
9746
|
+
return { landmarks, measurementsMm: measurements, irisConfidence, imageWidth, imageHeight };
|
|
9747
|
+
} catch (err) {
|
|
9748
|
+
console.error("[PS-SDK] Face detection failed:", err);
|
|
9749
|
+
return null;
|
|
9750
|
+
}
|
|
9751
|
+
}
|
|
9593
9752
|
function parseRange(s) {
|
|
9594
9753
|
const ns = s.replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n2) => !isNaN(n2));
|
|
9595
9754
|
return ns.length ? { min: Math.min(...ns), max: Math.max(...ns) } : { min: 0, max: 0 };
|
|
@@ -21542,7 +21701,6 @@ function HeadSizeView(props) {
|
|
|
21542
21701
|
title: "Headwear Measurements",
|
|
21543
21702
|
fields,
|
|
21544
21703
|
photoVariant: "close-up",
|
|
21545
|
-
disablePhotoUpload: true,
|
|
21546
21704
|
...rest
|
|
21547
21705
|
}
|
|
21548
21706
|
);
|
|
@@ -21602,7 +21760,6 @@ function FaceSizeView(props) {
|
|
|
21602
21760
|
fields,
|
|
21603
21761
|
unitOptions: EYEWEAR_UNIT_OPTIONS,
|
|
21604
21762
|
photoVariant: "close-up",
|
|
21605
|
-
disablePhotoUpload: true,
|
|
21606
21763
|
...rest
|
|
21607
21764
|
}
|
|
21608
21765
|
);
|
|
@@ -22290,6 +22447,53 @@ function PrimeStyleTryonInner({
|
|
|
22290
22447
|
setSizingLoading(true);
|
|
22291
22448
|
setEstimationDone(false);
|
|
22292
22449
|
setView("size-result");
|
|
22450
|
+
const measurementType = detectMeasurementType(productTitle);
|
|
22451
|
+
if (measurementType === "face" || measurementType === "head") {
|
|
22452
|
+
try {
|
|
22453
|
+
const faceResult = await detectFaceMeasurements(objUrl);
|
|
22454
|
+
const facePayload = {
|
|
22455
|
+
product: { title: productTitle },
|
|
22456
|
+
sizeGuide: sizeGuide ?? { found: false },
|
|
22457
|
+
sizingUnit: measurementType === "head" ? "cm" : "mm",
|
|
22458
|
+
category: measurementType,
|
|
22459
|
+
bodyImage: data.photoBase64
|
|
22460
|
+
};
|
|
22461
|
+
if (faceResult) {
|
|
22462
|
+
facePayload.faceMeasurementsMm = faceResult.measurementsMm;
|
|
22463
|
+
facePayload.faceLandmarks = faceResult.landmarks;
|
|
22464
|
+
facePayload.irisConfidence = faceResult.irisConfidence;
|
|
22465
|
+
}
|
|
22466
|
+
const recRes = await fetch(`${baseUrl}/api/v1/sizing/face-recommend`, {
|
|
22467
|
+
method: "POST",
|
|
22468
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
22469
|
+
body: JSON.stringify(facePayload)
|
|
22470
|
+
});
|
|
22471
|
+
if (recRes.ok) {
|
|
22472
|
+
const recData = await recRes.json();
|
|
22473
|
+
setSizingResult(recData);
|
|
22474
|
+
onComplete?.(recData);
|
|
22475
|
+
persistResultToProfile(
|
|
22476
|
+
{
|
|
22477
|
+
gender: data.gender,
|
|
22478
|
+
height: data.height,
|
|
22479
|
+
weight: data.weight,
|
|
22480
|
+
heightUnit: data.heightUnit,
|
|
22481
|
+
weightUnit: data.weightUnit,
|
|
22482
|
+
age: data.age,
|
|
22483
|
+
bodyImage: data.photoBase64
|
|
22484
|
+
},
|
|
22485
|
+
recData
|
|
22486
|
+
);
|
|
22487
|
+
} else {
|
|
22488
|
+
setEstimationDone(true);
|
|
22489
|
+
}
|
|
22490
|
+
} catch (err) {
|
|
22491
|
+
console.error("[ps-sdk] face-recommend failed:", err);
|
|
22492
|
+
setEstimationDone(true);
|
|
22493
|
+
}
|
|
22494
|
+
setSizingLoading(false);
|
|
22495
|
+
return;
|
|
22496
|
+
}
|
|
22293
22497
|
modelPoseRef.current = null;
|
|
22294
22498
|
setBodyLandmarks(null);
|
|
22295
22499
|
detectMeasurementLines(objUrl).then((lines) => {
|