@driftengine/capture 4.0.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.
Files changed (199) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +29 -0
  3. package/README.md +68 -0
  4. package/dist/browserFrames.d.ts +31 -0
  5. package/dist/browserFrames.js +105 -0
  6. package/dist/collide.d.ts +59 -0
  7. package/dist/collide.js +92 -0
  8. package/dist/decimate.d.ts +42 -0
  9. package/dist/decimate.js +439 -0
  10. package/dist/delight.d.ts +42 -0
  11. package/dist/delight.js +351 -0
  12. package/dist/delightScene.d.ts +37 -0
  13. package/dist/delightScene.js +183 -0
  14. package/dist/depth.d.ts +44 -0
  15. package/dist/depth.js +53 -0
  16. package/dist/features.d.ts +33 -0
  17. package/dist/features.js +260 -0
  18. package/dist/frames.d.ts +47 -0
  19. package/dist/frames.js +87 -0
  20. package/dist/fusion.d.ts +77 -0
  21. package/dist/fusion.js +152 -0
  22. package/dist/gaussians/adam.d.ts +57 -0
  23. package/dist/gaussians/adam.js +88 -0
  24. package/dist/gaussians/depth.d.ts +8 -0
  25. package/dist/gaussians/depth.js +73 -0
  26. package/dist/gaussians/gradients.d.ts +36 -0
  27. package/dist/gaussians/gradients.js +228 -0
  28. package/dist/gaussians/harmonics.d.ts +46 -0
  29. package/dist/gaussians/harmonics.js +99 -0
  30. package/dist/gaussians/optimise.d.ts +50 -0
  31. package/dist/gaussians/optimise.js +125 -0
  32. package/dist/gaussians/parameters.d.ts +35 -0
  33. package/dist/gaussians/parameters.js +165 -0
  34. package/dist/gaussians/project.d.ts +72 -0
  35. package/dist/gaussians/project.js +137 -0
  36. package/dist/gaussians/projectGradient.d.ts +15 -0
  37. package/dist/gaussians/projectGradient.js +213 -0
  38. package/dist/gaussians/rasterise.d.ts +13 -0
  39. package/dist/gaussians/rasterise.js +65 -0
  40. package/dist/gaussians/refine.d.ts +36 -0
  41. package/dist/gaussians/refine.js +136 -0
  42. package/dist/gaussians/similarity.d.ts +25 -0
  43. package/dist/gaussians/similarity.js +80 -0
  44. package/dist/index.d.ts +101 -0
  45. package/dist/index.js +96 -0
  46. package/dist/marching.d.ts +38 -0
  47. package/dist/marching.js +258 -0
  48. package/dist/masks.d.ts +59 -0
  49. package/dist/masks.js +165 -0
  50. package/dist/math/dense.d.ts +68 -0
  51. package/dist/math/dense.js +294 -0
  52. package/dist/math/schur.d.ts +23 -0
  53. package/dist/math/schur.js +140 -0
  54. package/dist/models/batchNorm.d.ts +17 -0
  55. package/dist/models/batchNorm.js +24 -0
  56. package/dist/models/clipTokenizer.d.ts +40 -0
  57. package/dist/models/clipTokenizer.js +122 -0
  58. package/dist/models/clipTower.d.ts +27 -0
  59. package/dist/models/clipTower.js +45 -0
  60. package/dist/models/depthAnything2.d.ts +38 -0
  61. package/dist/models/depthAnything2.js +163 -0
  62. package/dist/models/depthAnything3.d.ts +26 -0
  63. package/dist/models/depthAnything3.js +171 -0
  64. package/dist/models/dualDpt.d.ts +19 -0
  65. package/dist/models/dualDpt.js +138 -0
  66. package/dist/models/hiera.d.ts +49 -0
  67. package/dist/models/hiera.js +120 -0
  68. package/dist/models/hieraAttention.d.ts +16 -0
  69. package/dist/models/hieraAttention.js +68 -0
  70. package/dist/models/miniature.d.ts +36 -0
  71. package/dist/models/miniature.js +216 -0
  72. package/dist/models/mobileSam.d.ts +41 -0
  73. package/dist/models/mobileSam.js +88 -0
  74. package/dist/models/owlv2.d.ts +49 -0
  75. package/dist/models/owlv2.js +131 -0
  76. package/dist/models/owlv2Detect.d.ts +25 -0
  77. package/dist/models/owlv2Detect.js +80 -0
  78. package/dist/models/owlv2Miniature.d.ts +27 -0
  79. package/dist/models/owlv2Miniature.js +87 -0
  80. package/dist/models/rope.d.ts +23 -0
  81. package/dist/models/rope.js +84 -0
  82. package/dist/models/sam21.d.ts +45 -0
  83. package/dist/models/sam21.js +126 -0
  84. package/dist/models/sam21Miniature.d.ts +37 -0
  85. package/dist/models/sam21Miniature.js +194 -0
  86. package/dist/models/sam21Video.d.ts +30 -0
  87. package/dist/models/sam21Video.js +101 -0
  88. package/dist/models/sam2Choice.d.ts +19 -0
  89. package/dist/models/sam2Choice.js +55 -0
  90. package/dist/models/sam2Decoder.d.ts +36 -0
  91. package/dist/models/sam2Decoder.js +108 -0
  92. package/dist/models/sam2Memories.d.ts +54 -0
  93. package/dist/models/sam2Memories.js +93 -0
  94. package/dist/models/sam2Memory.d.ts +30 -0
  95. package/dist/models/sam2Memory.js +136 -0
  96. package/dist/models/sam2Positions.d.ts +24 -0
  97. package/dist/models/sam2Positions.js +94 -0
  98. package/dist/models/sam2Tracker.d.ts +69 -0
  99. package/dist/models/sam2Tracker.js +172 -0
  100. package/dist/models/samDecoder.d.ts +45 -0
  101. package/dist/models/samDecoder.js +96 -0
  102. package/dist/models/samMiniature.d.ts +43 -0
  103. package/dist/models/samMiniature.js +161 -0
  104. package/dist/models/samPrompt.d.ts +57 -0
  105. package/dist/models/samPrompt.js +160 -0
  106. package/dist/models/seeded.d.ts +16 -0
  107. package/dist/models/seeded.js +48 -0
  108. package/dist/models/tinyVit.d.ts +38 -0
  109. package/dist/models/tinyVit.js +82 -0
  110. package/dist/models/twoWay.d.ts +46 -0
  111. package/dist/models/twoWay.js +67 -0
  112. package/dist/models/vit.d.ts +45 -0
  113. package/dist/models/vit.js +157 -0
  114. package/dist/models/windowAttention.d.ts +25 -0
  115. package/dist/models/windowAttention.js +104 -0
  116. package/dist/poses.d.ts +31 -0
  117. package/dist/poses.js +750 -0
  118. package/dist/prepare.d.ts +30 -0
  119. package/dist/prepare.js +238 -0
  120. package/dist/propose.d.ts +36 -0
  121. package/dist/propose.js +63 -0
  122. package/dist/resample.d.ts +39 -0
  123. package/dist/resample.js +165 -0
  124. package/dist/run.d.ts +3 -0
  125. package/dist/run.js +1 -0
  126. package/dist/scene.d.ts +104 -0
  127. package/dist/scene.js +109 -0
  128. package/dist/segment.d.ts +40 -0
  129. package/dist/segment.js +184 -0
  130. package/dist/testScene.d.ts +55 -0
  131. package/dist/testScene.js +214 -0
  132. package/dist/twoView.d.ts +22 -0
  133. package/dist/twoView.js +435 -0
  134. package/package.json +63 -0
  135. package/src/browserFrames.ts +145 -0
  136. package/src/collide.ts +128 -0
  137. package/src/decimate.ts +510 -0
  138. package/src/delight.ts +427 -0
  139. package/src/delightScene.ts +249 -0
  140. package/src/depth.ts +100 -0
  141. package/src/features.ts +302 -0
  142. package/src/frames.ts +111 -0
  143. package/src/fusion.ts +229 -0
  144. package/src/gaussians/adam.ts +131 -0
  145. package/src/gaussians/depth.ts +87 -0
  146. package/src/gaussians/gradients.ts +285 -0
  147. package/src/gaussians/harmonics.ts +122 -0
  148. package/src/gaussians/optimise.ts +191 -0
  149. package/src/gaussians/parameters.ts +186 -0
  150. package/src/gaussians/project.ts +207 -0
  151. package/src/gaussians/projectGradient.ts +234 -0
  152. package/src/gaussians/rasterise.ts +81 -0
  153. package/src/gaussians/refine.ts +176 -0
  154. package/src/gaussians/similarity.ts +87 -0
  155. package/src/index.ts +225 -0
  156. package/src/marching.ts +358 -0
  157. package/src/masks.ts +201 -0
  158. package/src/math/dense.ts +317 -0
  159. package/src/math/schur.ts +167 -0
  160. package/src/models/batchNorm.ts +57 -0
  161. package/src/models/clipTokenizer.ts +129 -0
  162. package/src/models/clipTower.ts +87 -0
  163. package/src/models/depthAnything2.ts +276 -0
  164. package/src/models/depthAnything3.ts +229 -0
  165. package/src/models/dualDpt.ts +233 -0
  166. package/src/models/hiera.ts +231 -0
  167. package/src/models/hieraAttention.ts +121 -0
  168. package/src/models/miniature.ts +241 -0
  169. package/src/models/mobileSam.ts +135 -0
  170. package/src/models/owlv2.ts +190 -0
  171. package/src/models/owlv2Detect.ts +116 -0
  172. package/src/models/owlv2Miniature.ts +108 -0
  173. package/src/models/rope.ts +111 -0
  174. package/src/models/sam21.ts +182 -0
  175. package/src/models/sam21Miniature.ts +225 -0
  176. package/src/models/sam21Video.ts +154 -0
  177. package/src/models/sam2Choice.ts +62 -0
  178. package/src/models/sam2Decoder.ts +218 -0
  179. package/src/models/sam2Memories.ts +165 -0
  180. package/src/models/sam2Memory.ts +247 -0
  181. package/src/models/sam2Positions.ts +106 -0
  182. package/src/models/sam2Tracker.ts +300 -0
  183. package/src/models/samDecoder.ts +225 -0
  184. package/src/models/samMiniature.ts +190 -0
  185. package/src/models/samPrompt.ts +227 -0
  186. package/src/models/seeded.ts +50 -0
  187. package/src/models/tinyVit.ts +178 -0
  188. package/src/models/twoWay.ts +122 -0
  189. package/src/models/vit.ts +265 -0
  190. package/src/models/windowAttention.ts +155 -0
  191. package/src/poses.ts +894 -0
  192. package/src/prepare.ts +306 -0
  193. package/src/propose.ts +96 -0
  194. package/src/resample.ts +197 -0
  195. package/src/run.ts +7 -0
  196. package/src/scene.ts +141 -0
  197. package/src/segment.ts +220 -0
  198. package/src/testScene.ts +283 -0
  199. package/src/twoView.ts +536 -0
