@meta-sam/graphics 0.1.9 → 0.1.11

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 CHANGED
@@ -272,9 +272,21 @@ are configurable:
272
272
  - Boxes use the same color at `globalAlpha = 1` and a source-coordinate line width
273
273
  of `2 / max(abs(scaleX), abs(scaleY))`.
274
274
  - Masks render before boxes.
275
-
276
- The renderer does not mutate records. Object colors, contour geometry, box styling,
277
- draw order, and source transforms are not configurable.
275
+ - With `boxLabels: true`, each visible box gets a label at its top-left corner
276
+ after all boxes are drawn. The text is
277
+ `formatBoxLabel(boxLabel, objectId, confidence)`: the render's trimmed
278
+ `boxLabel` option, the box's `objectId`, then its parser `confidence` with
279
+ three decimals in parentheses, as `pillow 3 (0.945)`. The label and the
280
+ confidence appear only when present, so a box shows `pillow 3` without a
281
+ confidence and `3 (0.945)` without a label. The text is white 11px on a 16px-high fill in the object color.
282
+ Labels are sized in target CSS pixels, so they do not scale with the media. A
283
+ label sits above its box when the target has room and inside the box's top
284
+ edge otherwise, and it shifts left to stay inside the target. Hidden objects
285
+ and boxes from other video frames get no label. Labels are off by default.
286
+ Pass `boxLabel` to `render()` or `renderVideoFrame()`; it must be a string.
287
+
288
+ The renderer does not mutate records. Object colors, contour geometry, box and
289
+ label styling, draw order, and source transforms are not configurable.
278
290
 
279
291
  ## Lifecycle and transactional behavior
280
292
 
