meta-sam-parser 0.0.2__tar.gz → 0.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-sam-parser
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Native Python parsing primitives for the SAM 3 segmentation protocol
5
5
  Project-URL: Homepage, https://github.com/meta-models/meta-sam
6
6
  Project-URL: Repository, https://github.com/meta-models/meta-sam
@@ -106,24 +106,35 @@ python -m pip install .
106
106
 
107
107
  Import supported APIs only from the package root. Deep imports are unsupported;
108
108
  underscore-prefixed modules are implementation details and may change without notice.
109
- Format factories are zero-argument and create isolated parser state:
109
+ Format factories are zero-argument and create isolated parser state. The example
110
+ feeds the parser one frame of SAM 3.1 output exactly as the API emits it - one line
111
+ per frame, `<Nf>` then comma-separated `id<|box;...|><|mask;...|>` records:
110
112
 
111
113
  ```python
112
114
  from meta_sam_parser import CompletedOutcome, video_segmentation_format
113
115
 
116
+ output_text = (
117
+ "<0f>0<|box;x1=211;y1=228;x2=270;y2=254;w=320;h=334|>"
118
+ "<|mask;x=0;y=0;data=27,60,~!!!!M!0c[0o91w?q1!pIH4pPRVp2B3'7`e.ioeAf6-k/#Xd8%dX9x(|>"
119
+ ",1<|box;x1=155;y1=228;x2=202;y2=254;w=320;h=334|>"
120
+ "<|mask;x=0;y=0;data=27,48,~!!!!J!0c[q=Pj_zs=*4C4(/./x#:/`S_GnD`=o3?X{emCgO$y@|>\n"
121
+ )
122
+
114
123
  format_ = video_segmentation_format()
115
124
  parser = format_.create_parser()
116
125
 
117
- for snapshot in parser.push(
118
- "[frame=7] object=bus box=(10,20,110,80)\n"
119
- "[frame=7] object=bus mask=one_bit;size=5x5;data=!!!!!(QO(0lu8?\n"
120
- ):
126
+ # Chunks may split anywhere, including inside a mask payload.
127
+ split = output_text.index(",1<|box")
128
+ for snapshot in parser.push(output_text[:split]):
121
129
  print(snapshot.revision, len(snapshot.records))
130
+ for snapshot in parser.push(output_text[split:]):
131
+ print(snapshot.revision, len(snapshot.records)) # 1 4
122
132
 
123
133
  finished = parser.finish(CompletedOutcome())
124
134
  for snapshot in finished.events:
125
135
  print(snapshot.revision, len(snapshot.records))
126
136
  result = finished.result