package/src/depth.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Depth, its confidence and a camera per view, from a clip's frames rather than from tensors.
3
+ *
4
+ * **The frames arrive as a host hands them over** — RGBA bytes at whatever size the clip is — and
5
+ * this prepares them as Depth Anything 3's own processor does, runs the views together so the
6
+ * model's across-view blocks see each other, and decodes what comes back. A graph bakes its shapes,
7
+ * so one is built per count of views and prepared size and kept: a clip's frames are all the same
8
+ * size, so the second estimate over the same clip builds nothing.
9
+ *
10
+ * **It answers with its own arrays rather than filling the caller's**, which the engine's hot paths
11
+ * never do: the prepared size is not known until the frames are prepared, so a caller cannot have
12
+ * sized anything, and this runs once a clip rather than once a frame.
13
+ */
14
+ import { graphFromWeights, type NetworkGraph, type WeightSource } from '@driftengine/texture';
15
+
16
+ import {
17
+ decodeCamera,
18
+ decodeDepth,
19
+ depthAnything3,
20
+ type DepthAnything3Config,
21
+ } from './models/depthAnything3.ts';
22
+ import { prepareDepthFrame } from './prepare.ts';
23
+ import type { GraphRun } from './run.ts';
24
+
25
+ /** A frame as a host hands it over: RGBA bytes, and the size they are. */
26
+ export interface RawFrame {
27
+ readonly pixels: Uint8Array;
28
+ readonly width: number;
29
+ readonly height: number;
30
+ }
31
+
32
+ export interface DepthView {
33
+ /** `[height, width]` metres, and one confidence each. */
34
+ readonly depth: Float32Array;
35
+ readonly confidence: Float32Array;
36
+ /** 3 × 4 row-major, world to camera. */
37
+ readonly worldToCamera: Float32Array;
38
+ /** 3 × 3 row-major. */
39
+ readonly intrinsics: Float32Array;
40
+ }
41
+
42
+ export interface DepthEstimate {
43
+ /** The size the frames were prepared at, which the maps and the cameras are in. */
44
+ readonly width: number;
45
+ readonly height: number;
46
+ readonly views: readonly DepthView[];
47
+ }
48
+
49
+ export interface DepthEstimator {
50
+ estimate(frames: readonly RawFrame[]): Promise<DepthEstimate>;
51
+ }
52
+
53
+ /** An estimator over one model's weights, running its graphs through `run`. */
54
+ export function createDepthEstimator(
55
+ config: DepthAnything3Config,
56
+ weights: WeightSource,
57
+ run: GraphRun,
58
+ options: { readonly size?: number } = {},
59
+ ): DepthEstimator {
60
+ const size = options.size ?? 504;
61
+ const graphs = new Map<string, NetworkGraph>();
62
+ return {
63
+ async estimate(frames) {
64
+ if (frames.length === 0) throw new RangeError('a depth estimate needs a frame');
65
+ const prepared = frames.map((frame) =>
66
+ prepareDepthFrame(frame.pixels, frame.width, frame.height, size),
67
+ );
68
+ const { width, height } = prepared[0] as { width: number; height: number };
69
+ for (const [at, one] of prepared.entries()) {
70
+ if (one.width !== width || one.height !== height) {
71
+ throw new RangeError(
72
+ `frame ${at} prepares to ${one.width}×${one.height} and the first to ${width}×${height}` +
73
+ ': one estimate is one clip, at one size',
74
+ );
75
+ }
76
+ }
77
+ const key = `${frames.length}.${height}x${width}`;
78
+ let graph = graphs.get(key);
79
+ if (graph === undefined) {
80
+ graph = graphFromWeights(weights, depthAnything3(config, frames.length, height, width));
81
+ graphs.set(key, graph);
82
+ }
83
+ const out = await run(
84
+ graph,
85
+ new Map(prepared.map((one, view) => [`image${view}`, one.pixels])),
86
+ );
87
+ const pose = out.get('pose') as Float32Array;
88
+ const views = prepared.map((_, view) => {
89
+ const depth = new Float32Array(width * height);
90
+ const confidence = new Float32Array(width * height);
91
+ decodeDepth(out.get(`logits${view}`) as Float32Array, depth, confidence);
92
+ const worldToCamera = new Float32Array(12);
93
+ const intrinsics = new Float32Array(9);
94
+ decodeCamera(pose, view, height, width, worldToCamera, intrinsics);
95
+ return { depth, confidence, worldToCamera, intrinsics };
96
+ });
97
+ return { width, height, views };
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Corners in a frame, a binary description of each, and the matches between two frames.
3
+ *
4
+ * **A corner is a place where the image moves in two directions at once**, which is the one thing
5
+ * that can be found again from another angle: an edge slides along itself and a flat patch could be
6
+ * anywhere. So the response is Shi–Tomasi's — the smaller eigenvalue of the gradient's structure
7
+ * tensor, which is how much the weaker direction moves — kept where it is the largest in its own
8
+ * neighbourhood, and the strongest `budget` of those are the frame's features.
9
+ *
10
+ * **Each carries an orientation, and it is a vector rather than an angle.** The intensity centroid
11
+ * of a patch points away from its darker side; normalised, that is the cosine and sine the
12
+ * descriptor's pattern is turned by, so no inverse tangent is taken — which matters because this
13
+ * package is inside the determinism gate and `atan2` is one of the twenty-two functions no two
14
+ * engines must agree on.
15
+ *
16
+ * **The description is 256 comparisons of a blurred patch**, at pairs of places drawn once from a
17
+ * seed and turned by the feature's own orientation: a bit per pair, so two features compare in a
18
+ * few instructions and a frame's worth fits in memory. Blurred, because a comparison of two raw
19
+ * pixels is a comparison of two samples of the noise.
20
+ *
21
+ * **A match is mutual and unambiguous.** Each feature's nearest description in the other frame must
22
+ * also have it as its nearest, and each must be clearly nearer than its own second nearest —
23
+ * Lowe's ratio, applied from both sides — because a repeated texture answers every query equally
24
+ * well and a wrong match at this stage is a wrong camera later. **From both sides rather than one**,
25
+ * so matching the frames the other way round gives the same pairs; tested one-sided, it gave 46
26
+ * pairs one way and 48 the other, which would make a capture's answer depend on which frame it
27
+ * called first.
28
+ */
29
+ import { mulberry32 } from '@driftengine/core';
30
+
31
+ /** The features of one frame, in the arrays a caller owns. */
32
+ export interface FeatureSet {
33
+ readonly x: Float32Array;
34
+ readonly y: Float32Array;
35
+ /** Shi–Tomasi's smaller eigenvalue: how strong a corner it is. */
36
+ readonly response: Float32Array;
37
+ /** The orientation, as a unit vector rather than an angle. */
38
+ readonly cos: Float32Array;
39
+ readonly sin: Float32Array;
40
+ count: number;
41
+ }
42
+
43
+ /** The descriptions of a frame's features: `BYTES` bytes each, bit by comparison. */
44
+ export interface Descriptors {
45
+ readonly bits: Uint8Array;
46
+ readonly count: number;
47
+ }
48
+
49
+ /** A description is 256 bits, which is 32 bytes. */
50
+ export const DESCRIPTOR_BYTES = 32;
51
+ const PAIRS = DESCRIPTOR_BYTES * 8;
52
+ /** The patch a description is drawn from, and the one an orientation is measured over. */
53
+ const PATCH = 15;
54
+ const SUPPRESS = 3;
55
+ /** A corner must reach this share of the frame's strongest to be one at all. */
56
+ const FLOOR = 0.01;
57
+ /**
58
+ * And this much movement outright, in luma squared. **A share alone is not enough**: a wall carries
59
+ * a level or two of sensor noise, and a share of the strongest noise is still noise — measured, an
60
+ * uncorrelated level of ±1 answers with 265 "corners" of up to 6, and ±2 with 268 of up to 17,
61
+ * while the test scene's weakest real corner is 180 and its strongest 12,100. **25 sits between
62
+ * them**, three orders below what a texture scores and above what noise reaches.
63
+ */
64
+ const LEAST = 25;
65
+
66
+ export function createFeatureSet(budget: number): FeatureSet {
67
+ return {
68
+ x: new Float32Array(budget),
69
+ y: new Float32Array(budget),
70
+ response: new Float32Array(budget),
71
+ cos: new Float32Array(budget),
72
+ sin: new Float32Array(budget),
73
+ count: 0,
74
+ };
75
+ }
76
+
77
+ /* The pattern, drawn once: pairs of offsets inside the patch, the same on every machine. */
78
+ const PATTERN = ((): Int8Array => {
79
+ const random = mulberry32(0x0c0ffee1);
80
+ const out = new Int8Array(PAIRS * 4);
81
+ const reach = (PATCH - 1) / 2;
82
+ for (let i = 0; i < PAIRS * 4; i += 1) {
83
+ /* A rounded draw over the patch, which keeps the pairs inside it once they are turned. */
84
+ out[i] = Math.round((random() * 2 - 1) * reach * 0.7);
85
+ }
86
+ return out;
87
+ })();
88
+
89
+ /** The frame as luma, one byte a pixel, which everything below reads. */
90
+ function luma(image: Uint8Array, width: number, height: number): Float32Array {
91
+ const out = new Float32Array(width * height);
92
+ for (let at = 0; at < width * height; at += 1) {
93
+ out[at] =
94
+ 0.299 * (image[at * 4] as number) +
95
+ 0.587 * (image[at * 4 + 1] as number) +
96
+ 0.114 * (image[at * 4 + 2] as number);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** A 3 × 3 box blur, which is what a description compares rather than raw samples. */
102
+ function blurred(grey: Float32Array, width: number, height: number): Float32Array {
103
+ const out = new Float32Array(grey.length);
104
+ for (let y = 0; y < height; y += 1) {
105
+ for (let x = 0; x < width; x += 1) {
106
+ let sum = 0;
107
+ for (let dy = -1; dy <= 1; dy += 1) {
108
+ const row = Math.min(height - 1, Math.max(0, y + dy)) * width;
109
+ for (let dx = -1; dx <= 1; dx += 1) {
110
+ sum += grey[row + Math.min(width - 1, Math.max(0, x + dx))] as number;
111
+ }
112
+ }
113
+ out[y * width + x] = sum / 9;
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * The strongest corners of `image`, RGBA at `width × height`, into `out` — at most `budget` of
121
+ * them, strongest first.
122
+ */
123
+ export function detectFeatures(
124
+ image: Uint8Array,
125
+ width: number,
126
+ height: number,
127
+ out: FeatureSet,
128
+ budget: number,
129
+ ): void {
130
+ const grey = luma(image, width, height);
131
+ const response = new Float32Array(width * height);
132
+ const border = (PATCH - 1) / 2 + 1;
133
+ let strongest = 0;
134
+ for (let y = border; y < height - border; y += 1) {
135
+ for (let x = border; x < width - border; x += 1) {
136
+ /* The structure tensor over a 3 × 3 window of central differences. */
137
+ let xx = 0;
138
+ let yy = 0;
139
+ let xy = 0;
140
+ for (let dy = -1; dy <= 1; dy += 1) {
141
+ for (let dx = -1; dx <= 1; dx += 1) {
142
+ const at = (y + dy) * width + x + dx;
143
+ const gx = ((grey[at + 1] as number) - (grey[at - 1] as number)) / 2;
144
+ const gy = ((grey[at + width] as number) - (grey[at - width] as number)) / 2;
145
+ xx += gx * gx;
146
+ yy += gy * gy;
147
+ xy += gx * gy;
148
+ }
149
+ }
150
+ const half = (xx + yy) / 2;
151
+ const side = (xx - yy) / 2;
152
+ /* Shi–Tomasi: the smaller eigenvalue, which is the weaker direction's movement. */
153
+ const smaller = half - Math.sqrt(side * side + xy * xy);
154
+ response[y * width + x] = smaller;
155
+ strongest = Math.max(strongest, smaller);
156
+ }
157
+ }
158
+
159
+ /* Keep a response that is the largest within its neighbourhood and above the floor. */
160
+ const floor = Math.max(strongest * FLOOR, LEAST);
161
+ const kept: number[] = [];
162
+ for (let y = border; y < height - border; y += 1) {
163
+ for (let x = border; x < width - border; x += 1) {
164
+ const here = response[y * width + x] as number;
165
+ if (here < floor) continue;
166
+ let best = true;
167
+ for (let dy = -SUPPRESS; dy <= SUPPRESS && best; dy += 1) {
168
+ for (let dx = -SUPPRESS; dx <= SUPPRESS; dx += 1) {
169
+ if (dx === 0 && dy === 0) continue;
170
+ const other = response[(y + dy) * width + x + dx] as number;
171
+ /* A tie goes to the earlier pixel, so the answer does not depend on the scan. */
172
+ if (other > here || (other === here && (dy < 0 || (dy === 0 && dx < 0)))) {
173
+ best = false;
174
+ break;
175
+ }
176
+ }
177
+ }
178
+ if (best) kept.push(y * width + x);
179
+ }
180
+ }
181
+ kept.sort((a, b) => (response[b] as number) - (response[a] as number) || a - b);
182
+
183
+ const reach = (PATCH - 1) / 2;
184
+ out.count = Math.min(budget, out.x.length, kept.length);
185
+ for (let i = 0; i < out.count; i += 1) {
186
+ const at = kept[i] as number;
187
+ const x = at % width;
188
+ const y = (at - x) / width;
189
+ out.x[i] = x;
190
+ out.y[i] = y;
191
+ out.response[i] = response[at] as number;
192
+ /* The intensity centroid of the patch, as a direction. */
193
+ let mx = 0;
194
+ let my = 0;
195
+ for (let dy = -reach; dy <= reach; dy += 1) {
196
+ for (let dx = -reach; dx <= reach; dx += 1) {
197
+ if (dx * dx + dy * dy > reach * reach) continue;
198
+ const value = grey[(y + dy) * width + x + dx] as number;
199
+ mx += dx * value;
200
+ my += dy * value;
201
+ }
202
+ }
203
+ const length = Math.sqrt(mx * mx + my * my);
204
+ out.cos[i] = length === 0 ? 1 : mx / length;
205
+ out.sin[i] = length === 0 ? 0 : my / length;
206
+ }
207
+ }
208
+
209
+ /** A description of every feature in `features`, 32 bytes each. */
210
+ export function describeFeatures(
211
+ image: Uint8Array,
212
+ width: number,
213
+ height: number,
214
+ features: FeatureSet,
215
+ ): Descriptors {
216
+ const smooth = blurred(luma(image, width, height), width, height);
217
+ const bits = new Uint8Array(features.count * DESCRIPTOR_BYTES);
218
+ for (let i = 0; i < features.count; i += 1) {
219
+ const x = features.x[i] as number;
220
+ const y = features.y[i] as number;
221
+ const cos = features.cos[i] as number;
222
+ const sin = features.sin[i] as number;
223
+ for (let pair = 0; pair < PAIRS; pair += 1) {
224
+ const sample = (at: number): number => {
225
+ const dx = PATTERN[pair * 4 + at * 2] as number;
226
+ const dy = PATTERN[pair * 4 + at * 2 + 1] as number;
227
+ /* The pattern turns with the feature, which is what makes it survive a rotation. */
228
+ const rx = Math.round(x + cos * dx - sin * dy);
229
+ const ry = Math.round(y + sin * dx + cos * dy);
230
+ const cx = Math.min(width - 1, Math.max(0, rx));
231
+ const cy = Math.min(height - 1, Math.max(0, ry));
232
+ return smooth[cy * width + cx] as number;
233
+ };
234
+ if (sample(0) < sample(1)) {
235
+ bits[i * DESCRIPTOR_BYTES + (pair >> 3)] |= 1 << (pair & 7);
236
+ }
237
+ }
238
+ }
239
+ return { bits, count: features.count };
240
+ }
241
+
242
+ const ONES = ((): Uint8Array => {
243
+ const out = new Uint8Array(256);
244
+ for (let i = 0; i < 256; i += 1) out[i] = (i & 1) + (out[i >> 1] as number);
245
+ return out;
246
+ })();
247
+
248
+ function distance(a: Uint8Array, at: number, b: Uint8Array, to: number): number {
249
+ let sum = 0;
250
+ for (let byte = 0; byte < DESCRIPTOR_BYTES; byte += 1) {
251
+ sum += ONES[(a[at + byte] as number) ^ (b[to + byte] as number)] as number;
252
+ }
253
+ return sum;
254
+ }
255
+
256
+ /**
257
+ * The matches between two frames' descriptions into `out`, two indices each, and how many there
258
+ * are. A match is mutual and clears Lowe's `ratio`, which defaults to 0.8.
259
+ */
260
+ export function matchFeatures(
261
+ a: Descriptors,
262
+ b: Descriptors,
263
+ out: Int32Array,
264
+ options: { readonly ratio?: number } = {},
265
+ ): number {
266
+ const ratio = options.ratio ?? 0.8;
267
+ const bestOf = (
268
+ from: Descriptors,
269
+ to: Descriptors,
270
+ index: number,
271
+ ): { at: number; best: number; second: number } => {
272
+ let at = -1;
273
+ let best = Infinity;
274
+ let second = Infinity;
275
+ for (let j = 0; j < to.count; j += 1) {
276
+ const off = distance(from.bits, index * DESCRIPTOR_BYTES, to.bits, j * DESCRIPTOR_BYTES);
277
+ if (off < best) {
278
+ second = best;
279
+ best = off;
280
+ at = j;
281
+ } else if (off < second) {
282
+ second = off;
283
+ }
284
+ }
285
+ return { at, best, second };
286
+ };
287
+
288
+ let count = 0;
289
+ for (let i = 0; i < a.count && count * 2 + 1 < out.length; i += 1) {
290
+ const forward = bestOf(a, b, i);
291
+ if (forward.at < 0) continue;
292
+ /* Unambiguous: the nearest must be clearly nearer than whatever came second. */
293
+ if (!(forward.best < ratio * forward.second)) continue;
294
+ /* And mutual, by the same rule from the other side: matching either way gives one answer. */
295
+ const back = bestOf(b, a, forward.at);
296
+ if (back.at !== i || !(back.best < ratio * back.second)) continue;
297
+ out[count * 2] = i;
298
+ out[count * 2 + 1] = forward.at;
299
+ count += 1;
300
+ }
301
+ return count;
302
+ }
package/src/frames.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A clip's frames, and which of them a capture keeps.
3
+ *
4
+ * **The engine never opens a file.** A host hands it a `FrameSource` — a video element in a
5
+ * browser, a decoder on a native host, a folder of stills in a test — and the engine reads it one
6
+ * frame at a time, in order, into a buffer of its own. A minute of 1080p is eight gigabytes of
7
+ * pixels, so a clip is walked rather than held, and what a capture keeps is chosen as it goes past.
8
+ *
9
+ * **A frame may arrive later than it is asked for**, since every browser decoder is asynchronous, so
10
+ * `frameAt` may answer a promise and the two readers here are `async`. A host with its frames
11
+ * already in hand answers without one and pays nothing: an awaited value that is not a promise
12
+ * costs a microtask, and this is a clip's pace rather than a frame's.
13
+ *
14
+ * **Frames are chosen by motion, not by number.** What reconstruction needs is baseline: two views
15
+ * far enough apart to triangulate, and no more of them than the budget allows. So the frames kept
16
+ * are spaced by equal motion — a slow pan gives up most of its frames, a fast one keeps them — and
17
+ * a clip that never moves is one frame, which is what it holds. `every` is the other way to choose,
18
+ * for a caller that knows its clip: every Nth frame, under the same budget.
19
+ *
20
+ * **Motion is the mean absolute difference between one frame and the last**, over the colours and
21
+ * not the alpha, which is a measure of how much the picture changed rather than of how far anything
22
+ * travelled. **What it gives up** is telling a pan from a light being switched on; what would make
23
+ * it wrong is a clip whose brightness swings without the camera moving, and the answer then is the
24
+ * feature matching of Task 9 rather than a cleverer average.
25
+ */
26
+
27
+ export interface FrameSize {
28
+ readonly width: number;
29
+ readonly height: number;
30
+ }
31
+
32
+ export interface FrameSource {
33
+ frameCount(): number;
34
+ /**
35
+ * Writes frame `index` as RGBA into `out` and answers the frame's size. A buffer too small for
36
+ * the frame is left untouched, so a caller may ask with an empty one to learn the size first.
37
+ */
38
+ frameAt(index: number, out: Uint8Array): FrameSize | Promise<FrameSize>;
39
+ }
40
+
41
+ export interface FrameChoice {
42
+ /** At most this many frames. */
43
+ readonly budget: number;
44
+ /** Every Nth frame instead of by motion. */
45
+ readonly every?: number;
46
+ }
47
+
48
+ const NOTHING = new Uint8Array(0);
49
+
50
+ /** Every frame in order, each read over the last into one buffer the visitor must not keep. */
51
+ export async function eachFrame(
52
+ source: FrameSource,
53
+ visit: (index: number, pixels: Uint8Array, width: number, height: number) => void,
54
+ ): Promise<void> {
55
+ const count = source.frameCount();
56
+ if (count <= 0) return;
57
+ const { width, height } = await source.frameAt(0, NOTHING);
58
+ const pixels = new Uint8Array(width * height * 4);
59
+ for (let index = 0; index < count; index += 1) {
60
+ const size = await source.frameAt(index, pixels);
61
+ visit(index, pixels, size.width, size.height);
62
+ }
63
+ }
64
+
65
+ /** The frames a capture keeps, in order, the first among them. */
66
+ export async function selectFrames(source: FrameSource, choice: FrameChoice): Promise<number[]> {
67
+ const count = source.frameCount();
68
+ const budget = Math.max(1, Math.floor(choice.budget));
69
+ if (count <= 0) return [];
70
+ if (choice.every !== undefined) {
71
+ const kept: number[] = [];
72
+ for (let index = 0; index < count && kept.length < budget; index += choice.every) {
73
+ kept.push(index);
74
+ }
75
+ return kept;
76
+ }
77
+ if (count <= budget) return Array.from({ length: count }, (_, index) => index);
78
+
79
+ /* One pass for the motion between each frame and the last, keeping a copy of that last frame. */
80
+ const travelled = new Float64Array(count);
81
+ let last: Uint8Array | null = null;
82
+ await eachFrame(source, (index, pixels) => {
83
+ if (last !== null) {
84
+ let sum = 0;
85
+ for (let at = 0; at < pixels.length; at += 4) {
86
+ sum +=
87
+ Math.abs((pixels[at] as number) - (last[at] as number)) +
88
+ Math.abs((pixels[at + 1] as number) - (last[at + 1] as number)) +
89
+ Math.abs((pixels[at + 2] as number) - (last[at + 2] as number));
90
+ }
91
+ travelled[index] = (travelled[index - 1] as number) + sum / ((pixels.length / 4) * 3);
92
+ } else {
93
+ last = new Uint8Array(pixels.length);
94
+ }
95
+ last.set(pixels);
96
+ });
97
+
98
+ const total = travelled[count - 1] as number;
99
+ if (total === 0) return [0];
100
+ /* Spaced so the whole clip's motion divides into the budget's frames, the last one included. */
101
+ const step = total / (budget - 1);
102
+ const kept = [0];
103
+ let since = 0;
104
+ for (let index = 1; index < count && kept.length < budget; index += 1) {
105
+ if ((travelled[index] as number) - since >= step * (1 - 1e-9)) {
106
+ kept.push(index);
107
+ since = travelled[index] as number;
108
+ }
109
+ }
110
+ return kept;
111
+ }