@meta-sam/graphics 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -45
- package/dist/renderer.js +22 -25
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,45 +15,28 @@ npm install @meta-sam/graphics @meta-sam/parser @meta-sam/video
|
|
|
15
15
|
|
|
16
16
|
The renderer accepts image or video results and snapshots from `@meta-sam/parser`.
|
|
17
17
|
Call and await `update()` before `render()`; rendering is synchronous and sees only
|
|
18
|
-
the last successfully committed update.
|
|
18
|
+
the last successfully committed update. The example parses one frame of SAM API
|
|
19
|
+
output for a 320×334 image and draws it onto a canvas:
|
|
19
20
|
|
|
20
21
|
<!-- readme-example -->
|
|
21
22
|
|
|
22
23
|
```ts
|
|
23
24
|
import { SegmentationRenderer } from '@meta-sam/graphics';
|
|
24
|
-
import type
|
|
25
|
+
import { parseImageStream, type ResponsesEvent } from '@meta-sam/parser';
|
|
26
|
+
|
|
27
|
+
// One SAM API line: object 0 with its box and mask, in a 320×334 source image.
|
|
28
|
+
const outputText =
|
|
29
|
+
'<0f>0<|box;x1=211;y1=228;x2=270;y2=254;w=320;h=334|>' +
|
|
30
|
+
"<|mask;x=0;y=0;data=27,60,~!!!!M!0c[0o91w?q1!pIH4pPRVp2B3'7`e.ioeAf6-k/#Xd8%dX9x(|>\n";
|
|
31
|
+
|
|
32
|
+
async function* responseEvents(): AsyncIterable<ResponsesEvent> {
|
|
33
|
+
const lane = { item_id: 'message-1', output_index: 0, content_index: 0 };
|
|
34
|
+
yield { type: 'response.output_text.delta', ...lane, delta: outputText };
|
|
35
|
+
yield { type: 'response.output_text.done', ...lane, text: outputText };
|
|
36
|
+
yield { type: 'response.completed' };
|
|
37
|
+
}
|
|
25
38
|
|
|
26
|
-
const result
|
|
27
|
-
media: 'image',
|
|
28
|
-
revision: 1,
|
|
29
|
-
records: [
|
|
30
|
-
{
|
|
31
|
-
kind: 'mask',
|
|
32
|
-
order: 0,
|
|
33
|
-
objectId: 'bus',
|
|
34
|
-
identity: 'image:*:bus',
|
|
35
|
-
revision: 1,
|
|
36
|
-
mask: {
|
|
37
|
-
encoding: 'one_bit',
|
|
38
|
-
width: 5,
|
|
39
|
-
height: 5,
|
|
40
|
-
payload: '!!!!!(QO(0lu8?',
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
kind: 'box',
|
|
45
|
-
order: 1,
|
|
46
|
-
objectId: 'bus',
|
|
47
|
-
left: 1,
|
|
48
|
-
top: 1,
|
|
49
|
-
right: 4,
|
|
50
|
-
bottom: 4,
|
|
51
|
-
},
|
|
52
|
-
],
|
|
53
|
-
diagnostics: [],
|
|
54
|
-
rawOutput: '',
|
|
55
|
-
outcome: { status: 'completed' },
|
|
56
|
-
};
|
|
39
|
+
const result = await parseImageStream(responseEvents()).finalResult;
|
|
57
40
|
|
|
58
41
|
const canvas = document.querySelector<HTMLCanvasElement>('#overlay');
|
|
59
42
|
if (canvas === null) throw new Error('Missing overlay canvas.');
|
|
@@ -65,7 +48,9 @@ const renderer = new SegmentationRenderer();
|
|
|
65
48
|
await renderer.update(result);
|
|
66
49
|
renderer.render(context, {
|
|
67
50
|
media: 'image',
|
|
68
|
-
source
|
|
51
|
+
// The source rectangle is the frame the API reported as `w` × `h`; each mask
|
|
52
|
+
// is placed at its own box (`bounds`) inside that frame.
|
|
53
|
+
source: { x: 0, y: 0, width: 320, height: 334 },
|
|
69
54
|
target: { x: 0, y: 0, width: canvas.width, height: canvas.height },
|
|
70
55
|
});
|
|
71
56
|
|
|
@@ -75,9 +60,9 @@ renderer.clear();
|
|
|
75
60
|
renderer.dispose();
|
|
76
61
|
```
|
|
77
62
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
63
|
+
A real application passes parser snapshots to `update()` as they arrive and then
|
|
64
|
+
passes the final result when parsing completes. The renderer does not need the
|
|
65
|
+
request, the media bytes, or the transport.
|
|
81
66
|
|
|
82
67
|
## Video and frame selection
|
|
83
68
|
|
|
@@ -168,8 +153,7 @@ complete current view to every `update()` call.
|
|
|
168
153
|
|
|
169
154
|
- `update()` calls are serialized in invocation order. Always await the update that
|
|
170
155
|
must be visible before calling `render()`.
|
|
171
|
-
- Masks and boxes are retained. Text
|
|
172
|
-
drawn.
|
|
156
|
+
- Masks and boxes are retained. Text records are accepted but are not drawn.
|
|
173
157
|
- Mask records are collapsed by `identity`; the greatest per-mask `revision` wins.
|
|
174
158
|
At the same revision, conflicting mask data is rejected.
|
|
175
159
|
- Boxes are collapsed by frame and `objectId`; the last box in the accepted view
|
|
@@ -187,7 +171,10 @@ collapsed.
|
|
|
187
171
|
|
|
188
172
|
## Complete mask payloads
|
|
189
173
|
|
|
190
|
-
Graphics accepts only complete masks with this shape
|
|
174
|
+
Graphics accepts only complete masks with this shape, where `encoding` is
|
|
175
|
+
`lossless` (the SAM API default, payloads starting with `~`) or `one_bit`
|
|
176
|
+
(payloads starting with `!`) and `payload` is the opaque base85 text from the
|
|
177
|
+
wire, passed through unchanged:
|
|
191
178
|
|
|
192
179
|
```ts
|
|
193
180
|
{
|
|
@@ -198,12 +185,16 @@ Graphics accepts only complete masks with this shape:
|
|
|
198
185
|
}
|
|
199
186
|
```
|
|
200
187
|
|
|
188
|
+
A mask record parsed from SAM API output also carries `bounds`, the half-open box
|
|
189
|
+
the raster covers in source pixels; the renderer scales the `width × height`
|
|
190
|
+
raster into that box. A mask without `bounds` is drawn as a full-frame raster.
|
|
191
|
+
|
|
201
192
|
`width` and `height` must be positive safe integers, their area must be within the
|
|
202
193
|
configured and codec limits, and `payload` must be within the configured and codec
|
|
203
194
|
length limits. During `update()`, the renderer strictly decodes the entire payload,
|
|
204
|
-
verifies decoder finalization, verifies that
|
|
205
|
-
traces the resulting binary raster. Partial, truncated, trailing,
|
|
206
|
-
|
|
195
|
+
verifies decoder finalization, verifies that a `one_bit` representation is
|
|
196
|
+
canonical, and traces the resulting binary raster. Partial, truncated, trailing,
|
|
197
|
+
and invalid payloads reject the update, as does any other encoding.
|
|
207
198
|
|
|
208
199
|
Use `@meta-sam/parser` to produce complete mask records. Graphics does not accept
|
|
209
200
|
streaming mask fragments.
|
|
@@ -243,7 +234,8 @@ A hidden object contributes neither its mask nor its box, and its mask `Path2D`
|
|
|
243
234
|
not created. Colors and geometry are intentionally fixed; fill and outline opacity
|
|
244
235
|
are configurable:
|
|
245
236
|
|
|
246
|
-
- Each `objectId` hashes deterministically to one of eight colors
|
|
237
|
+
- Each `objectId` hashes deterministically to one of eight colors; `objectColor(id)`
|
|
238
|
+
returns the same color so legends can match the overlay.
|
|
247
239
|
- Every mask is traced once into a marching-squares contour: its vertices are the
|
|
248
240
|
midpoints of the edges between neighbouring pixel centers, so a straight
|
|
249
241
|
boundary follows the pixel edge while corners and diagonals are cut at 45°
|
|
@@ -371,8 +363,9 @@ Only the package root is public; deep imports are not supported.
|
|
|
371
363
|
| Export | Purpose |
|
|
372
364
|
| -------------------------------- | ----------------------------------------------------------- |
|
|
373
365
|
| `SegmentationRenderer` | Retain parser views and render masks and boxes. |
|
|
366
|
+
| `objectColor` | The color assigned to an object ID, for legends and labels. |
|
|
374
367
|
| `SegmentationGraphicsError` | Base class for package-specific errors. |
|
|
375
|
-
| `UnsupportedMaskEncodingError` | Reject a mask encoding other than `one_bit`.
|
|
368
|
+
| `UnsupportedMaskEncodingError` | Reject a mask encoding other than `lossless` or `one_bit`. |
|
|
376
369
|
| `InvalidMaskPayloadError` | Reject invalid mask data or a conflicting mask revision. |
|
|
377
370
|
| `SegmentationResourceLimitError` | Reject an update that exceeds a configured resource limit. |
|
|
378
371
|
| `InvalidRenderOptionsError` | Reject invalid result, frame, rectangle, or transform data. |
|
package/dist/renderer.js
CHANGED
|
@@ -52,8 +52,6 @@ export function objectColor(objectId) {
|
|
|
52
52
|
*/
|
|
53
53
|
const RETAINED_MASK_OVERHEAD = 64;
|
|
54
54
|
function sameBounds(left, right) {
|
|
55
|
-
if (left === undefined || right === undefined)
|
|
56
|
-
return left === right;
|
|
57
55
|
return (left.left === right.left &&
|
|
58
56
|
left.top === right.top &&
|
|
59
57
|
left.right === right.right &&
|
|
@@ -150,7 +148,7 @@ function retainMask(record) {
|
|
|
150
148
|
...(record.frame === undefined ? {} : { frameIndex: record.frame.frameIndex }),
|
|
151
149
|
width: record.mask.width,
|
|
152
150
|
height: record.mask.height,
|
|
153
|
-
|
|
151
|
+
bounds: record.bounds,
|
|
154
152
|
};
|
|
155
153
|
}
|
|
156
154
|
function traceMask(mask, limit) {
|
|
@@ -375,22 +373,18 @@ export class SegmentationRenderer {
|
|
|
375
373
|
continue;
|
|
376
374
|
const color = colorFor(mask.objectId);
|
|
377
375
|
context.fillStyle = color;
|
|
378
|
-
|
|
379
|
-
|
|
376
|
+
// The raster covers the record's box: scale width × height into bounds.
|
|
377
|
+
context.save();
|
|
378
|
+
try {
|
|
379
|
+
const boundsScaleX = (mask.bounds.right - mask.bounds.left) / mask.width;
|
|
380
|
+
const boundsScaleY = (mask.bounds.bottom - mask.bounds.top) / mask.height;
|
|
381
|
+
context.translate(mask.bounds.left, mask.bounds.top);
|
|
382
|
+
context.scale(boundsScaleX, boundsScaleY);
|
|
383
|
+
this.#paintMask(context, traced.path, color, outlineWidth /
|
|
384
|
+
Math.max(Math.abs(boundsScaleX), Math.abs(boundsScaleY), Number.MIN_VALUE));
|
|
380
385
|
}
|
|
381
|
-
|
|
382
|
-
context.
|
|
383
|
-
try {
|
|
384
|
-
const boundsScaleX = (mask.bounds.right - mask.bounds.left) / mask.width;
|
|
385
|
-
const boundsScaleY = (mask.bounds.bottom - mask.bounds.top) / mask.height;
|
|
386
|
-
context.translate(mask.bounds.left, mask.bounds.top);
|
|
387
|
-
context.scale(boundsScaleX, boundsScaleY);
|
|
388
|
-
this.#paintMask(context, traced.path, color, outlineWidth /
|
|
389
|
-
Math.max(Math.abs(boundsScaleX), Math.abs(boundsScaleY), Number.MIN_VALUE));
|
|
390
|
-
}
|
|
391
|
-
finally {
|
|
392
|
-
context.restore();
|
|
393
|
-
}
|
|
386
|
+
finally {
|
|
387
|
+
context.restore();
|
|
394
388
|
}
|
|
395
389
|
}
|
|
396
390
|
for (const box of state.boxes.values()) {
|
|
@@ -555,13 +549,16 @@ export class SegmentationRenderer {
|
|
|
555
549
|
mask.height <= 0) {
|
|
556
550
|
throw new InvalidMaskPayloadError('Mask dimensions must be positive safe integers.');
|
|
557
551
|
}
|
|
558
|
-
if (record.bounds
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
552
|
+
if (record.bounds === undefined ||
|
|
553
|
+
![
|
|
554
|
+
record.bounds.left,
|
|
555
|
+
record.bounds.top,
|
|
556
|
+
record.bounds.right,
|
|
557
|
+
record.bounds.bottom,
|
|
558
|
+
].every(Number.isFinite) ||
|
|
559
|
+
record.bounds.right <= record.bounds.left ||
|
|
560
|
+
record.bounds.bottom <= record.bounds.top) {
|
|
561
|
+
throw new InvalidMaskPayloadError('Mask bounds are invalid.');
|
|
565
562
|
}
|
|
566
563
|
const area = mask.width * mask.height;
|
|
567
564
|
if (!Number.isSafeInteger(area) || area > this.#limits.maxMaskArea) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meta-sam/graphics",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Retained Canvas 2D renderer for SAM 3 segmentation",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://github.com/meta-models/meta-sam#readme",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": "^20.17.0 || >=22.9.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@meta-sam/parser": "0.0.
|
|
36
|
+
"@meta-sam/parser": "0.0.10"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsc -b",
|