@rig-c/pose 0.16.0 → 0.18.0
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/README.md +77 -14
- package/cli.ts +73 -17
- package/package.json +1 -1
- package/src/clean.ts +346 -10
- package/src/legs.ts +423 -41
- package/src/map.ts +49 -3
- package/src/pose.ts +322 -24
- package/src/reduce.ts +167 -7
- package/src/retarget.ts +159 -48
- package/src/run.ts +251 -27
- package/src/switch.ts +31 -8
- package/src/verify.ts +29 -4
package/src/map.ts
CHANGED
|
@@ -50,6 +50,9 @@
|
|
|
50
50
|
* directions are read), holding every joint its `chains` use; `drawn` is each
|
|
51
51
|
* bone's world angle as painted (a reading: `artTurn`); `offsets` is a
|
|
52
52
|
* translate per bone, in its parent's axes, held while the set shows.
|
|
53
|
+
* `place: "by-hip"` (rig-c/pose#93) places the wrist of each of its chains that
|
|
54
|
+
* `pose` puts within one hip width of the mid-hip by its offset from the hip
|
|
55
|
+
* (`byHipChains`; the retarget solves it).
|
|
53
56
|
* `switch.ts` decides when each one shows.
|
|
54
57
|
*/
|
|
55
58
|
import type { RawBone } from 'rig-c/core';
|
|
@@ -95,6 +98,8 @@ export interface AlternateSet {
|
|
|
95
98
|
turn: Record<string, [number, number]>;
|
|
96
99
|
/** A stopgap: the only frames the set may show on, a list of [first, last] windows; on them it is chosen over any set without windows. Null: any frame. */
|
|
97
100
|
frames: Array<[number, number]> | null;
|
|
101
|
+
/** `place: "by-hip"` (rig-c/pose#93): the chains whose wrist `pose` puts within one hip width of the mid-hip, their wrists placed by the offset from the hip while the set shows. Absent or null: the pose's angles. */
|
|
102
|
+
place?: { by: 'hip'; chains: string[] } | null;
|
|
98
103
|
}
|
|
99
104
|
export interface JointMap {
|
|
100
105
|
chains: Chain[];
|
|
@@ -228,7 +233,39 @@ export function readMap(text: string, where: string): JointMap {
|
|
|
228
233
|
return { chains, translate, alternates };
|
|
229
234
|
}
|
|
230
235
|
|
|
231
|
-
const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …] }';
|
|
236
|
+
const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip" }';
|
|
237
|
+
|
|
238
|
+
/** The joints a wrist is, for `place: "by-hip"`: a chain whose last joint is one of them carries a wrist. */
|
|
239
|
+
export const WRISTS: readonly string[] = ['r_wrist', 'l_wrist'];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `place: "by-hip"` (rig-c/pose#93): the set's chains that carry a wrist (their
|
|
243
|
+
* last joint) which its key skeleton puts within one hip width of the mid-hip —
|
|
244
|
+
* |wrist − (r_hip + l_hip) / 2| ≤ |r_hip − l_hip| — and each wrist chain's
|
|
245
|
+
* distance in hip widths; or why the key skeleton cannot be read so.
|
|
246
|
+
*/
|
|
247
|
+
export function byHipChains(pose: Readonly<Record<string, readonly [number, number]>>, chains: readonly Chain[]): { chains: string[]; distances: Array<{ chain: string; wrist: string; hipWidths: number }> } | { problem: string } {
|
|
248
|
+
const r = pose.r_hip;
|
|
249
|
+
const l = pose.l_hip;
|
|
250
|
+
if (r === undefined || l === undefined) return { problem: `its "pose" has no ${[r === undefined ? '"r_hip"' : '', l === undefined ? '"l_hip"' : ''].filter((x) => x !== '').join(' or ')}; "by-hip" reads the hip width and the mid-hip off the key skeleton's two hips, which the map's author supplies` };
|
|
251
|
+
const noWrist = chains.filter((c) => WRISTS.includes(c.joints[c.joints.length - 1]!) && pose[c.joints[c.joints.length - 1]!] === undefined);
|
|
252
|
+
if (noWrist.length > 0) return { problem: noWrist.map((c) => `its "pose" has no "${c.joints[c.joints.length - 1]}", the wrist of chain "${c.name}"`).join('; ') + '; "by-hip" reads each wrist\'s distance from the mid-hip off the key skeleton' };
|
|
253
|
+
const width = Math.hypot(l[0] - r[0], l[1] - r[1]);
|
|
254
|
+
if (!(width > 1e-9)) return { problem: `its "pose" puts r_hip and l_hip at the same point; a hip width of 0 is no unit` };
|
|
255
|
+
const mid: [number, number] = [(r[0] + l[0]) / 2, (r[1] + l[1]) / 2];
|
|
256
|
+
const distances: Array<{ chain: string; wrist: string; hipWidths: number }> = [];
|
|
257
|
+
for (const c of chains) {
|
|
258
|
+
const wrist = c.joints[c.joints.length - 1]!;
|
|
259
|
+
if (!WRISTS.includes(wrist)) continue;
|
|
260
|
+
const w = pose[wrist];
|
|
261
|
+
if (w === undefined) continue;
|
|
262
|
+
distances.push({ chain: c.name, wrist, hipWidths: Math.hypot(w[0] - mid[0], w[1] - mid[1]) / width });
|
|
263
|
+
}
|
|
264
|
+
if (distances.length === 0) return { problem: `none of its chains (${chains.map((c) => c.name).join(', ')}) ends at a wrist (${WRISTS.join(', ')}); "by-hip" places a wrist` };
|
|
265
|
+
const near = distances.filter((d) => d.hipWidths <= 1);
|
|
266
|
+
if (near.length === 0) return { problem: `its "pose" puts no wrist within one hip width of the mid-hip (${distances.map((d) => `chain "${d.chain}" ${d.wrist} ${d.hipWidths.toFixed(2)} hip widths`).join(', ')}); "by-hip" places a hand that meets the body` };
|
|
267
|
+
return { chains: near.map((d) => d.chain), distances };
|
|
268
|
+
}
|
|
232
269
|
|
|
233
270
|
/** `alternates`: each set's shape, its chains against the map's, its key skeleton against those chains' joints, and its bones against those chains' bones. */
|
|
234
271
|
function readAlternates(p: Problems, where: string, raw: unknown, chains: readonly Chain[], translate: Translate | null): AlternateSet[] {
|
|
@@ -245,7 +282,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
|
|
|
245
282
|
p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
|
|
246
283
|
return;
|
|
247
284
|
}
|
|
248
|
-
refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames']);
|
|
285
|
+
refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place']);
|
|
249
286
|
const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
|
|
250
287
|
if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
|
|
251
288
|
else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
|
|
@@ -354,7 +391,16 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
|
|
|
354
391
|
p.add('MAP_ALT_FRAMES', `${label} "frames" is ${JSON.stringify(f)}; it must be a non-empty list of windows [[first, last], …], whole frame numbers ≥ 0 with first ≤ last`);
|
|
355
392
|
} else frames = (f as Array<[number, number]>).map((w) => [w[0], w[1]]);
|
|
356
393
|
}
|
|
357
|
-
|
|
394
|
+
let place: AlternateSet['place'] = null;
|
|
395
|
+
if ('place' in a) {
|
|
396
|
+
if (a.place !== 'by-hip') p.add('MAP_ALT_PLACE', `${label} "place" is ${JSON.stringify(a.place)}; the one placement a set may ask for is "by-hip"`);
|
|
397
|
+
else if (isObject(a.pose) && own.length > 0) {
|
|
398
|
+
const by = byHipChains(pose, own);
|
|
399
|
+
if ('problem' in by) p.add('MAP_ALT_PLACE', `${label} "place" is "by-hip", and ${by.problem}`);
|
|
400
|
+
else place = { by: 'hip', chains: by.chains };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }) });
|
|
358
404
|
});
|
|
359
405
|
return out;
|
|
360
406
|
}
|
package/src/pose.ts
CHANGED
|
@@ -70,16 +70,91 @@ export interface CleanSpike {
|
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* Who said the hips are `inferred` (#63): `declared` — the person running
|
|
73
|
-
* `rig-pose clean --hips-inferred` said so
|
|
74
|
-
*
|
|
73
|
+
* `rig-pose clean --hips-inferred` said so; `measured` (#86) — a tool read the
|
|
74
|
+
* pelvis line off the picture (`tools/pelvis.py`, after clean) and turned the
|
|
75
|
+
* hip pair to a measured angle on every frame. The exact shape:
|
|
76
|
+
*
|
|
77
|
+
* { by: "measured", method: string,
|
|
78
|
+
* read: Run[], interpolated: Run[], held: Run[],
|
|
79
|
+
* declined: Array<{ frames: Run, why: string }> } Run = [first, last]
|
|
80
|
+
*
|
|
81
|
+
* `method` names the reading. Runs are integer frame indices inside the
|
|
82
|
+
* sequence, first ≤ last, increasing and not overlapping within each list.
|
|
83
|
+
* Every frame of the sequence is in exactly one of `read` (the frame's own
|
|
84
|
+
* reading), `interpolated` (a declined frame between two reads close in time,
|
|
85
|
+
* the angle interpolated) and `held` (any other declined frame, the earlier
|
|
86
|
+
* read carried to the end of its run; at the clip's start, the later read). `declined` lists, with the reason, exactly the frames that are
|
|
87
|
+
* interpolated or held. Anything else is refused by name (POSE_CLEANED).
|
|
75
88
|
*/
|
|
76
|
-
export
|
|
77
|
-
|
|
89
|
+
export type Run = [number, number];
|
|
90
|
+
export type HipsInferred =
|
|
91
|
+
| { by: 'declared' }
|
|
92
|
+
| { by: 'measured'; method: string; read: Run[]; interpolated: Run[]; held: Run[]; declined: Array<{ frames: Run; why: string }> };
|
|
93
|
+
|
|
94
|
+
/** A copy of the record, in the writer's key order. */
|
|
95
|
+
export function copyHipsInferred(h: HipsInferred): HipsInferred {
|
|
96
|
+
const runs = (rs: Run[]): Run[] => rs.map(([a, b]) => [a, b]);
|
|
97
|
+
return h.by === 'declared'
|
|
98
|
+
? { by: 'declared' }
|
|
99
|
+
: { by: 'measured', method: h.method, read: runs(h.read), interpolated: runs(h.interpolated), held: runs(h.held), declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The frames a `measured` record gives an angle on (read, interpolated, held), as a set; null for any other record or none. */
|
|
103
|
+
export function measuredFrames(h: HipsInferred | undefined): Set<number> | null {
|
|
104
|
+
if (h === undefined || h.by !== 'measured') return null;
|
|
105
|
+
const out = new Set<number>();
|
|
106
|
+
for (const rs of [h.read, h.interpolated, h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The stillness hold's figures (#91): speed in trunk lengths per frame, reach in trunk lengths, the shortest hold in seconds, the blend in frames. */
|
|
111
|
+
export interface HoldParams {
|
|
112
|
+
speed: number;
|
|
113
|
+
reach: number;
|
|
114
|
+
minSeconds: number;
|
|
115
|
+
blend: number;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* One stillness hold of one joint: its frames (inclusive), and the joint's
|
|
119
|
+
* jitter inside them before and after, px/f². `after` is 0 on a hold held at
|
|
120
|
+
* its median throughout; on a hold that carries a seam's cross-fade it is the
|
|
121
|
+
* ramp's own curvature, expected and not noise (the lines print "ramp").
|
|
122
|
+
*/
|
|
123
|
+
export interface CleanHold {
|
|
124
|
+
joint: Body18;
|
|
125
|
+
frames: [number, number];
|
|
126
|
+
jitter: { before: number | null; after: number | null };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A run of frames (`frames`, inside a region the holds cover) on which a joint
|
|
130
|
+
* has a position and no hold, with the test it fails there: `speed` (its
|
|
131
|
+
* largest median speed, trunk lengths a frame; null where its speed is not
|
|
132
|
+
* known on some frame), `reach` (its largest distance from its median over the
|
|
133
|
+
* run, trunk lengths), `short` (it passes both and the run lasts under the
|
|
134
|
+
* minimum; the figure is its seconds), or `span` (it passes all three and its
|
|
135
|
+
* spans broke elsewhere; the figure is its reach).
|
|
136
|
+
*/
|
|
137
|
+
/** Two holds of one joint meeting (back to back, or a gap of under 6 frames): the second's first frame, the step between their medians in trunk lengths, and the gap in frames (cross-faded). */
|
|
138
|
+
export interface CleanSeam {
|
|
139
|
+
joint: Body18;
|
|
140
|
+
frame: number;
|
|
141
|
+
step: number;
|
|
142
|
+
gap: number;
|
|
143
|
+
}
|
|
144
|
+
export interface CleanUnheld {
|
|
145
|
+
joint: Body18;
|
|
146
|
+
frames: [number, number];
|
|
147
|
+
test: 'speed' | 'reach' | 'short' | 'span';
|
|
148
|
+
value: number | null;
|
|
78
149
|
}
|
|
79
150
|
export interface CleanRecord {
|
|
80
151
|
spec: typeof CLEAN_SPEC;
|
|
81
|
-
/**
|
|
82
|
-
|
|
152
|
+
/**
|
|
153
|
+
* `hipsInferred` is present only when clean marked the hips (`--hips-inferred`).
|
|
154
|
+
* `hold` (#91): the stillness hold's figures, or "off"; absent in a record
|
|
155
|
+
* written before #91.
|
|
156
|
+
*/
|
|
157
|
+
params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
|
|
83
158
|
/**
|
|
84
159
|
* `found`: every joint the spike step replaced, by frame (index into `frames`)
|
|
85
160
|
* and joint. `untested`: the frames the spike step did not test, because a
|
|
@@ -88,6 +163,10 @@ export interface CleanRecord {
|
|
|
88
163
|
spikes: { found: CleanSpike[]; untested: number[] };
|
|
89
164
|
/** Mean |second difference| of y, px/f², before and after; null where no joint has three consecutive positions. */
|
|
90
165
|
jitter: { before: number | null; after: number | null };
|
|
166
|
+
/** The stillness holds (#91), per joint; present, with `unheld`, exactly when `params.hold` is an object. */
|
|
167
|
+
holds?: CleanHold[];
|
|
168
|
+
unheld?: CleanUnheld[];
|
|
169
|
+
seams?: CleanSeam[];
|
|
91
170
|
}
|
|
92
171
|
export interface PoseSequence {
|
|
93
172
|
spec: typeof POSE_SPEC;
|
|
@@ -95,17 +174,28 @@ export interface PoseSequence {
|
|
|
95
174
|
width: number;
|
|
96
175
|
height: number;
|
|
97
176
|
source: string;
|
|
98
|
-
/** Optional, only with `camera`: where it came from — "tracked",
|
|
177
|
+
/** Optional, only with `camera`: where it came from — "tracked", "hand-labelled, N keys" (#63), or "hand windows, floor-snapped: …" (#82). */
|
|
99
178
|
cameraSource?: string;
|
|
100
179
|
/** Optional: one per frame, when a scan read the camera (#63). */
|
|
101
180
|
camera?: CameraFrame[];
|
|
102
181
|
/** Present once `rig-pose clean` has run on the sequence (#67). */
|
|
103
182
|
cleaned?: CleanRecord;
|
|
183
|
+
/**
|
|
184
|
+
* Optional (rig-c/pose#92): the clip's frame of reference. "in-place" — a
|
|
185
|
+
* gait filmed without the figure travelling (a treadmill or reel walk, a
|
|
186
|
+
* loop) — is the one value; `clean --loop` writes it, and it may be written
|
|
187
|
+
* by hand. Absent: the figure's travel in the picture is its travel.
|
|
188
|
+
*/
|
|
189
|
+
travel?: Travel;
|
|
104
190
|
frames: PoseFrame[];
|
|
105
191
|
}
|
|
106
192
|
|
|
193
|
+
/** The one value of a sequence's `travel` (rig-c/pose#92). */
|
|
194
|
+
export const TRAVEL_IN_PLACE = 'in-place';
|
|
195
|
+
export type Travel = typeof TRAVEL_IN_PLACE;
|
|
196
|
+
|
|
107
197
|
const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
|
|
108
|
-
const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned'];
|
|
198
|
+
const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned', 'travel'];
|
|
109
199
|
const CAMERA_KEYS = ['s', 'tx', 'ty', 'inliers'];
|
|
110
200
|
|
|
111
201
|
/**
|
|
@@ -190,10 +280,11 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
|
190
280
|
if (typeof doc.cameraSource !== 'string' || doc.cameraSource.trim() === '') p.add('POSE_CAMERA_SOURCE', `${where} "cameraSource" is ${typeName(doc.cameraSource)}; it must be a non-empty string saying where the camera came from ("tracked", "hand-labelled, N keys")`);
|
|
191
281
|
else if (!('camera' in doc)) p.add('POSE_CAMERA_SOURCE', `${where} has "cameraSource" ${JSON.stringify(doc.cameraSource)} and no "camera"; it states where a camera came from, so it needs one`);
|
|
192
282
|
}
|
|
283
|
+
if ('travel' in doc && doc.travel !== TRAVEL_IN_PLACE) p.add('POSE_TRAVEL', `${where} "travel" is ${typeName(doc.travel)}; the one value is "${TRAVEL_IN_PLACE}" (a gait filmed without the figure travelling), and without the key the picture's travel is the figure's`);
|
|
193
284
|
const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
|
|
194
285
|
p.throwIfAny();
|
|
195
286
|
const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
|
|
196
|
-
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames };
|
|
287
|
+
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(doc.travel === TRAVEL_IN_PLACE ? { travel: TRAVEL_IN_PLACE } : {}), frames };
|
|
197
288
|
}
|
|
198
289
|
|
|
199
290
|
/** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
|
|
@@ -234,32 +325,38 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
|
|
|
234
325
|
return out;
|
|
235
326
|
}
|
|
236
327
|
|
|
237
|
-
/** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }. */
|
|
328
|
+
/** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }. */
|
|
238
329
|
function readCleaned(p: Problems, where: string, c: unknown, frames: number | null): CleanRecord | undefined {
|
|
239
330
|
const cw = `${where} "cleaned"`;
|
|
240
|
-
const shape = '{ spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }';
|
|
331
|
+
const shape = '{ spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }';
|
|
241
332
|
if (!isObject(c)) {
|
|
242
333
|
p.add('POSE_CLEANED', `${cw} is ${typeName(c)}; it is the record rig-pose clean writes, ${shape}`);
|
|
243
334
|
return undefined;
|
|
244
335
|
}
|
|
245
336
|
const before = p.list.length;
|
|
246
|
-
refuseUnknownKeys(p, 'POSE_CLEANED', cw, c, ['spec', 'params', 'spikes', 'jitter']);
|
|
337
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', cw, c, ['spec', 'params', 'spikes', 'jitter', 'holds', 'unheld', 'seams']);
|
|
247
338
|
for (const k of ['spec', 'params', 'spikes', 'jitter']) if (!(k in c)) p.add('POSE_CLEANED', `${cw} has no "${k}"; the record is ${shape}`);
|
|
248
339
|
if ('spec' in c && c.spec !== CLEAN_SPEC) p.add('POSE_CLEANED', `${cw} "spec" is ${typeName(c.spec)}; this reader reads "${CLEAN_SPEC}"`);
|
|
249
340
|
const window = (v: unknown) => Number.isInteger(v) && (v === 1 || ((v as number) >= 5 && (v as number) % 2 === 1));
|
|
250
341
|
if ('params' in c) {
|
|
251
342
|
const q = c.params;
|
|
252
|
-
if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred? }`);
|
|
343
|
+
if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred?, hold? }`);
|
|
253
344
|
else {
|
|
254
|
-
refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred']);
|
|
255
|
-
if ('
|
|
256
|
-
const h = q.
|
|
257
|
-
|
|
345
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred', 'hold']);
|
|
346
|
+
if ('hold' in q) {
|
|
347
|
+
const h = q.hold;
|
|
348
|
+
const hw = `${cw} "params" "hold"`;
|
|
349
|
+
if (h === 'off') {
|
|
350
|
+
for (const k of ['holds', 'unheld', 'seams']) if (k in c) p.add('POSE_CLEANED', `${cw} has "${k}" with "params" "hold" "off"; a record lists holds only when the hold ran`);
|
|
351
|
+
} else if (!isObject(h)) p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be "off" or { speed, reach, minSeconds, blend }`);
|
|
258
352
|
else {
|
|
259
|
-
refuseUnknownKeys(p, 'POSE_CLEANED',
|
|
260
|
-
if (h
|
|
353
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['speed', 'reach', 'minSeconds', 'blend']);
|
|
354
|
+
for (const k of ['speed', 'reach', 'minSeconds'] as const) if (!(typeof h[k] === 'number' && Number.isFinite(h[k]) && h[k] > 0)) p.add('POSE_CLEANED', `${hw} "${k}" is ${typeName(h[k])}; it must be a number > 0 (${k === 'minSeconds' ? 'seconds' : k === 'speed' ? 'trunk lengths per frame' : 'trunk lengths'})`);
|
|
355
|
+
if (!(Number.isInteger(h.blend) && (h.blend as number) >= 0)) p.add('POSE_CLEANED', `${hw} "blend" is ${typeName(h.blend)}; it must be a whole number of frames ≥ 0`);
|
|
356
|
+
for (const k of ['holds', 'unheld', 'seams']) if (!(k in c)) p.add('POSE_CLEANED', `${cw} has "params" "hold" and no "${k}"; a record whose hold ran lists its holds, the joints it did not hold and its seams (an empty array when none)`);
|
|
261
357
|
}
|
|
262
|
-
}
|
|
358
|
+
} else for (const k of ['holds', 'unheld', 'seams']) if (k in c) p.add('POSE_CLEANED', `${cw} has "${k}" and no "params" "hold"; the holds' figures are recorded in "params" "hold"`);
|
|
359
|
+
if ('hipsInferred' in q) readHipsInferred(p, `${cw} "params" "hipsInferred"`, q.hipsInferred, frames);
|
|
263
360
|
if (typeof q.loop !== 'boolean') p.add('POSE_CLEANED', `${cw} "params" "loop" is ${typeName(q.loop)}; it must be true or false`);
|
|
264
361
|
for (const k of ['legs', 'rest'] as const) if (!window(q[k])) p.add('POSE_CLEANED', `${cw} "params" "${k}" is ${typeName(q[k])}; it must be 1 (none) or an odd whole number ≥ 5`);
|
|
265
362
|
}
|
|
@@ -299,20 +396,213 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
|
|
|
299
396
|
}
|
|
300
397
|
}
|
|
301
398
|
}
|
|
399
|
+
if ('holds' in c) {
|
|
400
|
+
const hs = c.holds;
|
|
401
|
+
if (!Array.isArray(hs)) p.add('POSE_CLEANED', `${cw} "holds" is ${typeName(hs)}; it must be an array of { joint, frames: [first, last], jitter: { before, after } }`);
|
|
402
|
+
else hs.forEach((h: unknown, i: number) => {
|
|
403
|
+
const hw = `${cw} holds[${i}]`;
|
|
404
|
+
if (!isObject(h)) {
|
|
405
|
+
p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; a hold is { joint, frames: [first, last], jitter: { before, after } }`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['joint', 'frames', 'jitter']);
|
|
409
|
+
if (typeof h.joint !== 'string' || !isBody18(h.joint)) p.add('POSE_CLEANED', `${hw} "joint" is ${typeName(h.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
|
|
410
|
+
const f = h.frames;
|
|
411
|
+
if (!(Array.isArray(f) && f.length === 2 && frameOk(f[0]) && frameOk(f[1]) && (f[0] as number) <= (f[1] as number))) p.add('POSE_CLEANED', `${hw} "frames" is ${typeName(f)}; it must be [first, last], frame indices in ${frameRange}, first ≤ last`);
|
|
412
|
+
const j = h.jitter;
|
|
413
|
+
if (!isObject(j)) p.add('POSE_CLEANED', `${hw} "jitter" is ${typeName(j)}; it must be { before, after }`);
|
|
414
|
+
else {
|
|
415
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', `${hw} "jitter"`, j, ['before', 'after']);
|
|
416
|
+
for (const k of ['before', 'after'] as const) {
|
|
417
|
+
const v = j[k];
|
|
418
|
+
if (!(v === null || (typeof v === 'number' && Number.isFinite(v) && v >= 0))) p.add('POSE_CLEANED', `${hw} "jitter" "${k}" is ${typeName(v)}; it must be px/f² ≥ 0, or null (not measured)`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if ('unheld' in c) {
|
|
424
|
+
const us = c.unheld;
|
|
425
|
+
if (!Array.isArray(us)) p.add('POSE_CLEANED', `${cw} "unheld" is ${typeName(us)}; it must be an array of { joint, frames: [first, last], test, value }`);
|
|
426
|
+
else us.forEach((u: unknown, i: number) => {
|
|
427
|
+
const uw = `${cw} unheld[${i}]`;
|
|
428
|
+
if (!isObject(u)) {
|
|
429
|
+
p.add('POSE_CLEANED', `${uw} is ${typeName(u)}; an unheld joint is { joint, frames: [first, last], test, value }`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', uw, u, ['joint', 'frames', 'test', 'value']);
|
|
433
|
+
if (typeof u.joint !== 'string' || !isBody18(u.joint)) p.add('POSE_CLEANED', `${uw} "joint" is ${typeName(u.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
|
|
434
|
+
const f = u.frames;
|
|
435
|
+
if (!(Array.isArray(f) && f.length === 2 && frameOk(f[0]) && frameOk(f[1]) && (f[0] as number) <= (f[1] as number))) p.add('POSE_CLEANED', `${uw} "frames" is ${typeName(f)}; it must be [first, last], frame indices in ${frameRange}, first ≤ last`);
|
|
436
|
+
if (u.test !== 'speed' && u.test !== 'reach' && u.test !== 'short' && u.test !== 'span') p.add('POSE_CLEANED', `${uw} "test" is ${typeName(u.test)}; it must be "speed", "reach", "short" or "span"`);
|
|
437
|
+
const v = u.value;
|
|
438
|
+
const ok = typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
439
|
+
if (!(ok || (v === null && u.test === 'speed'))) p.add('POSE_CLEANED', `${uw} "value" is ${typeName(v)}; it must be a figure ≥ 0 (trunk lengths, or seconds for "short"), or null for a speed not known`);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if ('seams' in c) {
|
|
443
|
+
const ss = c.seams;
|
|
444
|
+
if (!Array.isArray(ss)) p.add('POSE_CLEANED', `${cw} "seams" is ${typeName(ss)}; it must be an array of { joint, frame, step, gap }`);
|
|
445
|
+
else ss.forEach((x: unknown, i: number) => {
|
|
446
|
+
const sw = `${cw} seams[${i}]`;
|
|
447
|
+
if (!isObject(x)) {
|
|
448
|
+
p.add('POSE_CLEANED', `${sw} is ${typeName(x)}; a seam is { joint, frame, step, gap }`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', sw, x, ['joint', 'frame', 'step', 'gap']);
|
|
452
|
+
if (!(Number.isInteger(x.gap) && (x.gap as number) >= 0)) p.add('POSE_CLEANED', `${sw} "gap" is ${typeName(x.gap)}; it must be a whole number of frames ≥ 0`);
|
|
453
|
+
if (typeof x.joint !== 'string' || !isBody18(x.joint)) p.add('POSE_CLEANED', `${sw} "joint" is ${typeName(x.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
|
|
454
|
+
if (!frameOk(x.frame)) p.add('POSE_CLEANED', `${sw} "frame" is ${typeName(x.frame)}; it must be a frame index in ${frameRange}`);
|
|
455
|
+
if (!(typeof x.step === 'number' && Number.isFinite(x.step) && x.step >= 0)) p.add('POSE_CLEANED', `${sw} "step" is ${typeName(x.step)}; it must be trunk lengths ≥ 0`);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
302
458
|
if (p.list.length > before) return undefined;
|
|
303
|
-
const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
|
|
459
|
+
const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
|
|
304
460
|
const j = c.jitter as { before: number | null; after: number | null };
|
|
305
461
|
return {
|
|
306
462
|
spec: CLEAN_SPEC,
|
|
307
|
-
params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: {
|
|
463
|
+
params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(q.hipsInferred) } : {}), ...(q.hold !== undefined ? { hold: holdParamsOf(q.hold) } : {}) },
|
|
308
464
|
spikes: {
|
|
309
465
|
found: ((c.spikes as { found: Array<{ frame: number; joint: Body18 }> }).found).map((s) => ({ frame: s.frame, joint: s.joint })),
|
|
310
466
|
untested: [...(c.spikes as { untested: number[] }).untested],
|
|
311
467
|
},
|
|
312
468
|
jitter: { before: j.before, after: j.after },
|
|
469
|
+
...('holds' in c ? { holds: (c.holds as CleanHold[]).map(copyHold) } : {}),
|
|
470
|
+
...('unheld' in c ? { unheld: (c.unheld as CleanUnheld[]).map(copyUnheld) } : {}),
|
|
471
|
+
...('seams' in c ? { seams: (c.seams as CleanSeam[]).map((x) => ({ joint: x.joint, frame: x.frame, step: x.step, gap: x.gap })) } : {}),
|
|
313
472
|
};
|
|
314
473
|
}
|
|
315
474
|
|
|
475
|
+
/** `hipsInferred`: { by: "declared" } or the measured record (the shape at `HipsInferred`), refused by name otherwise. */
|
|
476
|
+
function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number | null): void {
|
|
477
|
+
const measuredShape = '{ by: "measured", method, read, interpolated, held: [[first, last]], declined: [{ frames: [first, last], why }] }';
|
|
478
|
+
if (!isObject(h)) {
|
|
479
|
+
p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be { by: "declared" } or ${measuredShape}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (h.by === 'declared') {
|
|
483
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by']);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (h.by !== 'measured') {
|
|
487
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by']);
|
|
488
|
+
p.add('POSE_CLEANED', `${hw} "by" is ${typeName(h.by)}; this reader reads "declared" (the person running rig-pose clean --hips-inferred said so) or "measured" (a tool read the pelvis line off the picture, tools/pelvis.py)`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const before = p.list.length;
|
|
492
|
+
const CLASSES = ['read', 'interpolated', 'held'] as const;
|
|
493
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', ...CLASSES, 'declined']);
|
|
494
|
+
for (const k of ['method', ...CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
|
|
495
|
+
if ('method' in h && !(typeof h.method === 'string' && h.method.trim() !== '')) p.add('POSE_CLEANED', `${hw} "method" is ${typeName(h.method)}; it must be a non-empty string naming the reading`);
|
|
496
|
+
const range = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
|
|
497
|
+
/** A run [first, last], or null after naming what is wrong with it. */
|
|
498
|
+
const run = (v: unknown, rw: string): Run | null => {
|
|
499
|
+
if (!(Array.isArray(v) && v.length === 2)) {
|
|
500
|
+
p.add('POSE_CLEANED', `${rw} is ${typeName(v)}; a run is [first, last], two frame indices`);
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
let ok = true;
|
|
504
|
+
v.forEach((f: unknown, k: number) => {
|
|
505
|
+
if (!(Number.isInteger(f) && (f as number) >= 0 && (frames === null || (f as number) < frames))) {
|
|
506
|
+
p.add('POSE_CLEANED', `${rw}[${k}] is ${typeName(f)}; it must be a frame index in ${range}`);
|
|
507
|
+
ok = false;
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
if (!ok) return null;
|
|
511
|
+
const [a, b] = v as Run;
|
|
512
|
+
if (a > b) {
|
|
513
|
+
p.add('POSE_CLEANED', `${rw} is [${a}, ${b}]; a run's first frame must not come after its last`);
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
return [a, b];
|
|
517
|
+
};
|
|
518
|
+
type Named = { r: Run; w: string };
|
|
519
|
+
/** Runs in order and apart: each starts after the previous one ends. */
|
|
520
|
+
const ordered = (rs: Named[], what: string) => {
|
|
521
|
+
for (let k = 1; k < rs.length; k++) {
|
|
522
|
+
const prev = rs[k - 1]!;
|
|
523
|
+
const cur = rs[k]!;
|
|
524
|
+
if (cur.r[0] <= prev.r[1]) p.add('POSE_CLEANED', `${cur.w} [${cur.r[0]}, ${cur.r[1]}] starts at or before the end of ${prev.w} [${prev.r[0]}, ${prev.r[1]}]; ${what} runs must increase and not overlap`);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
const classes: Record<(typeof CLASSES)[number], Named[]> = { read: [], interpolated: [], held: [] };
|
|
528
|
+
for (const c of CLASSES) {
|
|
529
|
+
if (!(c in h)) continue;
|
|
530
|
+
const list = h[c];
|
|
531
|
+
if (!Array.isArray(list)) {
|
|
532
|
+
p.add('POSE_CLEANED', `${hw} "${c}" is ${typeName(list)}; it must be an array of runs [first, last]`);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
list.forEach((v: unknown, k: number) => {
|
|
536
|
+
const w = `${hw} ${c}[${k}]`;
|
|
537
|
+
const r = run(v, w);
|
|
538
|
+
if (r !== null) classes[c].push({ r, w });
|
|
539
|
+
});
|
|
540
|
+
ordered(classes[c], c);
|
|
541
|
+
}
|
|
542
|
+
const declined: Named[] = [];
|
|
543
|
+
if ('declined' in h) {
|
|
544
|
+
if (!Array.isArray(h.declined)) p.add('POSE_CLEANED', `${hw} "declined" is ${typeName(h.declined)}; it must be an array of { frames: [first, last], why }`);
|
|
545
|
+
else h.declined.forEach((d: unknown, k: number) => {
|
|
546
|
+
const w = `${hw} declined[${k}]`;
|
|
547
|
+
if (!isObject(d)) {
|
|
548
|
+
p.add('POSE_CLEANED', `${w} is ${typeName(d)}; a declined run is { frames: [first, last], why }`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', w, d, ['frames', 'why']);
|
|
552
|
+
if (!('why' in d)) p.add('POSE_CLEANED', `${w} has no "why"; a declined run says why, a non-empty string`);
|
|
553
|
+
else if (!(typeof d.why === 'string' && d.why.trim() !== '')) p.add('POSE_CLEANED', `${w} "why" is ${typeName(d.why)}; a declined run says why, a non-empty string`);
|
|
554
|
+
if (!('frames' in d)) {
|
|
555
|
+
p.add('POSE_CLEANED', `${w} has no "frames"; a declined run is { frames: [first, last], why }`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const r = run(d.frames, `${w} "frames"`);
|
|
559
|
+
if (r !== null) declined.push({ r, w: `${w} "frames"` });
|
|
560
|
+
});
|
|
561
|
+
ordered(declined, 'declined');
|
|
562
|
+
}
|
|
563
|
+
// Across lists: the classes are disjoint, they cover every frame, and declined is exactly interpolated ∪ held.
|
|
564
|
+
for (let x = 0; x < CLASSES.length; x++) {
|
|
565
|
+
for (let y = x + 1; y < CLASSES.length; y++) {
|
|
566
|
+
for (const a of classes[CLASSES[x]!]) {
|
|
567
|
+
for (const b of classes[CLASSES[y]!]) {
|
|
568
|
+
if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of read, interpolated and held`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (p.list.length > before) return;
|
|
574
|
+
const set = (rs: Named[]) => {
|
|
575
|
+
const out = new Set<number>();
|
|
576
|
+
for (const { r } of rs) for (let f = r[0]; f <= r[1]; f++) out.add(f);
|
|
577
|
+
return out;
|
|
578
|
+
};
|
|
579
|
+
const spans = (fs: number[]) => {
|
|
580
|
+
const out: Run[] = [];
|
|
581
|
+
for (const f of fs) {
|
|
582
|
+
const last = out[out.length - 1];
|
|
583
|
+
if (last !== undefined && f === last[1] + 1) last[1] = f;
|
|
584
|
+
else out.push([f, f]);
|
|
585
|
+
}
|
|
586
|
+
return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
|
|
587
|
+
};
|
|
588
|
+
const read = set(classes.read);
|
|
589
|
+
const filled = set([...classes.interpolated, ...classes.held]);
|
|
590
|
+
const dec = set(declined);
|
|
591
|
+
if (frames !== null) {
|
|
592
|
+
const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !filled.has(f));
|
|
593
|
+
if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of read, interpolated and held; every frame of the sequence is one of them`);
|
|
594
|
+
}
|
|
595
|
+
const both = [...dec].filter((f) => read.has(f)).sort((a, b) => a - b);
|
|
596
|
+
if (both.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(both)} are both read and declined; a frame is read or declined, not both`);
|
|
597
|
+
const unfilled = [...dec].filter((f) => !read.has(f) && !filled.has(f)).sort((a, b) => a - b);
|
|
598
|
+
if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither interpolated nor held; a declined frame takes one of them`);
|
|
599
|
+
const undeclared = [...filled].filter((f) => !dec.has(f)).sort((a, b) => a - b);
|
|
600
|
+
if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are interpolated or held but not declined; only a frame the reading declined is filled, and "declined" says why`);
|
|
601
|
+
}
|
|
602
|
+
const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
|
|
603
|
+
const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: h.jitter.before, after: h.jitter.after } });
|
|
604
|
+
const copyUnheld = (u: CleanUnheld): CleanUnheld => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value });
|
|
605
|
+
|
|
316
606
|
function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
|
|
317
607
|
const out: Partial<Record<Body18, Joint>> = {};
|
|
318
608
|
if (!isObject(joints)) {
|
|
@@ -499,6 +789,14 @@ export function writePoseSequence(seq: PoseSequence): string {
|
|
|
499
789
|
const camera = seq.camera?.map((c) => ({ s: round(c.s, 6), tx: round(c.tx, 3), ty: round(c.ty, 3), inliers: c.inliers }));
|
|
500
790
|
const c = seq.cleaned;
|
|
501
791
|
const jit = (v: number | null) => (v === null ? null : round(v, 4));
|
|
502
|
-
const cleaned = c === undefined ? undefined : {
|
|
503
|
-
|
|
792
|
+
const cleaned = c === undefined ? undefined : {
|
|
793
|
+
spec: c.spec,
|
|
794
|
+
params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(c.params.hipsInferred) } : {}), ...(c.params.hold !== undefined ? { hold: holdParamsOf(c.params.hold) } : {}) },
|
|
795
|
+
spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] },
|
|
796
|
+
jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) },
|
|
797
|
+
...(c.holds !== undefined ? { holds: c.holds.map((h) => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: jit(h.jitter.before), after: jit(h.jitter.after) } })) } : {}),
|
|
798
|
+
...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
|
|
799
|
+
...(c.unheld !== undefined ? { unheld: c.unheld.map((u) => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value === null ? null : round(u.value, 4) })) } : {}),
|
|
800
|
+
};
|
|
801
|
+
return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(seq.travel !== undefined ? { travel: seq.travel } : {}), frames }, null, 2)}\n`;
|
|
504
802
|
}
|