137
+ # Two box records and two mask records, object IDs "0" and "1", frame 0.
127
138
  ```
128
139
 
129
140
  `push()` accepts arbitrarily split text chunks and returns zero or one cumulative
@@ -228,10 +239,10 @@ All public values are frozen, slotted dataclasses. Observable collections are
228
239
  tuples. The package root exports:
229
240
 
230
241
  - Geometry, masks, and aliases: `FrameReference`, `SegmentationMaskBounds`,
231
- `SegmentationMask`, `SegmentationMaskIdentity`, `SegmentationMedia`,
242
+ `SegmentationMask`, `SegmentationMaskEncoding`, `SegmentationMaskIdentity`, `SegmentationMedia`,
232
243
  `DiagnosticSeverity`, and `IncompleteReason`.
233
- - Records: `SegmentationTextRecord`, `SegmentationPointRecord`,
234
- `SegmentationBoxRecord`, `SegmentationMaskRecord`, and `SegmentationRecord`.
244
+ - Records: `SegmentationTextRecord`, `SegmentationBoxRecord`,
245
+ `SegmentationMaskRecord`, and `SegmentationRecord`.
235
246
  - Views: `SegmentationDiagnostic`, `ImageSegmentationSnapshot`,
236
247
  `VideoSegmentationSnapshot`, `SegmentationSnapshot`,
237
248
  `ImageSegmentationResult`, `VideoSegmentationResult`, and
@@ -258,15 +269,31 @@ the next revision.
258
269
 
259
270
  ## Parsing behavior
260
271
 
261
- The documented package grammar is the line form in the
272
+ SAM 3.1 returns segmentation as special-token text in one `output_text` lane, one
273
+ line per frame:
274
+
275
+ ```text
276
+ <Nf>id<|box;x1=..;y1=..;x2=..;y2=..;w=<frameW>;h=<frameH>|><|mask;x=0;y=0;data=<H>,<W>,<enc>payload|>,id<|box;...|><|mask;...|>
277
+ ```
278
+
279
+ `<Nf>` is the zero-based frame index; frames without a visible object emit no
280
+ line, so indices can skip. Each comma-separated record is a bare integer object
281
+ id - stable for an object across the frames of one response and not a dense
282
+ sequence - followed by one box and one mask. The parser retains the id as a string
283
+ in `object_id`. Box corners and the `w`/`h` frame size are source pixels; the
284
+ inclusive wire `x2`/`y2` become half-open `right`/`bottom`. The mask tuple is
285
+ `height,width,payload`, where the payload's first character selects the encoding:
286
+ `~` for `lossless` (the API default) or `!` for `one_bit`. The payload is base85,
287
+ not base64: after the marker its digits are printable ASCII `!` through `{` minus
288
+ the wire delimiters `" \ , ; < > |`, so it contains `*`, `$`, brackets, and
289
+ backticks, a base64 regex will not match it, and only the first character after
290
+ `H,W,` is the marker (`!` is also digit zero). Pass it through unchanged. Each record becomes one `SegmentationBoxRecord` followed by
291
+ one `SegmentationMaskRecord` whose `bounds` is the same half-open box. Image streams require frame zero and omit the frame from
292
+ normalized records; video records retain `frame.frame_index`. Every mask is
293
+ strictly decoded before insertion; an empty lane is a valid completed response
294
+ with no records.
295
+ The grammar is specified in the
262
296
  [SAM 3 protocol](https://github.com/meta-models/meta-sam/blob/main/protocol/sam3.md).
263
- The parser separately accepts compact box-first SAM API output as production
264
- compatibility input and normalizes it to the same records. Its object token is an
265
- ASCII-decimal ID retained as a string, and IDs may be multi-digit or non-contiguous.
266
- The compact input's inclusive `x2`/`y2` coordinates become half-open `right`/`bottom`
267
- bounds. Image API
268
- records require frame zero and omit it from normalized records; video records retain
269
- frame indices. Every mask is strictly decoded before insertion.
270
297
 
271
298
  Plain text remains an ordered text record. Malformed structured-looking lines
272
299
  produce diagnostics and parsing continues. `raw_output` preserves every input
@@ -359,8 +386,11 @@ by the `release PyPI distribution` workflow
359
386
  1. Bump `version` in `pyproject.toml`, run `node scripts/sync-compatibility` from
360
387
  the repository root so the compatibility matrix records the new version, and
361
388
  merge that change to `main`.
362
- 2. Push the tag `meta-sam-parser@<version>` at that commit. The workflow refuses a
363
- tag that does not match the manifest version.
389
+ 2. Push the tag `meta-sam-parser@<version>` at that merge commit on `main`. The
390
+ workflow refuses a tag that does not match the manifest version, a tag whose
391
+ commit is not on `main`, and a tag older than the latest `main` commit touching
392
+ `python/`, `conformance/`, or `protocol/`, so a tag created before later fixes
393
+ merged cannot publish stale source.
364
394
  3. The workflow builds the reproducible wheel and sdist, runs the artifact and
365
395
  clean-consumer audits and the cross-language conformance suite, and then waits
366
396
  for approval in the `pypi` GitHub environment before uploading through PyPI
@@ -17,24 +17,35 @@ python -m pip install .
17
17
 
18
18
  Import supported APIs only from the package root. Deep imports are unsupported;
19
19
  underscore-prefixed modules are implementation details and may change without notice.
20
- Format factories are zero-argument and create isolated parser state:
20
+ Format factories are zero-argument and create isolated parser state. The example
21
+ feeds the parser one frame of SAM 3.1 output exactly as the API emits it - one line
22
+ per frame, `<Nf>` then comma-separated `id<|box;...|><|mask;...|>` records:
21
23
 
22
24
  ```python
23
25
  from meta_sam_parser import CompletedOutcome, video_segmentation_format
24
26
 
27
+ output_text = (
28
+ "<0f>0<|box;x1=211;y1=228;x2=270;y2=254;w=320;h=334|>"
29
+ "<|mask;x=0;y=0;data=27,60,~!!!!M!0c[0o91w?q1!pIH4pPRVp2B3'7`e.ioeAf6-k/#Xd8%dX9x(|>"
30
+ ",1<|box;x1=155;y1=228;x2=202;y2=254;w=320;h=334|>"
31
+ "<|mask;x=0;y=0;data=27,48,~!!!!J!0c[q=Pj_zs=*4C4(/./x#:/`S_GnD`=o3?X{emCgO$y@|>\n"
32
+ )
33
+
25
34
  format_ = video_segmentation_format()
26
35
  parser = format_.create_parser()
27
36
 
28
- for snapshot in parser.push(
29
- "[frame=7] object=bus box=(10,20,110,80)\n"
30
- "[frame=7] object=bus mask=one_bit;size=5x5;data=!!!!!(QO(0lu8?\n"
31
- ):
37
+ # Chunks may split anywhere, including inside a mask payload.
38
+ split = output_text.index(",1<|box")
39
+ for snapshot in parser.push(output_text[:split]):
32
40
  print(snapshot.revision, len(snapshot.records))
41
+ for snapshot in parser.push(output_text[split:]):
42
+ print(snapshot.revision, len(snapshot.records)) # 1 4
33
43
 
34
44
  finished = parser.finish(CompletedOutcome())
35
45
  for snapshot in finished.events:
36
46
  print(snapshot.revision, len(snapshot.records))
37
47
  result = finished.result
48
+ # Two box records and two mask records, object IDs "0" and "1", frame 0.
38
49
  ```
39
50
 
40
51
  `push()` accepts arbitrarily split text chunks and returns zero or one cumulative
@@ -139,10 +150,10 @@ All public values are frozen, slotted dataclasses. Observable collections are
139
150
  tuples. The package root exports:
140
151
 
141
152
  - Geometry, masks, and aliases: `FrameReference`, `SegmentationMaskBounds`,
142
- `SegmentationMask`, `SegmentationMaskIdentity`, `SegmentationMedia`,
153
+ `SegmentationMask`, `SegmentationMaskEncoding`, `SegmentationMaskIdentity`, `SegmentationMedia`,
143
154
  `DiagnosticSeverity`, and `IncompleteReason`.
144
- - Records: `SegmentationTextRecord`, `SegmentationPointRecord`,
145
- `SegmentationBoxRecord`, `SegmentationMaskRecord`, and `SegmentationRecord`.
155
+ - Records: `SegmentationTextRecord`, `SegmentationBoxRecord`,
156
+ `SegmentationMaskRecord`, and `SegmentationRecord`.
146
157
  - Views: `SegmentationDiagnostic`, `ImageSegmentationSnapshot`,
147
158
  `VideoSegmentationSnapshot`, `SegmentationSnapshot`,
148
159
  `ImageSegmentationResult`, `VideoSegmentationResult`, and
@@ -169,15 +180,31 @@ the next revision.
169
180
 
170
181
  ## Parsing behavior
171
182
 
172
- The documented package grammar is the line form in the
183
+ SAM 3.1 returns segmentation as special-token text in one `output_text` lane, one
184
+ line per frame:
185
+
186
+ ```text
187
+ <Nf>id<|box;x1=..;y1=..;x2=..;y2=..;w=<frameW>;h=<frameH>|><|mask;x=0;y=0;data=<H>,<W>,<enc>payload|>,id<|box;...|><|mask;...|>
188
+ ```
189
+
190
+ `<Nf>` is the zero-based frame index; frames without a visible object emit no
191
+ line, so indices can skip. Each comma-separated record is a bare integer object
192
+ id - stable for an object across the frames of one response and not a dense
193
+ sequence - followed by one box and one mask. The parser retains the id as a string
194
+ in `object_id`. Box corners and the `w`/`h` frame size are source pixels; the
195
+ inclusive wire `x2`/`y2` become half-open `right`/`bottom`. The mask tuple is
196
+ `height,width,payload`, where the payload's first character selects the encoding:
197
+ `~` for `lossless` (the API default) or `!` for `one_bit`. The payload is base85,
198
+ not base64: after the marker its digits are printable ASCII `!` through `{` minus
199
+ the wire delimiters `" \ , ; < > |`, so it contains `*`, `$`, brackets, and
200
+ backticks, a base64 regex will not match it, and only the first character after
201
+ `H,W,` is the marker (`!` is also digit zero). Pass it through unchanged. Each record becomes one `SegmentationBoxRecord` followed by
202
+ one `SegmentationMaskRecord` whose `bounds` is the same half-open box. Image streams require frame zero and omit the frame from
203
+ normalized records; video records retain `frame.frame_index`. Every mask is
204
+ strictly decoded before insertion; an empty lane is a valid completed response
205
+ with no records.
206
+ The grammar is specified in the
173
207
  [SAM 3 protocol](https://github.com/meta-models/meta-sam/blob/main/protocol/sam3.md).
174
- The parser separately accepts compact box-first SAM API output as production
175
- compatibility input and normalizes it to the same records. Its object token is an
176
- ASCII-decimal ID retained as a string, and IDs may be multi-digit or non-contiguous.
177
- The compact input's inclusive `x2`/`y2` coordinates become half-open `right`/`bottom`
178
- bounds. Image API
179
- records require frame zero and omit it from normalized records; video records retain
180
- frame indices. Every mask is strictly decoded before insertion.
181
208
 
182
209
  Plain text remains an ordered text record. Malformed structured-looking lines
183
210
  produce diagnostics and parsing continues. `raw_output` preserves every input
@@ -270,8 +297,11 @@ by the `release PyPI distribution` workflow
270
297
  1. Bump `version` in `pyproject.toml`, run `node scripts/sync-compatibility` from
271
298
  the repository root so the compatibility matrix records the new version, and
272
299
  merge that change to `main`.
273
- 2. Push the tag `meta-sam-parser@<version>` at that commit. The workflow refuses a
274
- tag that does not match the manifest version.
300
+ 2. Push the tag `meta-sam-parser@<version>` at that merge commit on `main`. The
301
+ workflow refuses a tag that does not match the manifest version, a tag whose
302
+ commit is not on `main`, and a tag older than the latest `main` commit touching
303
+ `python/`, `conformance/`, or `protocol/`, so a tag created before later fixes
304
+ merged cannot publish stale source.
275
305
  3. The workflow builds the reproducible wheel and sdist, runs the artifact and
276
306
  clean-consumer audits and the cross-language conformance suite, and then waits
277
307
  for approval in the `pypi` GitHub environment before uploading through PyPI
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "meta-sam-parser"
9
- version = "0.0.2"
9
+ version = "0.0.3"
10
10
  description = "Native Python parsing primitives for the SAM 3 segmentation protocol"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -36,10 +36,10 @@ from ._types import (
36
36
  SegmentationDiagnostic,
37
37
  SegmentationMask,
38
38
  SegmentationMaskBounds,
39
+ SegmentationMaskEncoding,
39
40
  SegmentationMaskIdentity,
40
41
  SegmentationMaskRecord,
41
42
  SegmentationMedia,
42
- SegmentationPointRecord,
43
43
  SegmentationRecord,
44
44
  SegmentationResult,
45
45
  SegmentationSnapshot,
@@ -80,10 +80,10 @@ __all__ = [
80
80
  "SegmentationDiagnostic",
81
81
  "SegmentationMask",
82
82
  "SegmentationMaskBounds",
83
+ "SegmentationMaskEncoding",
83
84
  "SegmentationMaskIdentity",
84
85
  "SegmentationMaskRecord",
85
86
  "SegmentationMedia",
86
- "SegmentationPointRecord",
87
87
  "SegmentationRecord",
88
88
  "SegmentationResult",
89
89
  "SegmentationSnapshot",
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import math
6
5
  import re
7
6
  from dataclasses import dataclass
8
7
  from typing import cast
@@ -23,7 +22,6 @@ from ._types import (
23
22
  SegmentationMaskIdentity,
24
23
  SegmentationMaskRecord,
25
24
  SegmentationMedia,
26
- SegmentationPointRecord,
27
25
  SegmentationRecord,
28
26
  SegmentationResult,
29
27
  SegmentationSnapshot,
@@ -40,65 +38,28 @@ _JS_WHITESPACE = (
40
38
  _JS_WHITESPACE_CLASS = (
41
39
  r"\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
42
40
  )
43
- _JS_WHITESPACE_PATTERN = rf"[{_JS_WHITESPACE_CLASS}]"
44
- _JS_NON_WHITESPACE_PATTERN = rf"[^{_JS_WHITESPACE_CLASS}]"
45
41
  _JS_DOT_PATTERN = r"[^\n\r\u2028\u2029]"
46
- _RECORD_PATTERN = re.compile(
47
- rf"^(?:\[frame=([0-9]+)\]{_JS_WHITESPACE_PATTERN}+)?"
48
- rf"object=([A-Za-z0-9._-]+){_JS_WHITESPACE_PATTERN}+"
49
- rf"(point|box|mask)=({_JS_DOT_PATTERN}+)$"
50
- )
51
- _COMPACT_API_HEADER_PATTERN = re.compile(rf"^<([0-9]+)f>({_JS_DOT_PATTERN}*)$")
52
- _COMPACT_API_OBJECT_PATTERN = re.compile(
42
+ _API_HEADER_PATTERN = re.compile(rf"^<([0-9]+)f>({_JS_DOT_PATTERN}*)$")
43
+ _API_RECORD_PATTERN = re.compile(
53
44
  r"^(?:,)?([0-9]+)"
54
45
  r"<\|box;x1=(-?[0-9]+);y1=(-?[0-9]+);x2=(-?[0-9]+);"
55
46
  r"y2=(-?[0-9]+);w=([0-9]+);h=([0-9]+)\|>"
56
47
  r"<\|mask;x=0;y=0;data=([0-9]+),([0-9]+),([!~][^|]+)\|>"
57
48
  )
58
- _POINT_PATTERN = re.compile(r"^\(([^,]+),([^,]+)\)$")
59
- _BOX_PATTERN = re.compile(r"^\(([^,]+),([^,]+),([^,]+),([^,]+)\)$")
60
- _MASK_PATTERN = re.compile(
61
- rf"^([^;{_JS_WHITESPACE_CLASS}]+);"
62
- rf"size=([0-9]+)x([0-9]+);data=({_JS_NON_WHITESPACE_PATTERN}+)$"
63
- )
64
- _DECIMAL_NUMBER_PATTERN = re.compile(
65
- r"^[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))"
66
- r"(?:[eE][+-]?[0-9]+)?$"
67
- )
68
- _HEXADECIMAL_NUMBER_PATTERN = re.compile(r"^0[xX][0-9A-Fa-f]+$")
69
- _BINARY_NUMBER_PATTERN = re.compile(r"^0[bB][01]+$")
70
- _OCTAL_NUMBER_PATTERN = re.compile(r"^0[oO][0-7]+$")
71
49
 
72
50
 
73
51
  def _trim(value: str) -> str:
74
52
  return value.strip(_JS_WHITESPACE)
75
53
 
76
54
 
77
- def _javascript_number(value: str) -> float | None:
78
- normalized = _trim(value)
79
- if not normalized:
80
- return None
81
- try:
82
- if _HEXADECIMAL_NUMBER_PATTERN.fullmatch(normalized):
83
- parsed = float(int(normalized[2:], 16))
84
- elif _BINARY_NUMBER_PATTERN.fullmatch(normalized):
85
- parsed = float(int(normalized[2:], 2))
86
- elif _OCTAL_NUMBER_PATTERN.fullmatch(normalized):
87
- parsed = float(int(normalized[2:], 8))
88
- elif _DECIMAL_NUMBER_PATTERN.fullmatch(normalized):
89
- parsed = float(normalized)
90
- else:
91
- return None
92
- except (OverflowError, ValueError):
93
- return None
94
- return parsed if math.isfinite(parsed) else None
95
-
96
-
97
55
  def _integer_text(value: str) -> int | None:
98
- parsed = _javascript_number(value)
99
- if parsed is None or not parsed.is_integer() or abs(parsed) > _MAXIMUM_SAFE_INTEGER:
100
- return None
101
- return int(parsed)
56
+ """Parse a decimal integer token the way JavaScript's ``Number`` observes it.
57
+
58
+ Callers only pass regex captures of ``-?[0-9]+``; values beyond the
59
+ JavaScript safe-integer range are rejected so both parsers agree.
60
+ """
61
+ parsed = int(value)
62
+ return parsed if abs(parsed) <= _MAXIMUM_SAFE_INTEGER else None
102
63
 
103
64
 
104
65
  class _SegmentationParser:
@@ -180,50 +141,15 @@ class _SegmentationParser:
180
141
  return False
181
142
 
182
143
  if line.startswith("<"):
183
- accepted = self._accept_compact_api_line(line, raw)
144
+ accepted = self._accept_api_line(line, raw)
184
145
  if accepted is not None:
185
146
  return accepted
186
147
 
187
- match = _RECORD_PATTERN.fullmatch(line)
188
- if match is None:
189
- if line.startswith("[frame=") or line.startswith("object="):
190
- self._diagnose("malformed_record", "Malformed structured record.", raw)
191
- else:
192
- self._add_record(
193
- SegmentationTextRecord(order=len(self._records), text=raw)
194
- )
195
- return True
196
-
197
- frame_index = None if match.group(1) is None else _integer_text(match.group(1))
198
- if match.group(1) is not None and (frame_index is None or frame_index < 0):
199
- self._diagnose(
200
- "invalid_frame",
201
- "Frame references must be non-negative safe integers.",
202
- raw,
203
- )
204
- return True
205
- if self._media == "image" and frame_index is not None:
206
- self._diagnose(
207
- "unexpected_frame",
208
- "Image segmentation records cannot include a frame reference.",
209
- raw,
210
- )
211
- return True
212
-
213
- object_id = match.group(2)
214
- kind = match.group(3)
215
- value = match.group(4)
216
- frame = None if frame_index is None else FrameReference(frame_index)
217
- if kind == "point":
218
- self._accept_point(object_id, frame, value, raw)
219
- elif kind == "box":
220
- self._accept_box(object_id, frame, value, raw)
221
- else:
222
- self._accept_mask(object_id, frame, value, raw)
148
+ self._add_record(SegmentationTextRecord(order=len(self._records), text=raw))
223
149
  return True
224
150
 
225
- def _accept_compact_api_line(self, line: str, raw: str) -> bool | None:
226
- header = _COMPACT_API_HEADER_PATTERN.fullmatch(line)
151
+ def _accept_api_line(self, line: str, raw: str) -> bool | None:
152
+ header = _API_HEADER_PATTERN.fullmatch(line)
227
153
  if header is None:
228
154
  return None
229
155
  frame_index = _integer_text(header.group(1))
@@ -247,17 +173,17 @@ class _SegmentationParser:
247
173
  if not remainder:
248
174
  self._diagnose(
249
175
  "malformed_record",
250
- "Malformed compact SAM API object record.",
176
+ "Malformed SAM API object record.",
251
177
  raw,
252
178
  )
253
179
  return True
254
180
  accepted = False
255
181
  while remainder:
256
- match = _COMPACT_API_OBJECT_PATTERN.match(remainder)
182
+ match = _API_RECORD_PATTERN.match(remainder)
257
183
  if match is None:
258
184
  self._diagnose(
259
185
  "malformed_record",
260
- "Malformed compact SAM API object record.",
186
+ "Malformed SAM API object record.",
261
187
  raw,
262
188
  )
263
189
  return True
@@ -267,7 +193,7 @@ class _SegmentationParser:
267
193
  )
268
194
  if any(value is None for value in coordinate_values):
269
195
  self._diagnose(
270
- "invalid_box", "Canonical SAM box coordinates are invalid.", raw
196
+ "invalid_box", "SAM API box coordinates are invalid.", raw
271
197
  )
272
198
  return True
273
199
  (
@@ -289,7 +215,7 @@ class _SegmentationParser:
289
215
  or inclusive_bottom >= source_height
290
216
  ):
291
217
  self._diagnose(
292
- "invalid_box", "Canonical SAM box coordinates are invalid.", raw
218
+ "invalid_box", "SAM API box coordinates are invalid.", raw
293
219
  )
294
220
  return True
295
221
  bounds = SegmentationMaskBounds(
@@ -309,14 +235,16 @@ class _SegmentationParser:
309
235
  bottom=inclusive_bottom + 1,
310
236
  )
311
237
  )
312
- mask_height = match.group(8)
313
- mask_width = match.group(9)
314
238
  payload = match.group(10)
315
- encoding = "lossless" if payload.startswith("~") else "one_bit"
316
239
  self._accept_mask(
317
240
  object_id,
318
241
  frame,
319
- f"{encoding};size={mask_width}x{mask_height};data={payload}",
242
+ SegmentationMask(
243
+ encoding="lossless" if payload.startswith("~") else "one_bit",
244
+ payload=payload,
245
+ width=int(match.group(9)),
246
+ height=int(match.group(8)),
247
+ ),
320
248
  raw,
321
249
  bounds,
322
250
  )
@@ -324,109 +252,20 @@ class _SegmentationParser:
324
252
  remainder = remainder[match.end() :]
325
253
  return accepted
326
254
 
327
- def _accept_point(
328
- self,
329
- object_id: str,
330
- frame: FrameReference | None,
331
- value: str,
332
- raw: str,
333
- ) -> None:
334
- match = _POINT_PATTERN.fullmatch(_trim(value))
335
- x = None if match is None else _javascript_number(match.group(1))
336
- y = None if match is None else _javascript_number(match.group(2))
337
- if x is None or y is None:
338
- self._diagnose(
339
- "malformed_point", "Point coordinates must be finite numbers.", raw
340
- )
341
- return
342
- self._add_record(
343
- SegmentationPointRecord(
344
- order=len(self._records),
345
- object_id=object_id,
346
- frame=frame,
347
- x=x,
348
- y=y,
349
- )
350
- )
351
-
352
- def _accept_box(
353
- self,
354
- object_id: str,
355
- frame: FrameReference | None,
356
- value: str,
357
- raw: str,
358
- ) -> None:
359
- match = _BOX_PATTERN.fullmatch(_trim(value))
360
- coordinates = (
361
- None
362
- if match is None
363
- else tuple(_javascript_number(item) for item in match.group(1, 2, 3, 4))
364
- )
365
- if coordinates is None or any(item is None for item in coordinates):
366
- self._diagnose(
367
- "malformed_box", "Box coordinates must be finite numbers.", raw
368
- )
369
- return
370
- left, top, right, bottom = cast(tuple[float, float, float, float], coordinates)
371
- if right < left or bottom < top:
372
- self._diagnose("invalid_box", "Box coordinates must be ordered.", raw)
373
- return
374
- self._add_record(
375
- SegmentationBoxRecord(
376
- order=len(self._records),
377
- object_id=object_id,
378
- frame=frame,
379
- left=left,
380
- top=top,
381
- right=right,
382
- bottom=bottom,
383
- )
384
- )
385
-
386
255
  def _accept_mask(
387
256
  self,
388
257
  object_id: str,
389
258
  frame: FrameReference | None,
390
- value: str,
259
+ mask: SegmentationMask,
391
260
  raw: str,
392
- bounds: SegmentationMaskBounds | None = None,
261
+ bounds: SegmentationMaskBounds,
393
262
  ) -> None:
394
- match = _MASK_PATTERN.fullmatch(_trim(value))
395
- if match is None:
396
- self._diagnose(
397
- "malformed_mask",
398
- "Mask records require encoding, size, and one complete payload.",
399
- raw,
400
- )
401
- return
402
- encoding = match.group(1)
403
- width_number = _javascript_number(match.group(2))
404
- height_number = _javascript_number(match.group(3))
405
- width = int(width_number) if width_number is not None else 0
406
- height = int(height_number) if height_number is not None else 0
407
- payload = match.group(4)
408
- if encoding not in ("one_bit", "lossless"):
409
- self._diagnose(
410
- "unsupported_mask_encoding",
411
- f"Unsupported complete mask encoding: {encoding}.",
412
- raw,
413
- )
414
- return
415
- area = float(width) * float(height)
416
- if (
417
- width <= 0
418
- or height <= 0
419
- or not math.isfinite(area)
420
- or not area.is_integer()
421
- or abs(area) > _MAXIMUM_SAFE_INTEGER
422
- ):
263
+ area = mask.width * mask.height
264
+ if mask.width <= 0 or mask.height <= 0 or area > _MAXIMUM_SAFE_INTEGER:
423
265
  self._diagnose(
424
266
  "invalid_mask_size", "Mask dimensions must be positive.", raw
425
267
  )
426
268
  return
427
- mask = SegmentationMask(
428
- encoding=encoding, payload=payload, width=width, height=height
429
- )
430
269
  try:
431
270
  decode_mask_to_raster(mask)
432
271
  except InvalidSegmentationMaskError as error:
@@ -48,11 +48,20 @@ class SegmentationMaskBounds:
48
48
  bottom: float
49
49
 
50
50
 
51
+ SegmentationMaskEncoding: TypeAlias = Literal["lossless", "one_bit"]
52
+ """The wire encodings of a mask payload: ``~`` selects lossless, ``!`` one_bit."""
53
+
54
+
51
55
  @dataclass(frozen=True, slots=True)
52
56
  class SegmentationMask:
53
- """One complete encoded SAM 3 segmentation mask."""
57
+ """One complete mask exactly as the SAM API emitted it.
58
+
59
+ ``payload`` is the base85 text after the encoding marker and is never
60
+ partial; ``width`` and ``height`` are the raster's own dimensions, not the
61
+ frame's.
62
+ """
54
63
 
55
- encoding: str
64
+ encoding: SegmentationMaskEncoding
56
65
  payload: str
57
66
  width: int
58
67
  height: int
@@ -76,18 +85,6 @@ class SegmentationTextRecord:
76
85
  text: str
77
86
 
78
87
 
79
- @dataclass(frozen=True, slots=True)
80
- class SegmentationPointRecord:
81
- """A point associated with one object and optional frame."""
82
-
83
- kind: Literal["point"] = field(default="point", init=False)
84
- order: int
85
- object_id: str
86
- x: float
87
- y: float
88
- frame: FrameReference | None = None
89
-
90
-
91
88
  @dataclass(frozen=True, slots=True)
92
89
  class SegmentationBoxRecord:
93
90
  """A half-open or explicitly supplied box in source-media coordinates."""
@@ -112,15 +109,12 @@ class SegmentationMaskRecord:
112
109
  identity: SegmentationMaskIdentity
113
110
  revision: int
114
111
  mask: SegmentationMask
112
+ bounds: SegmentationMaskBounds
115
113
  frame: FrameReference | None = None
116
- bounds: SegmentationMaskBounds | None = None
117
114
 
118
115
 
119
116
  SegmentationRecord: TypeAlias = (
120
- SegmentationTextRecord
121
- | SegmentationPointRecord
122
- | SegmentationBoxRecord
123
- | SegmentationMaskRecord
117
+ SegmentationTextRecord | SegmentationBoxRecord | SegmentationMaskRecord
124
118
  )
125
119
 
126
120
 
File without changes