@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.
Files changed (3) hide show
  1. package/README.md +38 -45
  2. package/dist/renderer.js +22 -25
  3. 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 { ImageSegmentationResult } from '@meta-sam/parser';
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: ImageSegmentationResult = {
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: { x: 0, y: 0, width: 5, height: 5 },
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
- The mask payload in this example is one complete, canonical `one_bit` payload. A
79
- real application normally passes parser snapshots to `update()` as they arrive and
80
- then passes the final result when parsing completes.
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 and point records are accepted but are not
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 the representation is canonical, and
205
- traces the resulting binary raster. Partial, truncated, trailing, invalid, and
206
- noncanonical payloads reject the update. Unsupported encodings reject the update.
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
- ...(record.bounds === undefined ? {} : { bounds: record.bounds }),
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
- if (mask.bounds === undefined) {
379
- this.#paintMask(context, traced.path, color, outlineWidth);
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
- else {
382
- context.save();
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 !== undefined) {
559
- const { left, top, right, bottom } = record.bounds;
560
- if (![left, top, right, bottom].every(Number.isFinite) ||
561
- right <= left ||
562
- bottom <= top) {
563
- throw new InvalidMaskPayloadError('Mask bounds are invalid.');
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.5",
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.8"
36
+ "@meta-sam/parser": "0.0.10"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsc -b",