@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,392 @@
|
|
|
1
|
+
// masks: one SAM 3.1 mask per painted thing and per floor, checked against where Spark saw it.
|
|
2
|
+
// Things: SAM by phrase, the instances near Spark's box; a mask that fails the sanity gate is
|
|
3
|
+
// asked again with the box; if that fails too the box itself is the mask, marked coarse.
|
|
4
|
+
// Floors: SAM by name and by the box of Spark's cells; the candidate that agrees best with the
|
|
5
|
+
// cells wins; with none close enough the cells are the floor, marked coarse.
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
|
|
8
|
+
import { clampRect, components, dilate, fillHoles, framedOpening, growRect, intersect, union, subtract, maskBox, maskIou, polygonMask, rectArea, rectIou, rectMask, unionAll, withoutSpecks, type Rect } from '../image/raster.ts';
|
|
9
|
+
import type { Memo } from '../meta/memo.ts';
|
|
10
|
+
import { SAM_MAX_BOXES, samFind, type Box, type Found } from '../meta/sam.ts';
|
|
11
|
+
import type { Place } from '../world/schema.ts';
|
|
12
|
+
import { FRAME, type Layout } from './layout.ts';
|
|
13
|
+
import { cellsBox, cellsMask, GRID, type Kind, type Spots } from './locate.ts';
|
|
14
|
+
|
|
15
|
+
export type MaskSource = 'sam-text' | 'sam-box' | 'colour-cut' | 'sketch-colour' | 'spark-box' | 'layout-box' | 'spark-cells' | 'layout';
|
|
16
|
+
|
|
17
|
+
/** whole: px added by the whole-thing pass (parts SAM by name left out: what sits in or on it). */
|
|
18
|
+
export type ThingMask = { key: string; kind: Kind; phrase: string; source: MaskSource; coarse: boolean; px: number; box: Rect; mask: Mask; whole?: number };
|
|
19
|
+
export type FloorMask = { key: string; source: MaskSource; coarse: boolean; px: number; iouCells: number; iouLayout: number; mask: Mask };
|
|
20
|
+
|
|
21
|
+
const { width: W, height: H } = FRAME;
|
|
22
|
+
const toBox = ([left, top, right, bottom]: Rect): Box => ({ left, top, right, bottom });
|
|
23
|
+
const fromBox = (b: Box): Rect => [b.left, b.top, b.right, b.bottom];
|
|
24
|
+
|
|
25
|
+
/** Where the layout put a thing, for things Spark could not find. */
|
|
26
|
+
export function layoutBox(layout: Layout, key: string): Rect | undefined {
|
|
27
|
+
const t = layout.things.find((b) => b.key === key);
|
|
28
|
+
return t && clampRect([t.x - t.width / 2, t.footY - t.height, t.x + t.width / 2, t.footY], W, H);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The mask-sanity gate for a thing against the box it should fill. Empty is not found; much bigger
|
|
33
|
+
* than the box while barely overlapping it is a different object (SAM takes every instance it sees).
|
|
34
|
+
*/
|
|
35
|
+
export function thingMaskFault(mask: Mask, prior: Rect): string | undefined {
|
|
36
|
+
const px = maskArea(mask);
|
|
37
|
+
if (px < 150) return px === 0 ? 'empty' : `only ${px} px`;
|
|
38
|
+
const box = maskBox(mask)!;
|
|
39
|
+
const iou = rectIou(box, prior);
|
|
40
|
+
if (px > 3 * rectArea(prior) && iou < 0.3) return `${px} px is over 3x its box and overlaps it ${iou.toFixed(2)}`;
|
|
41
|
+
if (iou < 0.1) return `lies outside its box (overlap ${iou.toFixed(2)})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A box prompt sometimes returns what surrounds the thing (the puddle around a can) instead of it.
|
|
46
|
+
* Such a mask leaves the middle of the box empty.
|
|
47
|
+
*/
|
|
48
|
+
export function hollowFault(mask: Mask, [x0, y0, x1, y1]: Rect): string | undefined {
|
|
49
|
+
const [w, h] = [x1 - x0, y1 - y0];
|
|
50
|
+
const middle = rectMask(mask.width, mask.height, [x0 + w / 3, y0 + h / 3, x1 - w / 3, y1 - h / 3]);
|
|
51
|
+
const area = maskArea(middle);
|
|
52
|
+
const covered = area > 0 ? maskArea(intersect(mask, middle)) / area : 1;
|
|
53
|
+
if (covered < 0.25) return `covers only ${Math.round(covered * 100)}% of the middle of its box`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A way out set in a wall (a doorway, an arch, a gate) asked of SAM often comes back as its frame: posts and lintel,
|
|
58
|
+
* the middle empty (hollowFault). The way out is the gap the frame holds, open at the bottom where the floor runs
|
|
59
|
+
* through. Undefined when the frame holds no gap worth the name (a closed door, a wall).
|
|
60
|
+
*/
|
|
61
|
+
export function openingOf(frame: Mask): Mask | undefined {
|
|
62
|
+
const box = maskBox(frame);
|
|
63
|
+
if (!box) return undefined;
|
|
64
|
+
// Slivers where a post was cut ragged are not the opening.
|
|
65
|
+
const gap = components(dilate(dilate(framedOpening(frame, box), -3), 3))[0];
|
|
66
|
+
if (!gap) return undefined;
|
|
67
|
+
const filled = fillHoles(gap);
|
|
68
|
+
return maskArea(filled) >= 0.2 * rectArea(box) && !hollowFault(filled, maskBox(filled)!) ? filled : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The last try before a plain box: inside the box, the pixels whose colour stands out from a ring
|
|
73
|
+
* just outside it (the floor or wall around the thing), as one solid blob.
|
|
74
|
+
*/
|
|
75
|
+
export function colourCut(rgb: Buffer, box: Rect, ring = 12): Mask | undefined {
|
|
76
|
+
const [x0, y0, x1, y1] = clampRect(box, W, H);
|
|
77
|
+
const [ox0, oy0, ox1, oy1] = clampRect([x0 - ring, y0 - ring, x1 + ring, y1 + ring], W, H);
|
|
78
|
+
const sum = [0, 0, 0];
|
|
79
|
+
const sq = [0, 0, 0];
|
|
80
|
+
let n = 0;
|
|
81
|
+
for (let y = oy0; y < oy1; y++) {
|
|
82
|
+
for (let x = ox0; x < ox1; x++) {
|
|
83
|
+
if (x >= x0 && x < x1 && y >= y0 && y < y1) continue;
|
|
84
|
+
for (let c = 0; c < 3; c++) {
|
|
85
|
+
const v = rgb[(y * W + x) * 3 + c];
|
|
86
|
+
sum[c] += v;
|
|
87
|
+
sq[c] += v * v;
|
|
88
|
+
}
|
|
89
|
+
n++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (n === 0) return undefined;
|
|
93
|
+
const mean = sum.map((v) => v / n);
|
|
94
|
+
const spread = Math.sqrt(sq.reduce((t, v, c) => t + v / n - mean[c] ** 2, 0));
|
|
95
|
+
const limit = Math.max(40, 2 * spread);
|
|
96
|
+
const raw = emptyMask(W, H);
|
|
97
|
+
for (let y = y0; y < y1; y++) {
|
|
98
|
+
for (let x = x0; x < x1; x++) {
|
|
99
|
+
const i = y * W + x;
|
|
100
|
+
const d = Math.hypot(rgb[i * 3] - mean[0], rgb[i * 3 + 1] - mean[1], rgb[i * 3 + 2] - mean[2]);
|
|
101
|
+
if (d > limit) raw.bits[i] = 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const blob = components(dilate(dilate(raw, 2), -2))[0];
|
|
105
|
+
if (!blob) return undefined;
|
|
106
|
+
// Fill holes: whatever inside the box the outside can't reach is part of the thing.
|
|
107
|
+
const outside = emptyMask(W, H);
|
|
108
|
+
const stack: number[] = [];
|
|
109
|
+
for (let x = x0; x < x1; x++) stack.push(y0 * W + x, (y1 - 1) * W + x);
|
|
110
|
+
for (let y = y0; y < y1; y++) stack.push(y * W + x0, y * W + x1 - 1);
|
|
111
|
+
while (stack.length) {
|
|
112
|
+
const i = stack.pop()!;
|
|
113
|
+
const x = i % W;
|
|
114
|
+
const y = Math.floor(i / W);
|
|
115
|
+
if (x < x0 || x >= x1 || y < y0 || y >= y1 || outside.bits[i] || blob.bits[i]) continue;
|
|
116
|
+
outside.bits[i] = 1;
|
|
117
|
+
stack.push(i - 1, i + 1, i - W, i + W);
|
|
118
|
+
}
|
|
119
|
+
const solid = rectMask(W, H, [x0, y0, x1, y1]);
|
|
120
|
+
const filled = subtract(solid, outside);
|
|
121
|
+
const share = maskArea(filled) / Math.max(1, rectArea([x0, y0, x1, y1]));
|
|
122
|
+
return share > 0.15 && share < 0.92 ? filled : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* After the colour cut, before a plain box: inside the box, the pixels of the hue the layout gave the thing (the
|
|
127
|
+
* sketch Muse painted over), as they lie: walls, a hedge, a row of shelves, not one solid blob. A box as the mask
|
|
128
|
+
* draws its hover outline as a rectangle over everything in it. Undefined when that hue is not the thing's: too
|
|
129
|
+
* little or nearly all of the box has it.
|
|
130
|
+
*/
|
|
131
|
+
export function layoutColourCut(rgb: Buffer, box: Rect, colour: string): Mask | undefined {
|
|
132
|
+
const hex = /^#?([0-9a-f]{6})$/i.exec(colour.trim())?.[1];
|
|
133
|
+
if (!hex) return undefined;
|
|
134
|
+
const want = hsv(...([0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number]));
|
|
135
|
+
// A grey or cream sketch colour names no hue to find.
|
|
136
|
+
if (want.s < 0.25) return undefined;
|
|
137
|
+
const near = (i: number) => {
|
|
138
|
+
const c = hsv(rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]);
|
|
139
|
+
// Painted light and shadow change brightness and some saturation, not hue.
|
|
140
|
+
return c.s > 0.18 && c.v > 0.12 && Math.min(Math.abs(c.h - want.h), 360 - Math.abs(c.h - want.h)) < 16;
|
|
141
|
+
};
|
|
142
|
+
const [x0, y0, x1, y1] = clampRect(box, W, H);
|
|
143
|
+
const raw = emptyMask(W, H);
|
|
144
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) if (near(y * W + x)) raw.bits[y * W + x] = 1;
|
|
145
|
+
const area = rectArea([x0, y0, x1, y1]);
|
|
146
|
+
const kept = withoutSpecks(dilate(dilate(raw, 2), -2), Math.max(80, area * 0.002));
|
|
147
|
+
const share = maskArea(kept) / Math.max(1, area);
|
|
148
|
+
return share > 0.05 && share < 0.85 ? kept : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
|
|
152
|
+
const max = Math.max(r, g, b);
|
|
153
|
+
const d = max - Math.min(r, g, b);
|
|
154
|
+
const h = d === 0 ? 0 : max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
155
|
+
return { h: h * 60, s: max ? d / max : 0, v: max / 255 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A single SAM match near a misplaced box: similar size, centre within twice the box's size. */
|
|
159
|
+
export function loneMatch(found: Rect, box: Rect): boolean {
|
|
160
|
+
const ratio = rectArea(found) / Math.max(1, rectArea(box));
|
|
161
|
+
const size = Math.max(box[2] - box[0], box[3] - box[1]);
|
|
162
|
+
const dist = Math.hypot((found[0] + found[2] - box[0] - box[2]) / 2, (found[1] + found[3] - box[1] - box[3]) / 2);
|
|
163
|
+
return ratio > 0.25 && ratio < 4 && dist < 2 * size;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The instances that belong to the thing: the one nearest its box, plus any others mostly inside it. */
|
|
167
|
+
export function pickInstances(found: Found[], prior: Rect, passage = false): Mask | undefined {
|
|
168
|
+
if (found.length === 0) return undefined;
|
|
169
|
+
const scored = found.map((f) => {
|
|
170
|
+
const box = fromBox(f.box);
|
|
171
|
+
const inside = rectArea([Math.max(box[0], prior[0]), Math.max(box[1], prior[1]), Math.min(box[2], prior[2]), Math.min(box[3], prior[3])]);
|
|
172
|
+
return { f, iou: rectIou(box, prior), inside: rectArea(box) > 0 ? inside / rectArea(box) : 0 };
|
|
173
|
+
});
|
|
174
|
+
const best = scored.reduce((a, b) => (b.iou > a.iou ? b : a));
|
|
175
|
+
if (best.iou === 0) return undefined;
|
|
176
|
+
const keep = scored.filter((s) => s === best || s.inside > 0.6);
|
|
177
|
+
const mask = unionAll(W, H, keep.map((s) => s.f.mask));
|
|
178
|
+
// Stray specks of a lossy mask would each become an occluder later.
|
|
179
|
+
return withoutSpecks(mask, passage ? 8 : Math.max(60, maskArea(mask) * 0.03));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The box SAM is asked by for the whole thing: its mask's box joined with Spark's (Spark boxes a "bucket of fish
|
|
184
|
+
* heads" with the fish), grown on the top and the sides, where contents stick out; barely below, that is floor.
|
|
185
|
+
*/
|
|
186
|
+
export function wholeBox(mask: Rect, spark: Rect | undefined): Rect {
|
|
187
|
+
const [x0, y0, x1, y1] = spark ? [Math.min(mask[0], spark[0]), Math.min(mask[1], spark[1]), Math.max(mask[2], spark[2]), Math.max(mask[3], spark[3])] : mask;
|
|
188
|
+
const g = 0.15 * Math.max(x1 - x0, y1 - y0);
|
|
189
|
+
return clampRect([x0 - g, y0 - 2 * g, x1 + g, y1 + 0.2 * g], W, H);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The thing as one piece: SAM by name (`named`) joined with the parts SAM by the whole box (`boxed`) finds attached
|
|
194
|
+
* to it. Refused when the box found something else (it covers little of the named mask) or took in its surroundings
|
|
195
|
+
* (it adds over twice the thing itself); `others` (the other things) are never taken. Specks and slivers along the
|
|
196
|
+
* outline are not parts. Returns the mask and the px it gained (0: as it was).
|
|
197
|
+
*/
|
|
198
|
+
export function wholeThing(named: Mask, boxed: Mask | undefined, others: Mask, spark?: Rect): { mask: Mask; added: number; why?: string } {
|
|
199
|
+
const px = maskArea(named);
|
|
200
|
+
if (!boxed || px === 0) return { mask: named, added: 0 };
|
|
201
|
+
const cover = maskArea(intersect(boxed, named)) / px;
|
|
202
|
+
if (cover < 0.6) return { mask: named, added: 0, why: `the box found something else (covers ${Math.round(cover * 100)}% of it)` };
|
|
203
|
+
// Spark boxed the whole thing as it saw it: what SAM's grown box finds past that (wall, ceiling) is not the thing.
|
|
204
|
+
const within = spark ? intersect(boxed, rectMask(named.width, named.height, clampRect(growRect(spark, 1.1), named.width, named.height))) : boxed;
|
|
205
|
+
const extra = subtract(subtract(within, dilate(named, 2)), others);
|
|
206
|
+
const touch = dilate(named, 4);
|
|
207
|
+
const parts = components(extra).filter((c) => maskArea(c) >= Math.max(80, 0.01 * px) && maskArea(intersect(c, touch)) > 0);
|
|
208
|
+
const added = parts.reduce((n, c) => n + maskArea(c), 0);
|
|
209
|
+
if (added < 0.03 * px) return { mask: named, added: 0 };
|
|
210
|
+
// Contents can be as big as their container (a heap of fish over a small bucket), not much bigger. SAM by name can
|
|
211
|
+
// also take a sliver of the thing (the boards of a shelf without its frame and tins): then Spark's box says how big
|
|
212
|
+
// the thing is, and what fills it is the thing.
|
|
213
|
+
const size = spark ? Math.max(px, 0.5 * rectArea(spark)) : px;
|
|
214
|
+
if (added > 2 * size) return { mask: named, added: 0, why: `the box took in ${added} px around it, over twice the thing` };
|
|
215
|
+
// The 2 px seam left between the mask and its parts closes with them.
|
|
216
|
+
const joined = unionAll(named.width, named.height, [named, ...parts]);
|
|
217
|
+
const closed = subtract(dilate(dilate(joined, 3), -3), others);
|
|
218
|
+
return { mask: closed, added: maskArea(closed) - px };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function samBoxes(memo: Memo, label: string, painting: Buffer, boxes: Rect[]): Promise<(Mask | undefined)[]> {
|
|
222
|
+
const out: (Mask | undefined)[] = [];
|
|
223
|
+
for (let i = 0; i < boxes.length; i += SAM_MAX_BOXES) {
|
|
224
|
+
const chunk = boxes.slice(i, i + SAM_MAX_BOXES);
|
|
225
|
+
const found = await samFind(memo, { label: `${label}#${i / SAM_MAX_BOXES}`, image: painting, prompt: chunk.map(toBox) });
|
|
226
|
+
// One object per box; the parser keeps the prompt's order as the object id.
|
|
227
|
+
chunk.forEach((_, j) => out.push(found.find((f) => f.id === String(j))?.mask));
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function maskThings(memo: Memo, place: Place, layout: Layout, spots: Spots, painting: Buffer, log: (line: string) => void): Promise<ThingMask[]> {
|
|
233
|
+
const painted = place.things.filter((t) => t.role !== 'npc');
|
|
234
|
+
const tried = await Promise.all(painted.map(async (t) => {
|
|
235
|
+
const spot = spots[t.key];
|
|
236
|
+
const sparkBox = spot.box && clampRect(spot.box, W, H);
|
|
237
|
+
let prior = sparkBox && rectArea(sparkBox) > 0 ? sparkBox : layoutBox(layout, t.key)!;
|
|
238
|
+
// The phrase, Spark's other name for it, then the head noun: SAM often knows "can" when it misses "brass oil can".
|
|
239
|
+
const passage = t.role === 'exit' && (spot.kind === 'walk-on' || spot.kind === 'flat');
|
|
240
|
+
const phrases = [...new Set([...(passage ? [`entire ${spot.phrase}, all visible steps and landings up to the edge of the image`] : []), spot.phrase, spot.also, spot.phrase.trim().split(/\s+/).at(-1)].filter((p): p is string => !!p?.trim()))];
|
|
241
|
+
let mask: Mask | undefined;
|
|
242
|
+
let lone: Found | undefined;
|
|
243
|
+
for (const [i, phrase] of phrases.entries()) {
|
|
244
|
+
const found = await samFind(memo, { label: `mask:${place.key}/${t.key}${i ? `~${phrase}` : ''}`, image: painting, prompt: phrase });
|
|
245
|
+
mask = pickInstances(found, prior, passage);
|
|
246
|
+
if (mask) break;
|
|
247
|
+
if (found.length === 1 && !lone) lone = found[0];
|
|
248
|
+
}
|
|
249
|
+
// Spark's box can sit beside the thing. One SAM match of the right size close by is the thing.
|
|
250
|
+
if (!mask && lone && sparkBox && loneMatch(fromBox(lone.box), sparkBox)) {
|
|
251
|
+
log(`${t.key}: Spark's box misses it; took SAM's only match nearby`);
|
|
252
|
+
mask = withoutSpecks(lone.mask, Math.max(60, maskArea(lone.mask) * 0.03));
|
|
253
|
+
prior = fromBox(lone.box);
|
|
254
|
+
}
|
|
255
|
+
const opening = t.role === 'exit' && spot.kind === 'wall';
|
|
256
|
+
if (mask && opening && hollowFault(mask, maskBox(mask)!)) {
|
|
257
|
+
const gap = openingOf(mask);
|
|
258
|
+
if (gap) {
|
|
259
|
+
log(`${t.key}: SAM by name gave the frame of the way out; took the opening it holds`);
|
|
260
|
+
mask = gap;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const fault = mask ? thingMaskFault(mask, prior) : `SAM found no "${phrases.join('" or "')}"`;
|
|
264
|
+
return { t, spot, prior, opening, fromSpark: !!sparkBox && prior !== layoutBox(layout, t.key), mask, fault };
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
const retry = tried.filter((r) => r.fault);
|
|
268
|
+
const pixels = await sharp(painting).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer();
|
|
269
|
+
const boxed = await samBoxes(memo, `mask-box:${place.key}`, painting, retry.map((r) => clampRect(r.prior, W, H)));
|
|
270
|
+
const masked = tried.map((r) => {
|
|
271
|
+
const base = { key: r.t.key, kind: r.spot.kind, phrase: r.spot.phrase };
|
|
272
|
+
let mask = r.mask;
|
|
273
|
+
let source: MaskSource = 'sam-text';
|
|
274
|
+
if (r.fault) {
|
|
275
|
+
log(`${r.t.key}: ${r.fault}, asking SAM with the box`);
|
|
276
|
+
let byBox = boxed[retry.indexOf(r)];
|
|
277
|
+
// The box around a doorway brings back its frame; the opening it holds is the way out.
|
|
278
|
+
const gap = r.opening && byBox && hollowFault(byBox, r.prior) ? openingOf(byBox) : undefined;
|
|
279
|
+
if (gap) {
|
|
280
|
+
log(`${r.t.key}: SAM by the box gave the frame of the way out; took the opening it holds`);
|
|
281
|
+
byBox = gap;
|
|
282
|
+
}
|
|
283
|
+
const boxFault = byBox ? thingMaskFault(byBox, r.prior) ?? hollowFault(byBox, r.prior) : 'SAM box found nothing';
|
|
284
|
+
if (byBox && !boxFault) {
|
|
285
|
+
mask = withoutSpecks(byBox, Math.max(60, maskArea(byBox) * 0.03));
|
|
286
|
+
source = 'sam-box';
|
|
287
|
+
} else {
|
|
288
|
+
const cut = colourCut(pixels, r.prior);
|
|
289
|
+
const cutFault = cut ? thingMaskFault(cut, r.prior) ?? hollowFault(cut, r.prior) : 'nothing stands out from its surroundings';
|
|
290
|
+
const planned = layout.things.find((b) => b.key === r.t.key)?.colour;
|
|
291
|
+
const byColour = cut && !cutFault ? undefined : planned ? layoutColourCut(pixels, r.prior, planned) : undefined;
|
|
292
|
+
if (cut && !cutFault) {
|
|
293
|
+
mask = cut;
|
|
294
|
+
source = 'colour-cut';
|
|
295
|
+
log(`${r.t.key}: ${boxFault}; cut it out of its box by colour`);
|
|
296
|
+
} else if (byColour) {
|
|
297
|
+
mask = byColour;
|
|
298
|
+
source = 'sketch-colour';
|
|
299
|
+
log(`${r.t.key}: ${boxFault}; colour cut: ${cutFault}; took the pixels of its sketch colour ${planned} in its box`);
|
|
300
|
+
} else {
|
|
301
|
+
mask = rectMask(W, H, r.prior);
|
|
302
|
+
source = r.fromSpark ? 'spark-box' : 'layout-box';
|
|
303
|
+
log(`${r.t.key}: ${boxFault}; colour cut: ${cutFault}; the ${source} is its mask (coarse)`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { ...base, source, coarse: source === 'spark-box' || source === 'layout-box', px: maskArea(mask!), box: maskBox(mask!)!, mask: mask!, prior: r.spot.box ? clampRect(r.spot.box, W, H) : undefined };
|
|
308
|
+
});
|
|
309
|
+
return wholeThings(memo, place, painting, masked, log);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Did the parts found reach the edge of the box asked by (its top or a side, inside the frame)? Then there is more of it. */
|
|
313
|
+
export function reachesEdge(found: Rect, asked: Rect): boolean {
|
|
314
|
+
const near = 6;
|
|
315
|
+
return (asked[1] > 0 && found[1] - asked[1] < near) || (asked[0] > 0 && found[0] - asked[0] < near) || (asked[2] < W && asked[2] - found[2] < near);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Things that stand or sit (not floors laid flat): a batch of box prompts per place, see wholeThing. Parts that
|
|
320
|
+
* reach the edge of the box are asked once more by a box grown around them (fish piled high over a rim).
|
|
321
|
+
*/
|
|
322
|
+
async function wholeThings(memo: Memo, place: Place, painting: Buffer, masked: Array<ThingMask & { prior?: Rect }>, log: (line: string) => void): Promise<ThingMask[]> {
|
|
323
|
+
const out: ThingMask[] = masked.map(({ prior: _, ...t }) => t);
|
|
324
|
+
// A way out set in a wall (a door, an arch, a gate) has nothing in or on it: SAM by its grown box finds the house
|
|
325
|
+
// around it, and the door, joined with part of the house, then reads far taller than a person can pass.
|
|
326
|
+
const opening = (t: ThingMask) => t.kind === 'wall' && place.things.find((p) => p.key === t.key)?.role === 'exit';
|
|
327
|
+
let asked: { key: string; box: Rect; spark?: Rect }[] = masked.filter((t) => t.source === 'sam-text' && t.kind !== 'walk-on' && t.kind !== 'flat' && !opening(t)).map((t) => ({ key: t.key, box: wholeBox(t.box, t.prior), spark: t.prior }));
|
|
328
|
+
for (let round = 0; round < 2 && asked.length; round++) {
|
|
329
|
+
const boxed = await samBoxes(memo, `mask-whole${round ? round + 1 : ''}:${place.key}`, painting, asked.map((a) => a.box));
|
|
330
|
+
const again: typeof asked = [];
|
|
331
|
+
asked.forEach((a, i) => {
|
|
332
|
+
const k = out.findIndex((t) => t.key === a.key);
|
|
333
|
+
const t = out[k];
|
|
334
|
+
const others = unionAll(W, H, out.filter((o) => o.key !== t.key).map((o) => o.mask));
|
|
335
|
+
const whole = wholeThing(t.mask, boxed[i], others, a.spark);
|
|
336
|
+
if (whole.why) console.log(` ${place.key}: ${t.key}: kept its mask, ${whole.why}`);
|
|
337
|
+
if (!whole.added) return;
|
|
338
|
+
const box = maskBox(whole.mask)!;
|
|
339
|
+
out[k] = { ...t, mask: whole.mask, px: maskArea(whole.mask), box, whole: (t.whole ?? 0) + whole.added };
|
|
340
|
+
if (reachesEdge(box, a.box)) again.push({ key: t.key, box: wholeBox(box, undefined), spark: undefined });
|
|
341
|
+
});
|
|
342
|
+
asked = again;
|
|
343
|
+
}
|
|
344
|
+
for (const t of out) if (t.whole) log(`${t.key}: SAM by name left parts of it out (what is in or on it); ${t.whole} px joined from SAM by its whole box`);
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A floor as one surface: SAM cuts around everything lying on it (wood shavings, pebbles, leaves) and frays where
|
|
350
|
+
* the floor fades into fog or shadow. Gaps of a few px close, and holes it closes on every side up to 1.5% of the
|
|
351
|
+
* frame are filled: what lies there is walked over. Bigger holes stay (a pond, the floor under a wide table);
|
|
352
|
+
* standing things are cut out of the walk by their own masks later.
|
|
353
|
+
*/
|
|
354
|
+
export function solidFloor(m: Mask): Mask {
|
|
355
|
+
const closed = union(m, dilate(dilate(m, 3), -3));
|
|
356
|
+
return withoutSpecks(fillHoles(closed, 0.015 * m.width * m.height), 200);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Keeps a floor candidate to within one grid cell of Spark's marking and scores the agreement. */
|
|
360
|
+
export function scoreFloor(candidate: Mask, marked: Mask): { mask: Mask; iou: number } {
|
|
361
|
+
const mask = intersect(candidate, dilate(marked, GRID));
|
|
362
|
+
return { mask, iou: maskIou(mask, marked) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function maskFloors(memo: Memo, place: Place, layout: Layout, marked: Record<string, string[]>, painting: Buffer, log: (line: string) => void): Promise<FloorMask[]> {
|
|
366
|
+
const boxes = layout.floors.map((f) => cellsBox(marked[f.key]));
|
|
367
|
+
const withBox = boxes.flatMap((b, i) => (b ? [{ i, box: clampRect([b[0] - GRID / 2, b[1] - GRID / 2, b[2] + GRID / 2, b[3] + GRID / 2], W, H) }] : []));
|
|
368
|
+
const byBox = await samBoxes(memo, `floor-box:${place.key}`, painting, withBox.map((b) => b.box));
|
|
369
|
+
|
|
370
|
+
return Promise.all(layout.floors.map(async (floor, i) => {
|
|
371
|
+
const planned = polygonMask(W, H, floor.outline);
|
|
372
|
+
const cells = cellsMask(marked[floor.key]);
|
|
373
|
+
if (maskArea(cells) === 0) {
|
|
374
|
+
log(`floor ${floor.key}: Spark marked no cells; using the layout outline (coarse)`);
|
|
375
|
+
return { key: floor.key, source: 'layout' as const, coarse: true, px: maskArea(planned), iouCells: 0, iouLayout: 1, mask: planned };
|
|
376
|
+
}
|
|
377
|
+
const found = await samFind(memo, { label: `floor:${place.key}/${floor.key}`, image: painting, prompt: floor.key.replace(/-/g, ' ') });
|
|
378
|
+
const candidates: { source: MaskSource; mask: Mask }[] = [
|
|
379
|
+
...found.map((f) => ({ source: 'sam-text' as const, mask: f.mask })),
|
|
380
|
+
...(found.length > 1 ? [{ source: 'sam-text' as const, mask: unionAll(W, H, found.map((f) => f.mask)) }] : []),
|
|
381
|
+
];
|
|
382
|
+
const boxed = byBox[withBox.findIndex((b) => b.i === i)];
|
|
383
|
+
if (boxed) candidates.push({ source: 'sam-box', mask: boxed });
|
|
384
|
+
const scored = candidates.map((c) => ({ ...c, ...scoreFloor(solidFloor(c.mask), cells) })).sort((a, b) => b.iou - a.iou);
|
|
385
|
+
const best = scored[0];
|
|
386
|
+
if (best && best.iou >= 0.3) {
|
|
387
|
+
return { key: floor.key, source: best.source, coarse: false, px: maskArea(best.mask), iouCells: best.iou, iouLayout: maskIou(best.mask, planned), mask: best.mask };
|
|
388
|
+
}
|
|
389
|
+
log(`floor ${floor.key}: best SAM candidate agrees ${(best?.iou ?? 0).toFixed(2)} with Spark's cells; using the cells (coarse)`);
|
|
390
|
+
return { key: floor.key, source: 'spark-cells' as const, coarse: true, px: maskArea(cells), iouCells: 1, iouLayout: maskIou(cells, planned), mask: cells };
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Where a thing meets the floor. A standing thing hides the hero when his feet are behind its floor
|
|
2
|
+
// line, decided per column, so a slanted boat or a table seen from the corner hides him correctly.
|
|
3
|
+
import { maskArea, type Mask } from '../image/mask.ts';
|
|
4
|
+
import { bottomRows, components, maskBox, type Rect } from '../image/raster.ts';
|
|
5
|
+
|
|
6
|
+
export type XY = [number, number];
|
|
7
|
+
|
|
8
|
+
/** Blobs smaller than this don't hide anyone; they are specks or tiny details. */
|
|
9
|
+
export const MIN_OCCLUDER_PX = 900;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The chain of the lowest points that bulges downward (y grows down): the floor contact of a
|
|
13
|
+
* blob's bottom edge, bridging gaps between legs. Points must be sorted by x.
|
|
14
|
+
*/
|
|
15
|
+
export function lowerHull(points: XY[]): XY[] {
|
|
16
|
+
const hull: XY[] = [];
|
|
17
|
+
for (const p of points) {
|
|
18
|
+
// Pop the last point while it lies above (smaller y than) the line from the one before it to p.
|
|
19
|
+
while (hull.length >= 2) {
|
|
20
|
+
const [ax, ay] = hull[hull.length - 2];
|
|
21
|
+
const [bx, by] = hull[hull.length - 1];
|
|
22
|
+
const cross = (bx - ax) * (p[1] - ay) - (by - ay) * (p[0] - ax);
|
|
23
|
+
if (cross >= 0) hull.pop();
|
|
24
|
+
else break;
|
|
25
|
+
}
|
|
26
|
+
hull.push(p);
|
|
27
|
+
}
|
|
28
|
+
return hull;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Drops steep ends of the hull: they come from overhangs (a lantern arm, a roof) and not from the floor. */
|
|
32
|
+
export function trimSteepEnds(line: XY[]): XY[] {
|
|
33
|
+
const steep = (a: XY, b: XY) => Math.abs(b[1] - a[1]) > 2 * Math.abs(b[0] - a[0]);
|
|
34
|
+
let from = 0;
|
|
35
|
+
let to = line.length - 1;
|
|
36
|
+
while (to - from >= 1 && steep(line[from], line[from + 1])) from++;
|
|
37
|
+
while (to - from >= 1 && steep(line[to - 1], line[to])) to--;
|
|
38
|
+
return line.slice(from, to + 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The floor line of a blob: per-column bottoms, lower hull, steep ends trimmed. */
|
|
42
|
+
export function floorLine(blob: Mask): XY[] {
|
|
43
|
+
const rows = bottomRows(blob);
|
|
44
|
+
const points: XY[] = [];
|
|
45
|
+
rows.forEach((y, x) => {
|
|
46
|
+
if (y >= 0) points.push([x, y + 1]);
|
|
47
|
+
});
|
|
48
|
+
return trimSteepEnds(lowerHull(points));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function lineYAt(line: XY[], x: number): number {
|
|
52
|
+
if (x <= line[0][0]) return line[0][1];
|
|
53
|
+
for (let i = 1; i < line.length; i++) {
|
|
54
|
+
const [x1, y1] = line[i];
|
|
55
|
+
if (x <= x1) {
|
|
56
|
+
const [x0, y0] = line[i - 1];
|
|
57
|
+
return x1 === x0 ? y1 : y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return line[line.length - 1][1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type Blob = {
|
|
64
|
+
/** "barrel" for a single blob, "barrel#2" for the second largest of several. */
|
|
65
|
+
key: string;
|
|
66
|
+
thing: string;
|
|
67
|
+
mask: Mask;
|
|
68
|
+
box: Rect;
|
|
69
|
+
line: XY[];
|
|
70
|
+
baseY: number;
|
|
71
|
+
/** Floor rows covered by the thing's foot, every few columns: [x, back, front]. */
|
|
72
|
+
foot: [number, number, number][];
|
|
73
|
+
/** Spans the whole frame height: always in front of the hero. */
|
|
74
|
+
front: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The footprint is as deep as a third of the thing's width, at least 12 px. */
|
|
78
|
+
export const footDepth = (width: number) => Math.max(12, 0.35 * width);
|
|
79
|
+
|
|
80
|
+
export function blobsOf(thing: string, mask: Mask, step = 4): Blob[] {
|
|
81
|
+
const blobs = components(mask).filter((b) => maskArea(b) >= MIN_OCCLUDER_PX);
|
|
82
|
+
return blobs.map((b, i) => {
|
|
83
|
+
const box = maskBox(b)!;
|
|
84
|
+
const line = floorLine(b);
|
|
85
|
+
const x0 = line[0][0];
|
|
86
|
+
const x1 = line[line.length - 1][0];
|
|
87
|
+
const depth = footDepth(x1 - x0);
|
|
88
|
+
const foot: [number, number, number][] = [];
|
|
89
|
+
for (let x = x0; x <= x1; x += step) {
|
|
90
|
+
const front = Math.round(lineYAt(line, x));
|
|
91
|
+
foot.push([x, Math.max(box[1], front - Math.round(depth)), front]);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
key: blobs.length === 1 ? thing : `${thing}#${i + 1}`,
|
|
95
|
+
thing,
|
|
96
|
+
mask: b,
|
|
97
|
+
box,
|
|
98
|
+
line,
|
|
99
|
+
baseY: Math.max(...line.map(([, y]) => y)),
|
|
100
|
+
foot,
|
|
101
|
+
front: box[1] <= 3 && box[3] >= mask.height - 4,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Fewer points for the same line: keeps the corners, drops points within `tolerance` px of a straight run. */
|
|
107
|
+
export function simplifyLine(line: XY[], tolerance = 1.5): XY[] {
|
|
108
|
+
if (line.length <= 2) return line;
|
|
109
|
+
const out: XY[] = [line[0]];
|
|
110
|
+
let anchor = 0;
|
|
111
|
+
for (let i = 2; i < line.length; i++) {
|
|
112
|
+
const [ax, ay] = line[anchor];
|
|
113
|
+
const [bx, by] = line[i];
|
|
114
|
+
const len = Math.hypot(bx - ax, by - ay) || 1;
|
|
115
|
+
const off = line.slice(anchor + 1, i).some(([px, py]) => Math.abs((bx - ax) * (ay - py) - (ax - px) * (by - ay)) / len > tolerance);
|
|
116
|
+
if (off) {
|
|
117
|
+
anchor = i - 1;
|
|
118
|
+
out.push(line[anchor]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
out.push(line[line.length - 1]);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// occlusion-check: what the depth map will do to people standing on this place's floor, before anyone plays
|
|
2
|
+
// it (src/play/depth-occlusion.ts does it at play time). Read by verify: no depth map means only named
|
|
3
|
+
// things can hide anyone; floor cells on an upright face put people behind it; a spot where the painting
|
|
4
|
+
// covers nearly all of a person means they vanish there.
|
|
5
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
6
|
+
import { personPx, stepAt } from '../play/geometry.ts';
|
|
7
|
+
import { contactRows, depthAt, frontMask, hiddenShare, MAX_HIDDEN, PERSON_WIDTH } from '../play/depth-occlusion.ts';
|
|
8
|
+
import { UPRIGHT_SHARE } from './depthmap.ts';
|
|
9
|
+
import type { Depth } from '../play/shadow.ts';
|
|
10
|
+
import type { Finding } from './verify.ts';
|
|
11
|
+
import { gridOf } from './verify.ts';
|
|
12
|
+
import { nearestRoomy, reach, roomy } from './walk.ts';
|
|
13
|
+
|
|
14
|
+
/** Every how many walk cells (each way) a person is stood to check. */
|
|
15
|
+
const STRIDE = 2;
|
|
16
|
+
|
|
17
|
+
export type OcclusionCheck = {
|
|
18
|
+
/** Walk cells checked, cells on an upright face (feet over a wall or thin air), cells where the depth map would hide most of a person and so hides nothing. */
|
|
19
|
+
checked: number;
|
|
20
|
+
onFace: Array<[number, number]>;
|
|
21
|
+
unsure: Array<[number, number]>;
|
|
22
|
+
/** Cells where something unmasked (no card) hides part of a person: the depth map's own work. */
|
|
23
|
+
hiddenByUnmasked: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const boxOf = (pts: Array<[number, number]>): [number, number, number, number] => [
|
|
27
|
+
Math.min(...pts.map((p) => p[0])), Math.min(...pts.map((p) => p[1])), Math.max(...pts.map((p) => p[0])), Math.max(...pts.map((p) => p[1])),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Feet at (x, y) on an upright face: under most of their width the depth hardly changes down the screen (a floor's
|
|
32
|
+
* changes a row per row): the side of a bridge, the wall down to an underpass, a floor mask or a joining path laid on
|
|
33
|
+
* them. A card's thing in front of the feet is left out, and a post or a can in front of one foot is not most of it.
|
|
34
|
+
*/
|
|
35
|
+
export function onFace(g: Geometry, d: Depth, x: number, y: number, owned?: Uint8Array): boolean {
|
|
36
|
+
const h = personPx(g, y);
|
|
37
|
+
const w = Math.max(4, Math.round(h * PERSON_WIDTH));
|
|
38
|
+
let face = 0;
|
|
39
|
+
let feet = 0;
|
|
40
|
+
const span = Math.max(16, 0.25 * h);
|
|
41
|
+
for (let fx = x - w / 2; fx <= x + w / 2; fx += 2) {
|
|
42
|
+
const k = Math.min(d.w - 1, Math.max(0, Math.floor(fx / 2))) + Math.min(d.h - 1, Math.floor((y - 1) / 2)) * d.w;
|
|
43
|
+
if (owned?.[k]) continue;
|
|
44
|
+
feet++;
|
|
45
|
+
if ((depthAt(d, fx, y + span / 2) - depthAt(d, fx, y - span / 2)) / span < UPRIGHT_SHARE) face++;
|
|
46
|
+
}
|
|
47
|
+
return feet > 0 && face > 0.6 * feet;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Floor cut off by more than this share of the walk cells stays: a big part means the face test is wrong there. */
|
|
51
|
+
const FACE_CUT_MAX = 0.15;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The walk with its cells on an upright face closed (the front of a bridge's planks, a bank's edge down to a ledge),
|
|
55
|
+
* and the floor only they led to (a ledge of rock plates under the bridge's end): feet there stood over thin air.
|
|
56
|
+
* Stairs are upright by nature, risers and all, and keep their cells. Undefined when nothing closes, when no floor is
|
|
57
|
+
* near the start, or when it would cut off a big part of the place.
|
|
58
|
+
*/
|
|
59
|
+
export function closeFaces(g: Geometry, d: Depth, owned?: Uint8Array): { walk: string[]; zone: string[]; start: [number, number]; closed: number; beyond: number } | undefined {
|
|
60
|
+
const grid = gridOf(g);
|
|
61
|
+
const all = grid.count();
|
|
62
|
+
const faces = [...grid.open.keys()].filter((i) => grid.open[i] && !stepAt(g, ...grid.centre(i)) && onFace(g, d, ...grid.centre(i), owned));
|
|
63
|
+
if (!faces.length) return undefined;
|
|
64
|
+
faces.forEach((i) => (grid.open[i] = 0));
|
|
65
|
+
// A start on the face (the front of the planks) steps back onto the floor.
|
|
66
|
+
const cells = roomy(grid);
|
|
67
|
+
const start = nearestRoomy(grid, ...g.start, cells);
|
|
68
|
+
if (start === undefined || Math.hypot(...grid.centre(start).map((v, k) => v - g.start[k]) as [number, number]) > 8 * g.cell) return undefined;
|
|
69
|
+
const keep = reach(grid, start, cells);
|
|
70
|
+
for (const i of [...keep.keys()].filter((k) => keep[k])) for (const n of grid.neighbours(i)) if (grid.open[n] && !cells[n]) keep[n] = 1;
|
|
71
|
+
const beyond = [...grid.open.keys()].filter((i) => grid.open[i] && !keep[i]);
|
|
72
|
+
if (beyond.length > FACE_CUT_MAX * all) return undefined;
|
|
73
|
+
for (const i of [...faces, ...beyond]) [grid.open[i], grid.zone[i]] = [0, -1];
|
|
74
|
+
return { ...grid.rowsOut(), start: cells[grid.cellOf(...g.start)] ? g.start : grid.centre(start), closed: faces.length, beyond: beyond.length };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function checkOcclusion(g: Geometry, d: Depth, owned?: Uint8Array): OcclusionCheck {
|
|
78
|
+
const grid = gridOf(g);
|
|
79
|
+
const out: OcclusionCheck = { checked: 0, onFace: [], unsure: [], hiddenByUnmasked: 0 };
|
|
80
|
+
const contact = contactRows(g, d);
|
|
81
|
+
for (let r = 0; r < grid.rows; r += STRIDE) for (let c = 0; c < grid.cols; c += STRIDE) {
|
|
82
|
+
const i = grid.at(c, r);
|
|
83
|
+
if (!grid.open[i]) continue;
|
|
84
|
+
const [x, y] = grid.centre(i);
|
|
85
|
+
out.checked++;
|
|
86
|
+
const h = personPx(g, y);
|
|
87
|
+
const w = Math.max(4, Math.round(h * PERSON_WIDTH));
|
|
88
|
+
if (onFace(g, d, x, y, owned)) out.onFace.push([x, y]);
|
|
89
|
+
if (hiddenShare(g, d, x, y, owned) > MAX_HIDDEN) {
|
|
90
|
+
out.unsure.push([x, y]);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const rect: [number, number, number, number] = [Math.round(x - w / 2), Math.round(y - h), Math.round(x + w / 2), Math.round(y - Math.max(3, h * 0.04))];
|
|
94
|
+
if (frontMask(g, d, rect, x, y, owned)) out.hiddenByUnmasked++;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** verify's findings from the check (all flags: the place plays, a person should look). */
|
|
100
|
+
export function occlusionFindings(g: Geometry, check: OcclusionCheck | null): Finding[] {
|
|
101
|
+
if (!check) return g.depth ? [] : [{ code: 'NO_DEPTH', detail: 'no depth map: only named things with masks can hide someone; a lamp post or railing nobody named will be drawn behind them' }];
|
|
102
|
+
const out: Finding[] = [];
|
|
103
|
+
// A few cells on a face are the edge of a kerb or a step; many are a floor mask laid on a wall or a bridge's side.
|
|
104
|
+
if (check.onFace.length >= Math.max(10, 0.02 * check.checked)) {
|
|
105
|
+
out.push({ code: 'WALK_ON_FACE', detail: `${check.onFace.length} of ${check.checked} checked floor cells lie on an upright face (a bridge's side, a wall down to a lower floor): feet there are over a wall or thin air. Look at walk.png; floor_cells closes them`, box: boxOf(check.onFace) });
|
|
106
|
+
}
|
|
107
|
+
if (check.unsure.length >= Math.max(10, 0.05 * check.checked)) {
|
|
108
|
+
out.push({ code: 'DEPTH_UNSURE', detail: `${check.unsure.length} of ${check.checked} checked floor cells have over ${Math.round(MAX_HIDDEN * 100)}% of a person in front of them by the depth map (stairs, a slope, or floor where there is none): nothing unmasked hides anyone there`, box: boxOf(check.unsure) });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Reads depth.png and the cards' masks of a place folder (node only), and runs the check. Null without a depth map. */
|
|
114
|
+
export async function checkPlaceOcclusion(dir: string, g: Geometry): Promise<OcclusionCheck | null> {
|
|
115
|
+
const read = await placeDepth(dir, g);
|
|
116
|
+
return read && checkOcclusion(g, read.d, read.owned);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** depth.png of a place folder and, at its size, the pixels the cards look after (node only). Null without a depth map. */
|
|
120
|
+
export async function placeDepth(dir: string, g: Geometry): Promise<{ d: Depth; owned: Uint8Array } | null> {
|
|
121
|
+
if (!g.depth) return null;
|
|
122
|
+
const fs = await import('node:fs');
|
|
123
|
+
const path = await import('node:path');
|
|
124
|
+
const { default: sharp } = await import('sharp');
|
|
125
|
+
const { decodeDepth } = await import('../play/shadow.ts');
|
|
126
|
+
const file = path.join(dir, g.depth);
|
|
127
|
+
if (!fs.existsSync(file)) return null;
|
|
128
|
+
const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
129
|
+
const d = decodeDepth(data, info.width, info.height);
|
|
130
|
+
const owned = new Uint8Array(d.w * d.h);
|
|
131
|
+
for (const mask of new Set(g.occluders.map((o) => o.mask).filter((m): m is string => !!m))) {
|
|
132
|
+
const at = path.join(dir, mask);
|
|
133
|
+
if (!fs.existsSync(at)) continue;
|
|
134
|
+
const m = await sharp(at).resize(d.w, d.h, { fit: 'fill' }).greyscale().raw().toBuffer();
|
|
135
|
+
for (let i = 0; i < m.length; i++) if (m[i] > 127) owned[i] = 1;
|
|
136
|
+
}
|
|
137
|
+
return { d, owned };
|
|
138
|
+
}
|