@gtanczyk/diorama 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// A figure made to walk: SAM finds arms, legs and boots, code places the joints; the painting is then bent by them
|
|
2
|
+
// as one mesh into a walk sheet (src/scene/warp.ts), or, the older way, cut into parts the runtime turns.
|
|
3
|
+
// Joints sit at a person's proportions, measured below the head (a chibi's big head leaves short legs), on the
|
|
4
|
+
// parts' own pixels.
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
|
|
7
|
+
import { components, dilate, intersect, maskBox, subtract, union, unionAll } from '../image/raster.ts';
|
|
8
|
+
import type { Memo } from '../meta/memo.ts';
|
|
9
|
+
import { samFind } from '../meta/sam.ts';
|
|
10
|
+
import { heldMask } from './held.ts';
|
|
11
|
+
import { labelPixels, Part, silhouette, warpSheet } from './warp.ts';
|
|
12
|
+
import type { PartName, Rig } from '../play/rig.ts';
|
|
13
|
+
|
|
14
|
+
type P = [number, number];
|
|
15
|
+
|
|
16
|
+
/** Mean x of the mask's pixels in rows y-band..y+band, or undefined when there are none. */
|
|
17
|
+
function centreX(m: Mask, y: number, band = 6): number | undefined {
|
|
18
|
+
let sum = 0;
|
|
19
|
+
let n = 0;
|
|
20
|
+
for (let yy = Math.max(0, Math.round(y - band)); yy <= Math.min(m.height - 1, Math.round(y + band)); yy++) {
|
|
21
|
+
for (let x = 0; x < m.width; x++) if (m.bits[yy * m.width + x]) (sum += x), n++;
|
|
22
|
+
}
|
|
23
|
+
return n ? sum / n : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const overlap = (a: Mask, b: Mask) => maskArea(intersect(a, b));
|
|
27
|
+
|
|
28
|
+
/** Splits a leg at its knee: pixels before the knee along hip to ankle are thigh, the rest shin, sharing a seam. */
|
|
29
|
+
function splitLeg(leg: Mask, hip: P, knee: P, ankle: P, seam: number): { thigh: Mask; shin: Mask } {
|
|
30
|
+
const thigh = emptyMask(leg.width, leg.height);
|
|
31
|
+
const shin = emptyMask(leg.width, leg.height);
|
|
32
|
+
const dir: P = [ankle[0] - hip[0], ankle[1] - hip[1]];
|
|
33
|
+
const L = Math.hypot(...dir);
|
|
34
|
+
const at = ((knee[0] - hip[0]) * dir[0] + (knee[1] - hip[1]) * dir[1]) / L;
|
|
35
|
+
for (let i = 0; i < leg.bits.length; i++) {
|
|
36
|
+
if (!leg.bits[i]) continue;
|
|
37
|
+
const x = i % leg.width;
|
|
38
|
+
const y = Math.floor(i / leg.width);
|
|
39
|
+
const t = ((x - hip[0]) * dir[0] + (y - hip[1]) * dir[1]) / L;
|
|
40
|
+
if (t < at + seam) thigh.bits[i] = 1;
|
|
41
|
+
if (t > at - seam) shin.bits[i] = 1;
|
|
42
|
+
}
|
|
43
|
+
return { thigh, shin };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function distToLine([x, y]: P, points: P[]): number {
|
|
47
|
+
let best = Infinity;
|
|
48
|
+
for (let k = 1; k < points.length; k++) {
|
|
49
|
+
const [ax, ay] = points[k - 1];
|
|
50
|
+
const [bx, by] = points[k];
|
|
51
|
+
const dx = bx - ax;
|
|
52
|
+
const dy = by - ay;
|
|
53
|
+
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy || 1)));
|
|
54
|
+
best = Math.min(best, Math.hypot(x - ax - t * dx, y - ay - t * dy));
|
|
55
|
+
}
|
|
56
|
+
return best;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pixels within r of the polyline through `points`. */
|
|
60
|
+
function capsule(width: number, height: number, points: P[], r: number): Mask {
|
|
61
|
+
const m = emptyMask(width, height);
|
|
62
|
+
for (let k = 1; k < points.length; k++) {
|
|
63
|
+
const [ax, ay] = points[k - 1];
|
|
64
|
+
const [bx, by] = points[k];
|
|
65
|
+
const dx = bx - ax;
|
|
66
|
+
const dy = by - ay;
|
|
67
|
+
const l2 = dx * dx + dy * dy || 1;
|
|
68
|
+
for (let y = Math.max(0, Math.floor(Math.min(ay, by) - r)); y <= Math.min(height - 1, Math.ceil(Math.max(ay, by) + r)); y++) {
|
|
69
|
+
for (let x = Math.max(0, Math.floor(Math.min(ax, bx) - r)); x <= Math.min(width - 1, Math.ceil(Math.max(ax, bx) + r)); x++) {
|
|
70
|
+
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / l2));
|
|
71
|
+
if (Math.hypot(x - ax - t * dx, y - ay - t * dy) <= r) m.bits[y * width + x] = 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return m;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Paints over `hole` with the colours around it: each hole pixel takes the mean of the known pixels in the
|
|
80
|
+
* smallest block (2, 4, 8 ... px) around it that has any. Smooth, without the streaks of a ring-by-ring fill.
|
|
81
|
+
*/
|
|
82
|
+
function fillHole(rgba: Buffer, width: number, keep: Mask, hole: Mask) {
|
|
83
|
+
const height = keep.height;
|
|
84
|
+
const todo: number[] = [];
|
|
85
|
+
hole.bits.forEach((b, i) => b && !keep.bits[i] && todo.push(i));
|
|
86
|
+
for (let size = 2; todo.length && size <= 512; size *= 2) {
|
|
87
|
+
const bw = Math.ceil(width / size);
|
|
88
|
+
const bh = Math.ceil(height / size);
|
|
89
|
+
const sum = new Float64Array(bw * bh * 4);
|
|
90
|
+
for (let i = 0; i < keep.bits.length; i++) {
|
|
91
|
+
if (!keep.bits[i]) continue;
|
|
92
|
+
const b = Math.floor(Math.floor(i / width) / size) * bw + Math.floor((i % width) / size);
|
|
93
|
+
sum[b * 4] += rgba[i * 4]; sum[b * 4 + 1] += rgba[i * 4 + 1]; sum[b * 4 + 2] += rgba[i * 4 + 2]; sum[b * 4 + 3]++;
|
|
94
|
+
}
|
|
95
|
+
for (let k = todo.length - 1; k >= 0; k--) {
|
|
96
|
+
const i = todo[k];
|
|
97
|
+
// The pixel's own block and its neighbours, so the mean is centred on it rather than on a grid.
|
|
98
|
+
const bx = Math.floor((i % width) / size);
|
|
99
|
+
const by = Math.floor(Math.floor(i / width) / size);
|
|
100
|
+
let r = 0, g = 0, bl = 0, n = 0;
|
|
101
|
+
for (let y = Math.max(0, by - 1); y <= Math.min(bh - 1, by + 1); y++) {
|
|
102
|
+
for (let x = Math.max(0, bx - 1); x <= Math.min(bw - 1, bx + 1); x++) {
|
|
103
|
+
const b = (y * bw + x) * 4;
|
|
104
|
+
r += sum[b]; g += sum[b + 1]; bl += sum[b + 2]; n += sum[b + 3];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!n) continue;
|
|
108
|
+
rgba[i * 4] = r / n; rgba[i * 4 + 1] = g / n; rgba[i * 4 + 2] = bl / n; rgba[i * 4 + 3] = 255;
|
|
109
|
+
todo.splice(k, 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Soften the block edges: box-blur the image three times and keep the result only inside the hole.
|
|
113
|
+
const inside = new Uint8Array(hole.bits.length);
|
|
114
|
+
hole.bits.forEach((b, i) => (inside[i] = b && !keep.bits[i] ? 1 : 0));
|
|
115
|
+
for (let pass = 0; pass < 3; pass++) blurInside(rgba, width, height, inside, 6);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Box blur weighted by opacity (transparent pixels add nothing), written back only inside the hole. */
|
|
119
|
+
function blurInside(rgba: Buffer, width: number, height: number, inside: Uint8Array, r: number) {
|
|
120
|
+
const N = width * height;
|
|
121
|
+
// Premultiplied channels plus a weight channel, blurred along x then y.
|
|
122
|
+
let cur = new Float32Array(N * 4);
|
|
123
|
+
for (let i = 0; i < N; i++) {
|
|
124
|
+
const a = rgba[i * 4 + 3] > 0 ? 1 : 0;
|
|
125
|
+
cur[i * 4] = rgba[i * 4] * a; cur[i * 4 + 1] = rgba[i * 4 + 1] * a; cur[i * 4 + 2] = rgba[i * 4 + 2] * a; cur[i * 4 + 3] = a;
|
|
126
|
+
}
|
|
127
|
+
for (const horizontal of [true, false]) {
|
|
128
|
+
const next = new Float32Array(N * 4);
|
|
129
|
+
const lines = horizontal ? height : width;
|
|
130
|
+
const len = horizontal ? width : height;
|
|
131
|
+
const idx = (line: number, k: number) => (horizontal ? line * width + k : k * width + line) * 4;
|
|
132
|
+
for (let line = 0; line < lines; line++) {
|
|
133
|
+
const acc = [0, 0, 0, 0];
|
|
134
|
+
for (let k = 0; k <= Math.min(r, len - 1); k++) for (let c = 0; c < 4; c++) acc[c] += cur[idx(line, k) + c];
|
|
135
|
+
for (let k = 0; k < len; k++) {
|
|
136
|
+
for (let c = 0; c < 4; c++) next[idx(line, k) + c] = acc[c];
|
|
137
|
+
if (k + r + 1 < len) for (let c = 0; c < 4; c++) acc[c] += cur[idx(line, k + r + 1) + c];
|
|
138
|
+
if (k - r >= 0) for (let c = 0; c < 4; c++) acc[c] -= cur[idx(line, k - r) + c];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
cur = next;
|
|
142
|
+
}
|
|
143
|
+
for (let i = 0; i < N; i++) {
|
|
144
|
+
if (!inside[i] || cur[i * 4 + 3] <= 0) continue;
|
|
145
|
+
for (let c = 0; c < 3; c++) rgba[i * 4 + c] = cur[i * 4 + c] / cur[i * 4 + 3];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Hole of `part` inside the body: its pixels in rows where the body has pixels on both sides. */
|
|
150
|
+
function enclosed(body: Mask, part: Mask): Mask {
|
|
151
|
+
const out = emptyMask(body.width, body.height);
|
|
152
|
+
for (let y = 0; y < body.height; y++) {
|
|
153
|
+
let left = -1, right = -1;
|
|
154
|
+
for (let x = 0; x < body.width; x++) if (body.bits[y * body.width + x]) (left < 0 && (left = x)), (right = x);
|
|
155
|
+
if (left < 0) continue;
|
|
156
|
+
for (let x = left; x <= right; x++) if (part.bits[y * body.width + x]) out.bits[y * body.width + x] = 1;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type RigBuild = { rig: Rig; parts: Record<string, Buffer>; issues: string[] };
|
|
162
|
+
|
|
163
|
+
/** Cut parts (the old rig), or the whole painting warped into a walk sheet (the default: nothing tears). */
|
|
164
|
+
export type RigStyle = 'parts' | 'warp';
|
|
165
|
+
|
|
166
|
+
/** `holds`: what the character holds (scene/held.ts), found by SAM and walked with the hands that hold it. */
|
|
167
|
+
export async function rigFigure(memo: Memo, key: string, painting: Buffer, style: RigStyle = 'warp', holds: string[] = []): Promise<RigBuild> {
|
|
168
|
+
const png = await sharp(painting).png().toBuffer();
|
|
169
|
+
const find = async (prompt: string) => (await samFind(memo, { label: `rig:${key}:${prompt}`, image: png, prompt })).map((f) => f.mask);
|
|
170
|
+
const [people, arms, boots, legsFound, trousers, heads] = await Promise.all(['person', 'arm', 'boot', 'leg', 'trousers', 'head'].map(find));
|
|
171
|
+
const issues: string[] = [];
|
|
172
|
+
// A child's drawing (a mushroom girl, a box-bodied man) is often no "person" to SAM: it is asked as a character next.
|
|
173
|
+
let person = people.sort((a, b) => maskArea(b) - maskArea(a))[0];
|
|
174
|
+
for (const prompt of ['cartoon character', 'figure']) {
|
|
175
|
+
if (person) break;
|
|
176
|
+
person = (await find(prompt)).sort((a, b) => maskArea(b) - maskArea(a))[0];
|
|
177
|
+
if (person) issues.push(`found as a ${prompt}, not a person`);
|
|
178
|
+
}
|
|
179
|
+
if (!person) throw new Error(`${key}: SAM found no person or character to rig`);
|
|
180
|
+
const { width: W, height: Hh } = person;
|
|
181
|
+
const [px0, top, px1, bottom] = maskBox(person)!;
|
|
182
|
+
const ground = bottom - 1;
|
|
183
|
+
const H = ground - top;
|
|
184
|
+
const inPerson = (m: Mask) => intersect(m, person);
|
|
185
|
+
// What they hold, however big (a slice of toast wider than they are): part of the picture that walks.
|
|
186
|
+
const held = await heldMask(memo, key, png, person, holds);
|
|
187
|
+
const heldBox = held && maskBox(held);
|
|
188
|
+
|
|
189
|
+
// Two boots, nearest-first by size; a figure in long trousers or a dress may show none.
|
|
190
|
+
const feet = boots.map(inPerson).filter((b) => maskArea(b) > H * 2).sort((a, b) => maskArea(b) - maskArea(a)).slice(0, 2);
|
|
191
|
+
if (feet.length < 2) issues.push(`found ${feet.length} boot(s), bare feet stay on the shins`);
|
|
192
|
+
const footless = (m: Mask) => feet.reduce((acc, f) => subtract(acc, f), m);
|
|
193
|
+
// The lower body without the feet: SAM's trousers with the bare legs below them (rolled-up trousers, shorts,
|
|
194
|
+
// a skirt), else every leg it found.
|
|
195
|
+
const legs = legsFound.map((l) => footless(inPerson(l))).filter((l) => maskArea(l) > H * 4).sort((a, b) => maskArea(b) - maskArea(a));
|
|
196
|
+
const worn = trousers[0] && inPerson(trousers[0]);
|
|
197
|
+
const lower = footless(worn ? legs.filter((l) => overlap(dilate(worn, 4), l) > 0).reduce((a, l) => union(a, l), worn) : unionAll(W, Hh, legs));
|
|
198
|
+
if (maskArea(lower) < H * 4) throw new Error(`${key}: no legs found to rig`);
|
|
199
|
+
|
|
200
|
+
// The hip: a grown-up's sits at 0.575 of the height below the chin (half the whole height); a child or a chibi
|
|
201
|
+
// with a big head has it lower. Where trousers start near there, at their waistband.
|
|
202
|
+
const head = heads.map(inPerson).filter((h) => maskBox(h) && maskBox(h)![1] < top + 0.15 * H).sort((a, b) => maskArea(b) - maskArea(a))[0];
|
|
203
|
+
const chin = head ? Math.min(maskBox(head)![3], top + 0.4 * H) : top + 0.13 * H;
|
|
204
|
+
const byHead = ground - 0.575 * (ground - chin);
|
|
205
|
+
const wornBox = worn && maskBox(worn);
|
|
206
|
+
const waist = wornBox ? wornBox[1] + 0.03 * H : undefined;
|
|
207
|
+
const hipY = waist !== undefined && waist > byHead - 0.08 * H && waist < byHead + 0.06 * H ? waist : byHead;
|
|
208
|
+
if (Math.abs(hipY - (ground - 0.5 * H)) > 0.04 * H) issues.push(`hip at ${((ground - hipY) / H).toFixed(2)} of the height (${head ? 'head' : 'no head found'}${hipY === waist ? ', waistband' : ''})`);
|
|
209
|
+
// Near leg: the bigger whole leg SAM sees, from above the knee; far leg: the rest of the lower body.
|
|
210
|
+
// SAM's "leg" on bare legs under trousers is only the shin: then it marks where each leg ends instead.
|
|
211
|
+
const leg = legs.find((l) => maskBox(l)![1] < ground - 0.4 * H);
|
|
212
|
+
const shins = leg ? [] : legs.slice(0, 2);
|
|
213
|
+
let near: Mask;
|
|
214
|
+
let far: Mask;
|
|
215
|
+
if (leg && maskArea(subtract(lower, leg)) > maskArea(lower) * 0.15) {
|
|
216
|
+
near = intersect(dilate(leg, 1), lower);
|
|
217
|
+
far = components(subtract(lower, near))[0];
|
|
218
|
+
} else {
|
|
219
|
+
// One leg mask covering both (or none): each lower-body pixel goes to the leg whose line,
|
|
220
|
+
// hip to knee to the top of its boot, runs nearest. The bigger boot is the near one.
|
|
221
|
+
issues.push(`legs split along hip-to-${feet.length < 2 && shins.length === 2 ? 'shin' : 'boot'} lines, not by SAM`);
|
|
222
|
+
const lowerBox = maskBox(lower)!;
|
|
223
|
+
const hipGuess: P = [centreX(lower, Math.max(hipY, lowerBox[1] + 8), 10) ?? (px0 + px1) / 2, hipY];
|
|
224
|
+
const line = (f: Mask): P[] => {
|
|
225
|
+
const t = maskBox(f)![1];
|
|
226
|
+
const foot: P = [centreX(f, t + 4, 4) ?? hipGuess[0], t];
|
|
227
|
+
return [hipGuess, [(hipGuess[0] + foot[0]) / 2, (hipGuess[1] + foot[1]) / 2], foot];
|
|
228
|
+
};
|
|
229
|
+
const lines = (feet.length < 2 && shins.length === 2 ? shins : feet).map(line);
|
|
230
|
+
near = emptyMask(W, Hh);
|
|
231
|
+
far = emptyMask(W, Hh);
|
|
232
|
+
lower.bits.forEach((b, i) => {
|
|
233
|
+
if (!b) return;
|
|
234
|
+
const at: P = [i % W, Math.floor(i / W)];
|
|
235
|
+
(lines.length < 2 || distToLine(at, lines[0]) <= distToLine(at, lines[1]) ? near : far).bits[i] = 1;
|
|
236
|
+
});
|
|
237
|
+
far = components(far)[0] ?? far;
|
|
238
|
+
}
|
|
239
|
+
// Each boot belongs to the leg it touches most.
|
|
240
|
+
const touching = (legMask: Mask, f: Mask) => overlap(dilate(legMask, 4), f);
|
|
241
|
+
const [nearBoot, farBoot] = feet.length === 2 && touching(near, feet[1]) > touching(near, feet[0]) ? [feet[1], feet[0]] : [feet[0] ?? emptyMask(W, Hh), feet[1] ?? emptyMask(W, Hh)]; // barefoot: no boots at all
|
|
242
|
+
|
|
243
|
+
const handMasks = (await find('hand')).map(inPerson);
|
|
244
|
+
// SAM's arm often stops at the cuff; each hand joins the arm it touches.
|
|
245
|
+
const withHand = (arm: Mask) => handMasks.filter((h) => overlap(dilate(arm, 3), h) > 0).reduce((a, h) => union(a, h), arm);
|
|
246
|
+
const armMasks = arms.map(inPerson).map(withHand).filter((a) => maskArea(a) > H * 3).sort((a, b) => maskArea(b) - maskArea(a)).slice(0, 2);
|
|
247
|
+
if (armMasks.length < 2) issues.push(`found ${armMasks.length} arm(s); a missing arm stays on the torso`);
|
|
248
|
+
const [nearArm = emptyMask(W, Hh), farArm = emptyMask(W, Hh)] = armMasks.map((a, i) => (i === 0 ? intersect(dilate(a, 3), person) : a));
|
|
249
|
+
|
|
250
|
+
const legsBox = maskBox(union(near, far))!;
|
|
251
|
+
// Over the middle of the two boots when both show: a figure painted mid-stride has its hip between its feet,
|
|
252
|
+
// not over the leg SAM saw more of (else the walk swings one leg far out to reach under it).
|
|
253
|
+
const bootTop = (b: Mask) => centreX(b, maskBox(b)![1] + 0.01 * H, 0.01 * H);
|
|
254
|
+
const between = maskArea(nearBoot) && maskArea(farBoot) ? [bootTop(nearBoot), bootTop(farBoot)] : [];
|
|
255
|
+
const hipX = between.length === 2 && between.every((x) => x !== undefined)
|
|
256
|
+
? (between[0]! + between[1]!) / 2
|
|
257
|
+
: centreX(union(near, far), Math.max(hipY, legsBox[1] + 8), 10) ?? (px0 + px1) / 2;
|
|
258
|
+
const hip: P = [hipX, hipY];
|
|
259
|
+
const joints = (legMask: Mask, boot: Mask) => {
|
|
260
|
+
const kneeY = ground - 0.56 * (ground - hipY);
|
|
261
|
+
const bootBox0 = maskArea(boot) ? maskBox(boot) : undefined;
|
|
262
|
+
// A knee hidden under a coat or an apron sits on the line from the hip down to the boot.
|
|
263
|
+
const kneeX = centreX(legMask, kneeY) ?? (bootBox0 ? hipX + ((centreX(boot, bootBox0[1] + 4, 4) ?? hipX) - hipX) * (kneeY - hipY) / (bootBox0[1] - hipY || 1) : hipX);
|
|
264
|
+
const knee: P = [kneeX, kneeY];
|
|
265
|
+
// The ankle pivot is where the leg enters the boot (its top middle), so the boot hangs off the shin's end.
|
|
266
|
+
const bootBox = maskArea(boot) ? maskBox(boot) : undefined;
|
|
267
|
+
const ankleY = bootBox ? bootBox[1] + 0.02 * H : maskBox(legMask)![3] - 0.02 * H;
|
|
268
|
+
const ankle: P = [centreX(bootBox ? boot : legMask, ankleY, 0.015 * H) ?? knee[0], ankleY];
|
|
269
|
+
return { knee, ankle };
|
|
270
|
+
};
|
|
271
|
+
const nearJ = joints(near, nearBoot);
|
|
272
|
+
const farJ = joints(far, farBoot);
|
|
273
|
+
const shoulderY = chin + 0.06 * H;
|
|
274
|
+
const shoulder = (arm: Mask): P => {
|
|
275
|
+
const box = maskBox(arm);
|
|
276
|
+
return [box ? (centreX(arm, box[1] + 0.06 * H, 0.04 * H) ?? hipX) : hipX, box ? Math.max(shoulderY, box[1] + 0.05 * H) : shoulderY];
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const seam = Math.max(3, 0.012 * H);
|
|
280
|
+
// Each leg completed to a whole limb: what the coat or the other leg hid gets the leg's own colours,
|
|
281
|
+
// so nothing tears open when the legs part.
|
|
282
|
+
const { data } = await sharp(png).resize(W, Hh, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
283
|
+
const wholeLeg = (visible: Mask, j: { knee: P; ankle: P }, boot: Mask) => {
|
|
284
|
+
const length = Math.hypot(j.knee[0] - hip[0], j.knee[1] - hip[1]) + Math.hypot(j.ankle[0] - j.knee[0], j.ankle[1] - j.knee[1]);
|
|
285
|
+
const r = Math.min(0.07 * H, Math.max(0.03 * H, maskArea(visible) / length / 2));
|
|
286
|
+
const tube = subtract(capsule(W, Hh, [hip, j.knee, j.ankle], r), boot);
|
|
287
|
+
// A bare foot is all there in the painting: the tube stops above it, so no smear grows round the heel.
|
|
288
|
+
if (!maskArea(boot)) tube.bits.fill(0, Math.max(0, Math.round(j.ankle[1] - 0.06 * H)) * W);
|
|
289
|
+
const mask = union(visible, tube);
|
|
290
|
+
const pixels = Buffer.from(data);
|
|
291
|
+
fillHole(pixels, W, visible, mask);
|
|
292
|
+
return { mask, pixels };
|
|
293
|
+
};
|
|
294
|
+
const nearWhole = wholeLeg(near, nearJ, nearBoot);
|
|
295
|
+
const farWhole = wholeLeg(far, farJ, farBoot);
|
|
296
|
+
const nearSplit = splitLeg(nearWhole.mask, hip, nearJ.knee, nearJ.ankle, seam);
|
|
297
|
+
const farSplit = splitLeg(farWhole.mask, hip, farJ.knee, farJ.ankle, seam);
|
|
298
|
+
const limbs = unionAll(W, Hh, [near, far, nearBoot, farBoot, nearArm, farArm]);
|
|
299
|
+
const torso = components(subtract(person, limbs)).filter((c, i) => i === 0 || maskArea(c) > H * 2).reduce((a, c) => union(a, c), emptyMask(W, Hh));
|
|
300
|
+
|
|
301
|
+
// Room around the figure for swinging legs, then every part as its own RGBA image.
|
|
302
|
+
const margin = Math.round(0.3 * H);
|
|
303
|
+
const crop = { x0: Math.max(0, Math.min(px0 - margin, heldBox?.[0] ?? W)), y0: Math.max(0, Math.min(top - Math.round(0.05 * H), heldBox?.[1] ?? Hh)) };
|
|
304
|
+
const crop1 = { x1: Math.min(W, Math.max(px1 + margin, heldBox?.[2] ?? 0)), y1: Math.min(Hh, bottom + 2) };
|
|
305
|
+
const torsoPixels = Buffer.from(data);
|
|
306
|
+
// The body behind the near arm, painted in from the colours around it.
|
|
307
|
+
const behindArm = enclosed(torso, dilate(nearArm, 2));
|
|
308
|
+
fillHole(torsoPixels, W, torso, behindArm);
|
|
309
|
+
const torsoFilled = union(torso, behindArm);
|
|
310
|
+
|
|
311
|
+
const shift = (p: P): P => [p[0] - crop.x0, p[1] - crop.y0];
|
|
312
|
+
const placed: Omit<Rig, 'parts'> = {
|
|
313
|
+
size: [crop1.x1 - crop.x0, crop1.y1 - crop.y0],
|
|
314
|
+
ground: ground - crop.y0,
|
|
315
|
+
height: H,
|
|
316
|
+
hip: shift(hip),
|
|
317
|
+
shoulder: { near: shift(shoulder(nearArm)), far: shift(shoulder(farArm)) },
|
|
318
|
+
leg: { near: { knee: shift(nearJ.knee), ankle: shift(nearJ.ankle) }, far: { knee: shift(farJ.knee), ankle: shift(farJ.ankle) } },
|
|
319
|
+
};
|
|
320
|
+
if (style === 'warp') {
|
|
321
|
+
// The whole painting, held things and all, each pixel labelled with the part whose bones pull it.
|
|
322
|
+
const whole = silhouette(data, held ? union(person, held) : person);
|
|
323
|
+
const labels = labelPixels(whole, [[Part.Body, person], [Part.FarLeg, far], [Part.NearLeg, near], [Part.FarBoot, farBoot], [Part.NearBoot, nearBoot], [Part.FarArm, farArm], [Part.NearArm, nearArm]], person);
|
|
324
|
+
const [cw, chh] = placed.size;
|
|
325
|
+
const image = Buffer.alloc(cw * chh * 4);
|
|
326
|
+
const cropLabels = new Uint8Array(cw * chh);
|
|
327
|
+
for (let y = 0; y < chh; y++) {
|
|
328
|
+
for (let x = 0; x < cw; x++) {
|
|
329
|
+
const i = (y + crop.y0) * W + x + crop.x0;
|
|
330
|
+
if (!whole.bits[i]) continue;
|
|
331
|
+
data.copy(image, (y * cw + x) * 4, i * 4, i * 4 + 4);
|
|
332
|
+
image[(y * cw + x) * 4 + 3] = 255;
|
|
333
|
+
cropLabels[y * cw + x] = labels[i];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const base: Rig = { ...placed, parts: [] };
|
|
337
|
+
const sheet = await warpSheet(base, image, cropLabels);
|
|
338
|
+
const rig: Rig = { ...base, sheet: { file: 'walk.png', cols: sheet.cols, rows: sheet.rows, frames: sheet.frames, cell: sheet.cell, scale: sheet.scale } };
|
|
339
|
+
return { rig, parts: { 'walk.png': sheet.png }, issues };
|
|
340
|
+
}
|
|
341
|
+
const masks: Record<PartName, { mask: Mask; from: Buffer }> = {
|
|
342
|
+
farArm: { mask: farArm, from: data },
|
|
343
|
+
farThigh: { mask: farSplit.thigh, from: farWhole.pixels },
|
|
344
|
+
farShin: { mask: farSplit.shin, from: farWhole.pixels },
|
|
345
|
+
farBoot: { mask: farBoot, from: data },
|
|
346
|
+
nearThigh: { mask: nearSplit.thigh, from: nearWhole.pixels },
|
|
347
|
+
nearShin: { mask: nearSplit.shin, from: nearWhole.pixels },
|
|
348
|
+
nearBoot: { mask: nearBoot, from: data },
|
|
349
|
+
torso: { mask: torsoFilled, from: torsoPixels },
|
|
350
|
+
nearArm: { mask: nearArm, from: data },
|
|
351
|
+
};
|
|
352
|
+
const parts: Record<string, Buffer> = {};
|
|
353
|
+
const list: Rig['parts'] = [];
|
|
354
|
+
for (const name of Object.keys(masks) as PartName[]) {
|
|
355
|
+
const { mask, from } = masks[name];
|
|
356
|
+
const box = maskBox(mask);
|
|
357
|
+
if (!box) continue;
|
|
358
|
+
const [x0, y0, x1, y1] = box;
|
|
359
|
+
const w = x1 - x0;
|
|
360
|
+
const h = y1 - y0;
|
|
361
|
+
const out = Buffer.alloc(w * h * 4);
|
|
362
|
+
for (let y = 0; y < h; y++) {
|
|
363
|
+
for (let x = 0; x < w; x++) {
|
|
364
|
+
const i = (y0 + y) * W + x0 + x;
|
|
365
|
+
if (!mask.bits[i]) continue;
|
|
366
|
+
from.copy(out, (y * w + x) * 4, i * 4, i * 4 + 4);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const file = `${name}.png`;
|
|
370
|
+
parts[file] = await sharp(out, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer();
|
|
371
|
+
list.push({ name, file, at: [x0 - crop.x0, y0 - crop.y0] });
|
|
372
|
+
}
|
|
373
|
+
const rig: Rig = { ...placed, parts: list };
|
|
374
|
+
return { rig, parts, issues };
|
|
375
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// How tall the hero is at each spot: a 1.62 m person is pxPerY * (feetY - horizonY) px tall.
|
|
2
|
+
// Spark judges a person's height at eight floor points, asked three ways (median per point), from the painting's perspective; that has
|
|
3
|
+
// been the steadiest source. Object heights are a fallback: Spark's guesses at metres are worse.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { Memo } from '../meta/memo.ts';
|
|
6
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
7
|
+
import { FRAME } from './layout.ts';
|
|
8
|
+
|
|
9
|
+
export const PERSON_M = 1.62;
|
|
10
|
+
|
|
11
|
+
export type Scale = { horizonY: number; pxPerY: number; source: 'spark' | 'anchors' | 'layout' };
|
|
12
|
+
/** A person `px` tall with feet at `y`. */
|
|
13
|
+
export type Sample = { x?: number; y: number; px: number };
|
|
14
|
+
|
|
15
|
+
/** Least squares px = c * (y - h). Undefined when the points can't give a line. */
|
|
16
|
+
export function fitLine(samples: Sample[]): { c: number; h: number } | undefined {
|
|
17
|
+
if (samples.length < 2) return undefined;
|
|
18
|
+
const n = samples.length;
|
|
19
|
+
const my = samples.reduce((s, p) => s + p.y, 0) / n;
|
|
20
|
+
const mp = samples.reduce((s, p) => s + p.px, 0) / n;
|
|
21
|
+
const syy = samples.reduce((s, p) => s + (p.y - my) ** 2, 0);
|
|
22
|
+
if (syy < 1) return undefined;
|
|
23
|
+
const c = samples.reduce((s, p) => s + (p.y - my) * (p.px - mp), 0) / syy;
|
|
24
|
+
if (c <= 0) return undefined;
|
|
25
|
+
return { c, h: my - mp / c };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Least squares c with the horizon fixed. */
|
|
29
|
+
export function fitSlope(samples: Sample[], horizonY: number): number | undefined {
|
|
30
|
+
const usable = samples.filter((s) => s.y - horizonY > 20);
|
|
31
|
+
const den = usable.reduce((s, p) => s + (p.y - horizonY) ** 2, 0);
|
|
32
|
+
return den > 0 ? usable.reduce((s, p) => s + p.px * (p.y - horizonY), 0) / den : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SLOPE = { min: 0.2, max: 4 };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A scale from samples, or a reason it can't be trusted. The horizon has to sit above the highest
|
|
39
|
+
* floor (`topY`) with a margin, or the hero would shrink to nothing or flip at the back.
|
|
40
|
+
*/
|
|
41
|
+
export function scaleFrom(samples: Sample[], topY: number, layoutHorizon: number, source: Scale['source']): { scale?: Scale; why?: string } {
|
|
42
|
+
const line = fitLine(samples);
|
|
43
|
+
if (line && line.c > SLOPE.min && line.c < SLOPE.max && line.h <= topY - 20) {
|
|
44
|
+
return { scale: { horizonY: Math.round(line.h), pxPerY: round3(line.c), source } };
|
|
45
|
+
}
|
|
46
|
+
// Points that disagree on the horizon: keep the layout's horizon (if it is high enough) and fit the slope.
|
|
47
|
+
const horizonY = Math.min(layoutHorizon, topY - 40);
|
|
48
|
+
const c = fitSlope(samples, horizonY);
|
|
49
|
+
if (c && c > SLOPE.min && c < SLOPE.max) return { scale: { horizonY, pxPerY: round3(c), source } };
|
|
50
|
+
return { why: line ? `line px = ${line.c.toFixed(2)} * (y - ${Math.round(line.h)}) is implausible` : `${samples.length} usable point(s)` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Eight probe points on the floor: two per depth, from the far rows to the near ones. Spark's
|
|
57
|
+
* guesses scatter by tens of percent; more points give the line fit something to average.
|
|
58
|
+
*/
|
|
59
|
+
export function probePoints(floorRows: { y: number; xs: number[] }[]): { x: number; y: number }[] {
|
|
60
|
+
if (floorRows.length === 0) return [];
|
|
61
|
+
const sorted = [...floorRows].sort((a, b) => a.y - b.y);
|
|
62
|
+
return [0.08, 0.38, 0.68, 0.95].flatMap((q) => {
|
|
63
|
+
const row = sorted[Math.min(sorted.length - 1, Math.round(q * (sorted.length - 1)))];
|
|
64
|
+
const xs = [...row.xs].sort((a, b) => a - b);
|
|
65
|
+
return [0.3, 0.7].map((f) => ({ x: Math.round(xs[Math.floor(f * (xs.length - 1))]), y: Math.round(row.y) }));
|
|
66
|
+
}).filter((p, i, all) => all.findIndex((o) => o.y === p.y && o.x === p.x) === i);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const People = z.object({ people: z.array(z.object({ x: z.number(), y: z.number(), px: z.number() })) });
|
|
70
|
+
|
|
71
|
+
/** A thing measured on its mask, to give Spark something to compare a person with. */
|
|
72
|
+
export type Reference = { key: string; x: number; footY: number; px: number; metres: number };
|
|
73
|
+
|
|
74
|
+
/** Spark's answers to one question drift by ±15% from run to run of the pipeline (a moved point is a new
|
|
75
|
+
* question). Asking the same points in a few orders and taking each point's median holds the scale still. */
|
|
76
|
+
export const SCALE_ASKS = 3;
|
|
77
|
+
|
|
78
|
+
export async function sparkPeople(memo: Memo, label: string, painting: Buffer, points: { x: number; y: number }[], refs: Reference[] = []): Promise<Sample[]> {
|
|
79
|
+
const orders = [points, [...points].reverse(), [...points.filter((_, i) => i % 2), ...points.filter((_, i) => i % 2 === 0)]].slice(0, SCALE_ASKS);
|
|
80
|
+
const errors: unknown[] = [];
|
|
81
|
+
const takes = await Promise.all(orders.map((order, k) => askPeople(memo, k ? `${label}#${k}` : label, painting, order, refs).catch((error) => (errors.push(error), [] as Sample[]))));
|
|
82
|
+
// One take failing is fine, the others carry the median. All of them failing is not an answer of zero points: say why.
|
|
83
|
+
if (points.length && errors.length === orders.length) throw errors[0];
|
|
84
|
+
return medianPeople(points, takes);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Each point's median answer over the takes; a take that skipped a point just has no say on it. */
|
|
88
|
+
export function medianPeople(points: { x: number; y: number }[], takes: Sample[][]): Sample[] {
|
|
89
|
+
return points.flatMap((p) => {
|
|
90
|
+
const px = takes.flatMap((take) => take.filter((s) => Math.abs((s.x ?? p.x) - p.x) <= 4 && Math.abs(s.y - p.y) <= 4).slice(0, 1).map((s) => s.px)).sort((a, b) => a - b);
|
|
91
|
+
if (!px.length) return [];
|
|
92
|
+
const mid = px.length >> 1;
|
|
93
|
+
return [{ x: p.x, y: p.y, px: px.length % 2 ? px[mid] : (px[mid - 1] + px[mid]) / 2 }];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function askPeople(memo: Memo, label: string, painting: Buffer, points: { x: number; y: number }[], refs: Reference[]): Promise<Sample[]> {
|
|
98
|
+
const known = refs.length
|
|
99
|
+
? `\nFor reference, measured in this painting (the real heights are rough guesses):\n${refs.map((r) => `- ${r.key}: stands at (${Math.round(r.x)}, ${Math.round(r.footY)}), ${Math.round(r.px)} px tall, roughly ${r.metres} m`).join('\n')}\n`
|
|
100
|
+
: '';
|
|
101
|
+
const prompt = `This is a painted background for a 2D adventure game (${FRAME.width}x${FRAME.height} px, x right, y down). Judge its perspective from the things in it (doors, boats, steps, furniture, trees, planks): things farther away are smaller, and a person is drawn smaller the higher their feet are in the picture.${known}
|
|
102
|
+
For each point on the ground below, how tall in pixels would an adult ${PERSON_M} m tall be, standing with their feet there?
|
|
103
|
+
Points: ${points.map((p) => `(${p.x}, ${p.y})`).join(', ')}
|
|
104
|
+
|
|
105
|
+
Reply with JSON only: {"people": [{"x": 0, "y": 0, "px": 0}]}`;
|
|
106
|
+
const { people } = People.parse(await askSparkJson(memo, { schema: People, label, prompt, images: [painting] }));
|
|
107
|
+
return people.filter((p) => p.px > 10 && p.px < 900);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A door on the floor: a person standing at its foot is about PERSON_M / metres of its height. `atLeast` when
|
|
112
|
+
* the frame cuts its top off, so the person is at least that tall there.
|
|
113
|
+
*/
|
|
114
|
+
export type DoorRef = { key: string; y: number; px: number; atLeast: boolean };
|
|
115
|
+
|
|
116
|
+
/** A door the scale may disagree with by this much before it is pulled to the door. */
|
|
117
|
+
const DOOR_TOL = { low: 0.85, high: 1.3 };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The scale held against a door. Spark judges a person from the whole picture and, in a close-up room, shrank
|
|
121
|
+
* the hero at the back to half the door's height; nothing in a room says how tall a person is as plainly as
|
|
122
|
+
* its door. When the scale disagrees, the line is fitted through the door's person with Spark's slope
|
|
123
|
+
* (least squares on the other samples), else with the old slope through the door. Undefined when it agrees.
|
|
124
|
+
*/
|
|
125
|
+
export function doorCheck(scale: Scale, samples: Sample[], door: DoorRef, topY: number): { scale?: Scale; why: string } | undefined {
|
|
126
|
+
const implied = scale.pxPerY * (door.y - scale.horizonY);
|
|
127
|
+
const tooSmall = implied < DOOR_TOL.low * door.px;
|
|
128
|
+
const tooBig = !door.atLeast && implied > DOOR_TOL.high * door.px;
|
|
129
|
+
if (!tooSmall && !tooBig) return undefined;
|
|
130
|
+
const said = `a person by ${door.key} would be ${Math.round(implied)} px, the door says ${door.atLeast ? 'at least ' : ''}${Math.round(door.px)} px`;
|
|
131
|
+
const fits = (c: number, h: number) => c > SLOPE.min && c < SLOPE.max && h <= topY - 20;
|
|
132
|
+
const usable = samples.filter((s) => Math.abs(s.y - door.y) > 20);
|
|
133
|
+
const den = usable.reduce((s, p) => s + (p.y - door.y) ** 2, 0);
|
|
134
|
+
const slope = den > 0 ? usable.reduce((s, p) => s + (p.y - door.y) * (p.px - door.px), 0) / den : 0;
|
|
135
|
+
for (const c of [slope, scale.pxPerY]) {
|
|
136
|
+
const h = door.y - door.px / c;
|
|
137
|
+
if (c > 0 && fits(c, h)) return { scale: { ...scale, horizonY: Math.round(h), pxPerY: round3(c) }, why: `${said}: scale set through the door` };
|
|
138
|
+
}
|
|
139
|
+
return { why: `${said}, and no plausible line goes through it` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Words that name a door a person walks through (not a trapdoor or a door mat). */
|
|
143
|
+
const DOOR_WORD = /(^|[^a-z])(door|doorway)s?([^a-z]|$)/i;
|
|
144
|
+
const NOT_A_DOOR = /trap|mat|knob|handle|bell|oven|fridge|cupboard|cabinet|wardrobe|closet|locker|car\b/i;
|
|
145
|
+
|
|
146
|
+
export function isDoor(name: string, metres: number | undefined): boolean {
|
|
147
|
+
return DOOR_WORD.test(name) && !NOT_A_DOOR.test(name) && (metres ?? 0) >= 1.8;
|
|
148
|
+
}
|