@@ -316,6 +328,7 @@ import {
316
328
  const options: SegmentationRendererOptions = {
317
329
  maskFillOpacity: 0.5,
318
330
  maskOutline: { width: 2, opacity: 0.9 },
331
+ boxLabels: true,
319
332
  maxCachedPaths: 128,
320
333
  maxCachedComplexity: 250_000,
321
334
  maxRecords: 20_000,
@@ -335,6 +348,7 @@ const renderer = new SegmentationRenderer(options);
335
348
  | `maskOutline` | `true` |
336
349
  | `maskOutline.width` | `0.003 × min(source.width, source.height)` |
337
350
  | `maskOutline.opacity` | `0.8` |
351
+ | `boxLabels` | `false` |
338
352
  | `maxCachedPaths` | `128` |
339
353
  | `maxCachedComplexity` | `250_000` |
340
354
  | `maxRecords` | `20_000` |
@@ -348,7 +362,8 @@ const renderer = new SegmentationRenderer(options);
348
362
  `maskFillOpacity` and `maskOutline.opacity` must be finite numbers in the inclusive
349
363
  range from `0` through `1`. An outline with opacity `0` remains enabled and is still
350
364
  stroked. `maskOutline` also accepts `true`, `false`, or `{ width }`; width is in source
351
- pixels and must be finite and greater than zero. Every resource override must be a
365
+ pixels and must be finite and greater than zero. `boxLabels` must be a
366
+ boolean. Every resource override must be a
352
367
  positive safe integer. Invalid constructor options throw `TypeError`. Constructor
353
368
  settings are resolved once, so later mutation of an options object has no effect.
354
369
  Cache limits evict least-recently-used paths. Other resource limits reject an update
@@ -364,6 +379,8 @@ Only the package root is public; deep imports are not supported.
364
379
  | -------------------------------- | ----------------------------------------------------------- |
365
380
  | `SegmentationRenderer` | Retain parser views and render masks and boxes. |
366
381
  | `objectColor` | The color assigned to an object ID, for legends and labels. |
382
+ | `formatBoxLabel` | The text of one box label, for matching legends. |
383
+ | `formatConfidence` | A confidence as box labels show it, for matching legends. |
367
384
  | `SegmentationGraphicsError` | Base class for package-specific errors. |
368
385
  | `UnsupportedMaskEncodingError` | Reject a mask encoding other than `lossless` or `one_bit`. |
369
386
  | `InvalidMaskPayloadError` | Reject invalid mask data or a conflicting mask revision. |
@@ -399,6 +416,8 @@ interface SegmentationRendererOptions {
399
416
  /** Mask fill opacity. Defaults to 0.35. */
400
417
  readonly maskFillOpacity?: number;
401
418
  readonly maskOutline?: boolean | MaskOutlineOptions;
419
+ /** Draw "<boxLabel> <objectId> (<confidence>)" box labels. Defaults to false. */
420
+ readonly boxLabels?: boolean;
402
421
  // Resource limit options are unchanged.
403
422
  }
404
423
  ```
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { InvalidMaskPayloadError, InvalidRenderOptionsError, Path2DUnavailableError, RendererDisposedError, SegmentationGraphicsError, SegmentationResourceLimitError, UnsupportedMaskEncodingError, } from './errors.js';
2
- export { SegmentationRenderer, objectColor } from './renderer.js';
2
+ export { SegmentationRenderer, formatBoxLabel, formatConfidence, objectColor, } from './renderer.js';
3
3
  export type { ImageRenderOptions, MaskOutlineOptions, Rectangle, SegmentationCanvasContext, SegmentationRendererOptions, SegmentationRenderOptions, SegmentationUpdateOptions, VideoFrameCompositionContext, VideoFrameCompositionOptions, VideoFrameFit, VideoRenderOptions, } from './renderer.js';
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@
2
2
  * Copyright (c) Meta Platforms, Inc. and affiliates. All Rights Reserved.
3
3
  */
4
4
  export { InvalidMaskPayloadError, InvalidRenderOptionsError, Path2DUnavailableError, RendererDisposedError, SegmentationGraphicsError, SegmentationResourceLimitError, UnsupportedMaskEncodingError, } from './errors.js';
5
- export { SegmentationRenderer, objectColor } from './renderer.js';
5
+ export { SegmentationRenderer, formatBoxLabel, formatConfidence, objectColor, } from './renderer.js';
@@ -39,6 +39,8 @@ export interface VideoFrameCompositionOptions {
39
39
  readonly devicePixelRatio?: number | (() => number);
40
40
  /** Object IDs, not mask identities, to omit from this composition. */
41
41
  readonly hiddenIds?: ReadonlySet<string> | readonly string[];
42
+ /** Text shown before the object ID in box labels; see `boxLabels`. */
43
+ readonly boxLabel?: string;
42
44
  }
43
45
  export interface MaskOutlineOptions {
44
46
  /**
@@ -58,6 +60,13 @@ export interface SegmentationRendererOptions {
58
60
  * fill. Defaults to enabled; pass `false` for fill only.
59
61
  */
60
62
  readonly maskOutline?: boolean | MaskOutlineOptions;
63
+ /**
64
+ * Draw a label at each box's top-left corner: the render's `boxLabel`, the
65
+ * box's object ID, then its parser `confidence` in parentheses, as
66
+ * `pillow 3 (0.945)`. The label and the confidence appear only when present.
67
+ * Defaults to `false`.
68
+ */
69
+ readonly boxLabels?: boolean;
61
70
  /** Traced paths kept in the LRU cache. */
62
71
  readonly maxCachedPaths?: number;
63
72
  /** Total traced-path characters kept in the LRU cache. */
@@ -84,6 +93,8 @@ interface RenderOptionsBase {
84
93
  readonly source: Rectangle;
85
94
  readonly target: Rectangle;
86
95
  readonly hiddenIds?: ReadonlySet<string> | readonly string[];
96
+ /** Text shown before the object ID in box labels; see `boxLabels`. */
97
+ readonly boxLabel?: string;
87
98
  }
88
99
  export interface ImageRenderOptions extends RenderOptionsBase {
89
100
  readonly media: 'image';
@@ -99,6 +110,14 @@ type SegmentationView = SegmentationResult | SegmentationSnapshot;
99
110
  * Exposed so legends and inspectors can match the composited overlay exactly.
100
111
  */
101
112
  export declare function objectColor(objectId: string): string;
113
+ /** A confidence as box labels show it: the value with three decimals. */
114
+ export declare function formatConfidence(confidence: number): string;
115
+ /**
116
+ * The text of one box label: the trimmed label, the object ID, then the
117
+ * confidence in parentheses, as `pillow 3 (0.945)`. A missing or blank label
118
+ * and a missing confidence are left out, so the object ID is always shown.
119
+ */
120
+ export declare function formatBoxLabel(label: string | undefined, objectId: string, confidence: number | undefined): string;
102
121
  export declare class SegmentationRenderer {
103
122
  #private;
104
123
  constructor(options?: SegmentationRendererOptions);
package/dist/renderer.js CHANGED
@@ -179,6 +179,32 @@ const DEFAULT_MASK_OUTLINE_OPACITY = 0.8;
179
179
  const MASK_OUTLINE_WIDTH_RATIO = 0.003;
180
180
  /** Contour width in target CSS pixels when the source size is unusable. */
181
181
  const FALLBACK_MASK_OUTLINE_WIDTH = 1.5;
182
+ /** Box label geometry in target CSS pixels. */
183
+ const BOX_LABEL_HEIGHT = 16;
184
+ const BOX_LABEL_PADDING = 4;
185
+ const BOX_LABEL_FONT = '600 11px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
186
+ const BOX_LABEL_TEXT_COLOR = '#ffffff';
187
+ /** A confidence as box labels show it: the value with three decimals. */
188
+ export function formatConfidence(confidence) {
189
+ return confidence.toFixed(3);
190
+ }
191
+ /**
192
+ * The text of one box label: the trimmed label, the object ID, then the
193
+ * confidence in parentheses, as `pillow 3 (0.945)`. A missing or blank label
194
+ * and a missing confidence are left out, so the object ID is always shown.
195
+ */
196
+ export function formatBoxLabel(label, objectId, confidence) {
197
+ return [
198
+ ...(label === undefined || label.trim().length === 0 ? [] : [label.trim()]),
199
+ objectId,
200
+ ...(confidence === undefined ? [] : [`(${formatConfidence(confidence)})`]),
201
+ ].join(' ');
202
+ }
203
+ function validateBoxLabel(label) {
204
+ if (label !== undefined && typeof label !== 'string') {
205
+ throw new InvalidRenderOptionsError('boxLabel must be a string.');
206
+ }
207
+ }
182
208
  function resolveMaskOutline(option) {
183
209
  if (option === false) {
184
210
  return { enabled: false, width: null, opacity: DEFAULT_MASK_OUTLINE_OPACITY };
@@ -206,6 +232,7 @@ export class SegmentationRenderer {
206
232
  #limits;
207
233
  #maskFillOpacity;
208
234
  #outline;
235
+ #boxLabels;
209
236
  #cache = new Map();
210
237
  #cacheComplexity = 0;
211
238
  #state;
@@ -215,6 +242,10 @@ export class SegmentationRenderer {
215
242
  constructor(options = {}) {
216
243
  this.#maskFillOpacity = unitInterval(options.maskFillOpacity, DEFAULT_MASK_FILL_OPACITY, 'maskFillOpacity');
217
244
  this.#outline = resolveMaskOutline(options.maskOutline);
245
+ if (options.boxLabels !== undefined && typeof options.boxLabels !== 'boolean') {
246
+ throw new TypeError('boxLabels must be a boolean.');
247
+ }
248
+ this.#boxLabels = options.boxLabels === true;
218
249
  this.#limits = {
219
250
  maxCachedPaths: positiveInteger(options.maxCachedPaths, 128, 'maxCachedPaths'),
220
251
  maxCachedComplexity: positiveInteger(options.maxCachedComplexity, 250_000, 'maxCachedComplexity'),
@@ -313,6 +344,7 @@ export class SegmentationRenderer {
313
344
  source,
314
345
  target,
315
346
  ...(options.hiddenIds === undefined ? {} : { hiddenIds: options.hiddenIds }),
347
+ ...(options.boxLabel === undefined ? {} : { boxLabel: options.boxLabel }),
316
348
  });
317
349
  }
318
350
  finally {
@@ -327,6 +359,7 @@ export class SegmentationRenderer {
327
359
  validateRectangle(options.target, 'target');
328
360
  if (options.media === 'video')
329
361
  validateFrame(options.frameIndex);
362
+ validateBoxLabel(options.boxLabel);
330
363
  const state = this.#state;
331
364
  if (state === undefined)
332
365
  return;
@@ -400,6 +433,54 @@ export class SegmentationRenderer {
400
433
  2 / Math.max(Math.abs(scaleX), Math.abs(scaleY), Number.MIN_VALUE);
401
434
  context.strokeRect(box.left, box.top, box.right - box.left, box.bottom - box.top);
402
435
  }
436
+ if (this.#boxLabels) {
437
+ this.#paintBoxLabels(context, state, options, hidden, {
438
+ scaleX,
439
+ scaleY,
440
+ offsetX,
441
+ offsetY,
442
+ });
443
+ }
444
+ }
445
+ finally {
446
+ context.restore();
447
+ }
448
+ }
449
+ /**
450
+ * Draws each visible box's label in target CSS pixels, so the label keeps
451
+ * one size whatever the media scale. The label sits on the box's top
452
+ * edge, above the box when the target has room and inside it otherwise, and
453
+ * it is shifted left when it would extend past the target's right edge.
454
+ */
455
+ #paintBoxLabels(context, state, options, hidden, transform) {
456
+ const { scaleX, scaleY, offsetX, offsetY } = transform;
457
+ const { target } = options;
458
+ context.save();
459
+ try {
460
+ // Undo the source-to-target transform; any device-pixel scale stays.
461
+ context.transform(1 / scaleX, 0, 0, 1 / scaleY, -offsetX / scaleX, -offsetY / scaleY);
462
+ context.globalAlpha = 1;
463
+ context.font = BOX_LABEL_FONT;
464
+ context.textAlign = 'left';
465
+ context.textBaseline = 'middle';
466
+ for (const box of state.boxes.values()) {
467
+ if (hidden.has(box.objectId) ||
468
+ (options.media === 'video' &&
469
+ box.frameIndex !== undefined &&
470
+ box.frameIndex !== options.frameIndex)) {
471
+ continue;
472
+ }
473
+ const text = formatBoxLabel(options.boxLabel, box.objectId, box.confidence);
474
+ const width = context.measureText(text).width + 2 * BOX_LABEL_PADDING;
475
+ const left = offsetX + box.left * scaleX;
476
+ const top = offsetY + box.top * scaleY;
477
+ const x = Math.max(target.x, Math.min(left, target.x + target.width - width));
478
+ const y = top - BOX_LABEL_HEIGHT >= target.y ? top - BOX_LABEL_HEIGHT : top;
479
+ context.fillStyle = colorFor(box.objectId);
480
+ context.fillRect(x, y, width, BOX_LABEL_HEIGHT);
481
+ context.fillStyle = BOX_LABEL_TEXT_COLOR;
482
+ context.fillText(text, x + BOX_LABEL_PADDING, y + BOX_LABEL_HEIGHT / 2);
483
+ }
403
484
  }
404
485
  finally {
405
486
  context.restore();
@@ -471,6 +552,12 @@ export class SegmentationRenderer {
471
552
  height < 0) {
472
553
  throw new InvalidRenderOptionsError('Box coordinates are invalid.');
473
554
  }
555
+ if (record.confidence !== undefined &&
556
+ (!Number.isFinite(record.confidence) ||
557
+ record.confidence < 0 ||
558
+ record.confidence > 1)) {
559
+ throw new InvalidRenderOptionsError('Box confidence must be a number from 0 through 1.');
560
+ }
474
561
  const identity = boxIdentity(record);
475
562
  boxes.set(identity, {
476
563
  identity,
@@ -482,6 +569,7 @@ export class SegmentationRenderer {
482
569
  top: record.top,
483
570
  right: record.right,
484
571
  bottom: record.bottom,
572
+ ...(record.confidence === undefined ? {} : { confidence: record.confidence }),
485
573
  });
486
574
  }
487
575
  }
@@ -496,6 +584,7 @@ export class SegmentationRenderer {
496
584
  box.top,
497
585
  box.right,
498
586
  box.bottom,
587
+ box.confidence ?? null,
499
588
  ]).length;
500
589
  if (complexity > this.#limits.maxRetainedComplexity) {
501
590
  throw new SegmentationResourceLimitError('maxRetainedComplexity');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meta-sam/graphics",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Retained Canvas 2D renderer for SAM 3 segmentation",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://dev.meta.ai/",
@@ -33,7 +33,7 @@
33
33
  "node": "^20.17.0 || >=22.9.0"
34
34
  },
35
35
  "dependencies": {
36
- "@meta-sam/parser": "0.0.12"
36
+ "@meta-sam/parser": "0.0.13"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsc -b",