[0.0.12] Segmentation phase 3: segs and mask chunks in .mcap files

- modules/Capture_File/segment_chunks.jai: a front "segs" chunk (text: engine, date, the image read
  and its size, the class list's hash, and each segment's id, score, area, box and class) and a back
  "mask" chunk (a segment id per pixel, run-length encoded with LEB128 lengths, longest side at most
  1024), keyed by model. No new file version: chunks came in version 3.
- mcap_store takes back chunks too, so a model's summary and mask are stored in one rewrite.
- Tests: the text round trip, the runs' edge cases (one segment over everything, none, runs across
  rows, multi-byte lengths, id 255), bad masks refused, and storing, replacing and erasing both
  chunks in a file with other chunks kept.
- segment-probe takes .mcap files (the image each is shown as; videos skipped) and with -store stores
  its segments in them. On 27 images a mask is 0.8 to 6.4 KB.
- The format README's "Segmentation chunks" section; the plan's phase 3 results.
This commit is contained in:
Musa Mahmood 2026-10-10 03:44:41 +01:00
parent cb21b73460
commit 8b6602119a
8 changed files with 425 additions and 13 deletions

View File

@ -29,7 +29,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check"; GIT_BRANCH :: "check";
GIT_HASH :: "-"; GIT_HASH :: "-";
VERSION :: "0.0.11"; VERSION :: "0.0.12";
BUILD_DATE :: "-"; BUILD_DATE :: "-";
DEBUG :: true; DEBUG :: true;
COMPILE_TESTS :: true; COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.0.11"; VERSION :: "0.0.12";
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
// builds never write over the ones you run by hand. // builds never write over the ones you run by hand.
build_directory := "bin"; build_directory := "bin";

View File

@ -197,3 +197,40 @@ the images. 768 values, 1.5 KB, for SigLIP 2 base.
`mcap_store` (`modules/Capture_File/module.jai`) stores an OCR section and chunks in one rewrite; `mcap_store` (`modules/Capture_File/module.jai`) stores an OCR section and chunks in one rewrite;
`label_chunks.jai` reads and writes these two. `label_chunks.jai` reads and writes these two.
## Segmentation chunks
A segmentation model (Mask2Former, `modules/Segment`) adds two chunks, keyed by its name, in one
rewrite (`mcap_store` with `front` and `back`), so a file has both or neither. Images only: a video's
frame is not segmented.
**`segs`**, in front: what is in the image, as text in the style above. Search and Stats read it.
```
@version 1
engine: "onnxruntime 1.30.0, CUDA"
date: 2026-10-12T09:30:00Z
image: rendered 4032 3024
classes: 5c1e9a0d77b2f413
segment: 1 0.981 0.2140 1210 380 900 2210 "person"
segment: 4 0.993 0.3105 0 0 4032 1100 "sky"
```
| Key | Value |
| --- | --- |
| `engine`, `date` | As in OCR text. |
| `image` | Which image was read (`rendered` or `original`) and its size, as shown (turned by its EXIF orientation). The boxes are in its pixels. |
| `classes` | A 64-bit FNV-1a hash of the model's class list: segments from another list are out of date. |
| `segment` | Its id in the mask, its score (0 to 1), its area (a fraction of the image), its box (x y width height), and its class as people search it ("wall", not COCO's "wall-other-merged"), quoted with `""` for a quote. Largest first. |
**`mask`**, at the back: which segment each pixel is in, for drawing. Binary:
| Field | Type | |
| --- | --- | --- |
| format | u8 | `1`: run-length encoded. |
| width, height | u16, u16 | The map's: the image's shape, its longest side at most 1024. |
| id bits | u8 | `8`. |
| runs | | To the end of the chunk, row by row, wrapping from one row to the next: a u8 segment id (0: none) and its length, LEB128 (7 bits a byte, low bits first, the top bit set on every byte but the last). The runs cover the map exactly. |
On 27 photos and frames, a mask was 0.8 to 6.4 KB and the `segs` text 200 to 400 bytes.
`mcap_erase` with both tags takes them out together. `segment_chunks.jai` reads and writes them.

View File

@ -492,11 +492,12 @@ mcap_read_chunks :: (d: *Deserializer) -> [] Mcap_Chunk, ok: bool, error: string
#scope_export #scope_export
// Stores what a scan adds to the .mcap at path in one rewrite: with ocr, that OCR section in place of // Stores what a scan adds to the .mcap at path in one rewrite: with ocr, that OCR section in place of
// any by the same model (the others kept); and each of front, in place of any front chunk with the // any by the same model (the others kept); and each of front and of back, in place of any chunk there
// same tag and key (every other chunk kept, known to this build or not). Upgrades the file to the // with the same tag and key (every other chunk kept, known to this build or not), so a model's front
// summary and back data are stored together or not at all. Upgrades the file to the
// current version. Rewritten beside itself and swapped in (mcap_write_streamed, which copies the // current version. Rewritten beside itself and swapped in (mcap_write_streamed, which copies the
// original across without holding it), so a failure leaves it as it was. // original across without holding it), so a failure leaves it as it was.
mcap_store :: (path: string, ocr: *Mcap_Ocr_Section = null, front: [] Mcap_Chunk = .[]) -> ok: bool, error: string { mcap_store :: (path: string, ocr: *Mcap_Ocr_Section = null, front: [] Mcap_Chunk = .[], back: [] Mcap_Chunk = .[]) -> ok: bool, error: string {
push_allocator(temp); push_allocator(temp);
m, original, parsed, parse_error := mcap_read_without_original(path); m, original, parsed, parse_error := mcap_read_without_original(path);
if !parsed return false, parse_error; if !parsed return false, parse_error;
@ -510,6 +511,7 @@ mcap_store :: (path: string, ocr: *Mcap_Ocr_Section = null, front: [] Mcap_Chunk
m.ocr = mcap_ocr_text(sections); m.ocr = mcap_ocr_text(sections);
} }
m.front_chunks = mcap_chunks_with(m.front_chunks, front); m.front_chunks = mcap_chunks_with(m.front_chunks, front);
m.back_chunks = mcap_chunks_with(m.back_chunks, back);
written, write_error := mcap_write_streamed(path, m, original); written, write_error := mcap_write_streamed(path, m, original);
return written, write_error; return written, write_error;
} }
@ -571,6 +573,7 @@ mcap_chunks_with :: (chunks: [] Mcap_Chunk, added: [] Mcap_Chunk) -> [] Mcap_Chu
#load "ocr_text.jai"; #load "ocr_text.jai";
#load "label_chunks.jai"; #load "label_chunks.jai";
#load "segment_chunks.jai";
#import "Basic"; #import "Basic";
#import "File"; #import "File";

View File

@ -0,0 +1,197 @@
// The chunks a segmentation model adds to a capture (no new file version: chunks came in version 3),
// each keyed by the model's name:
//
// "segs", in front: what is in the image, as text in the style of the Ex4 text formats
//
// @version 1
// engine: "onnxruntime 1.30.0, CUDA"
// date: 2026-10-12T09:30:00Z
// image: rendered 4032 3024
// classes: 5c1e9a0d77b2f413
// segment: 1 0.981 0.2140 1210 380 900 2210 "person"
//
// image: which image was read (rendered or original) and its size, which the boxes are in. classes:
// a hash of the model's class list, so segments from another list can be told apart. segment: its
// id in the mask, its score (0 to 1), its area (a fraction of the image), its box (x y width
// height), and its class, quoted with "" for a quote; largest first. A line that cannot be read is
// skipped, as are unknown keys.
//
// "mask", after the images: the segment id of every pixel, run-length encoded:
//
// u8 format 1
// u16 width the map's: the image's shape, its longest side at most 1024 (or so the model says)
// u16 height
// u8 id bits 8
// then runs, row by row, wrapping from one row to the next: a u8 id (0: no segment) and a length,
// LEB128 (7 bits a byte, low first, the top bit set on all but the last). The runs cover every
// pixel exactly.
MCAP_SEGMENTS_TAG :: "segs";
MCAP_MASK_TAG :: "mask";
MCAP_SEGMENTS_TEXT_VERSION :: 1;
MCAP_MASK_RLE :: 1;
Mcap_Segment :: struct {
id: u8; // its pixels' id in the mask
score: float; // the model's confidence in its class, 0 to 1
area: float; // a fraction of the image
x, y, width, height: s32; // its box, in the image's pixels
class: string; // as people see and search it: "person", "wall"
}
Mcap_Segments :: struct {
engine: string; // what ran it, such as "onnxruntime 1.30.0, CUDA"
date: string; // when, UTC: 2026-10-12T09:30:00Z
image: string; // which image was read: "rendered" or "original"
image_width, image_height: s32;
classes: string; // the class list's hash
segments: [..] Mcap_Segment; // largest first
}
Mcap_Mask :: struct {
width, height: s32;
map: [] u8; // a segment id per pixel, row by row; 0: none
}
// A segs chunk's text. Temp.
mcap_segments_text :: (s: Mcap_Segments) -> string {
builder: String_Builder;
builder.allocator = temp;
print_to_builder(*builder, "@version %\n", MCAP_SEGMENTS_TEXT_VERSION);
print_to_builder(*builder, "engine: %\n", mcap_quote(s.engine));
print_to_builder(*builder, "date: %\n", s.date);
print_to_builder(*builder, "image: % % %\n", s.image, s.image_width, s.image_height);
print_to_builder(*builder, "classes: %\n", s.classes);
for s.segments {
print_to_builder(*builder, "segment: % % % % % % % %\n", it.id, formatFloat(it.score, trailing_width = 3, zero_removal = .NO),
formatFloat(it.area, trailing_width = 4, zero_removal = .NO), it.x, it.y, it.width, it.height, mcap_quote(it.class));
}
return builder_to_string(*builder,, temp);
}
// A segs chunk's segments; strings and arrays from the context allocator.
mcap_segments_parse :: (text: string) -> Mcap_Segments, ok: bool, error: string {
result: Mcap_Segments;
version_seen := false;
for raw: split(text, "\n",, temp) {
line := trim(raw);
if !line.count || line[0] == #char "#" continue;
if begins_with(line, "@version") {
rest := trim(advance(line, 8));
version, ok := string_to_int(rest);
if !ok || version < 1 || version > MCAP_SEGMENTS_TEXT_VERSION return result, false, tprint("its segments are version %, but this build reads up to %", rest, MCAP_SEGMENTS_TEXT_VERSION);
version_seen = true;
continue;
}
if !version_seen return result, false, "its segments do not start with @version";
colon := find_index_from_left(line, #char ":");
if colon < 0 continue;
key := trim(slice(line, 0, colon));
value := trim(advance(line, colon + 1));
if key == {
case "engine";
quoted, _, ok := mcap_read_quoted(value);
if ok result.engine = copy_string(quoted);
case "date"; result.date = copy_string(value);
case "classes"; result.classes = copy_string(value);
case "image";
parts := split(value, " ",, temp);
if parts.count == 3 {
w, w_ok := string_to_int(parts[1], T = s32);
h, h_ok := string_to_int(parts[2], T = s32);
if w_ok && h_ok { result.image = copy_string(parts[0]); result.image_width, result.image_height = w, h; }
}
case "segment";
// Seven numbers, then the quoted class.
quote := find_index_from_left(value, #char "\"");
if quote < 0 continue;
numbers := split(trim(slice(value, 0, quote)), " ",, temp);
if numbers.count != 7 continue;
id, id_ok := string_to_int(numbers[0]);
score, score_ok := string_to_float(numbers[1]);
area, area_ok := string_to_float(numbers[2]);
box: [4] s32;
box_ok := true;
for 0..3 {
n, ok := string_to_int(numbers[3 + it], T = s32);
box[it] = n;
box_ok &&= ok;
}
class, after, class_ok := mcap_read_quoted(advance(value, quote));
if !id_ok || id < 1 || id > 255 || !score_ok || !area_ok || !box_ok || !class_ok || trim(after).count continue;
array_add(*result.segments, .{cast(u8) id, score, area, box[0], box[1], box[2], box[3], copy_string(class)});
}
}
return result, true, "";
}
// A mask chunk's data for a map of width x height segment ids. Temp.
mcap_mask_bytes :: (width: s32, height: s32, map: [] u8) -> [] u8 {
assert(map.count == cast(s64) width * height && width > 0 && height > 0 && width <= 0xFFFF && height <= 0xFFFF);
out: [..] u8;
out.allocator = temp;
array_reserve(*out, 64 + map.count / 16);
array_add(*out, MCAP_MASK_RLE);
array_add(*out, cast(u8) (width & 0xFF), cast(u8) (width >> 8), cast(u8) (height & 0xFF), cast(u8) (height >> 8));
array_add(*out, 8);
at := 0;
while at < map.count {
id := map[at];
run := 1;
while at + run < map.count && map[at + run] == id run += 1;
array_add(*out, id);
length := cast(u64) run;
while true { // LEB128
byte := cast(u8) (length & 0x7F);
length >>= 7;
if length { array_add(*out, byte | 0x80); }
else { array_add(*out, byte); break; }
}
at += run;
}
return out;
}
// A mask chunk's map; the map from the context allocator.
mcap_mask_parse :: (data: [] u8) -> Mcap_Mask, ok: bool, error: string {
mask: Mcap_Mask;
if data.count < 6 return mask, false, "its mask is cut short";
if data[0] != MCAP_MASK_RLE return mask, false, tprint("its mask is in format %, which this build does not read", data[0]);
mask.width = (cast(s32) data[1]) | ((cast(s32) data[2]) << 8);
mask.height = (cast(s32) data[3]) | ((cast(s32) data[4]) << 8);
if data[5] != 8 return mask, false, tprint("its mask has %-bit ids, which this build does not read", data[5]);
if !mask.width || !mask.height return mask, false, "its mask has no size";
total := cast(s64) mask.width * mask.height;
mask.map = NewArray(total, u8, initialized = false);
filled := 0;
at := 6;
while at < data.count {
id := data[at];
at += 1;
length: u64;
shift: u32;
while true {
if at >= data.count || shift > 35 { array_free(mask.map); mask.map = .[]; return mask, false, "its mask's runs are cut short"; }
byte := data[at];
at += 1;
length |= (cast(u64) (byte & 0x7F)) << shift;
shift += 7;
if !(byte & 0x80) break;
}
if !length || cast(s64) length > total - filled { array_free(mask.map); mask.map = .[]; return mask, false, "its mask's runs do not fit its size"; }
memset(mask.map.data + filled, id, cast(s64) length);
filled += cast(s64) length;
}
if filled != total { array_free(mask.map); mask.map = .[]; return mask, false, "its mask's runs do not cover it"; }
return mask, true, "";
}
// The chunks for segments by model, ready to store together with mcap_store (segs in front, mask in
// back). Their data is temp.
mcap_segments_chunk :: (model: string, segments: Mcap_Segments) -> Mcap_Chunk {
return .{tag = mcap_tag(MCAP_SEGMENTS_TAG), key = model, data = cast([] u8) mcap_segments_text(segments)};
}
mcap_mask_chunk :: (model: string, width: s32, height: s32, map: [] u8) -> Mcap_Chunk {
return .{tag = mcap_tag(MCAP_MASK_TAG), key = model, data = mcap_mask_bytes(width, height, map)};
}

View File

@ -78,7 +78,7 @@
<li><span class="tag decided">Decided</span> <strong>Images only</strong>, and only in the <strong>library folders you opt in</strong>. The setting is per folder, kept in a file in the library root, so it travels with the library.</li> <li><span class="tag decided">Decided</span> <strong>Images only</strong>, and only in the <strong>library folders you opt in</strong>. The setting is per folder, kept in a file in the library root, so it travels with the library.</li>
<li><span class="tag decided">Decided</span> <strong>No .mcap version bump.</strong> Results go in two new chunk kinds: a small <code>segs</code> summary at the front for search, and a <code>mask</code> at the back for drawing.</li> <li><span class="tag decided">Decided</span> <strong>No .mcap version bump.</strong> Results go in two new chunk kinds: a small <code>segs</code> summary at the front for search, and a <code>mask</code> at the back for drawing.</li>
<li>It is built in six phases. The first proves the ONNX export matches PyTorch before anything else depends on it.</li> <li>It is built in six phases. The first proves the ONNX export matches PyTorch before anything else depends on it.</li>
<li><span class="tag decided">Progress</span> <strong>Phases 1 and 2 are done</strong> (2026-10-10). The model is exported and checked, and <code>modules/Segment</code> runs it in mcap at about 57 ms per image on the RTX 5080. Next is phase 3: storing results in .mcap files.</li> <li><span class="tag decided">Progress</span> <strong>Phases 1 to 3 are done</strong> (2026-10-10). The model is exported and checked, <code>modules/Segment</code> runs it in mcap at about 57 ms per image on the RTX 5080, and results are stored in .mcap files at about 5 KB per image. Next is phase 4: the per-folder opt-in and the scan.</li>
<li><span class="tag later">Later</span> Video, by treating sampled frames as images, is shelved.</li> <li><span class="tag later">Later</span> Video, by treating sampled frames as images, is shelved.</li>
</ul> </ul>
</div> </div>
@ -349,7 +349,7 @@ then runs: (id, length) pairs over the whole map, row by row, wrapping a
</div> </div>
<div class="phase"> <div class="phase">
<h3>Phase 3: Chunks and storing</h3> <h3>Phase 3: Chunks and storing <span class="tag decided">Done</span></h3>
<p><code>segment_chunks.jai</code> (text, RLE), <code>mcap_store</code> with back chunks, the README section, and tests:</p> <p><code>segment_chunks.jai</code> (text, RLE), <code>mcap_store</code> with back chunks, the README section, and tests:</p>
<ul> <ul>
<li>Round trip.</li> <li>Round trip.</li>
@ -357,6 +357,13 @@ then runs: (id, length) pairs over the whole map, row by row, wrapping a
<li>Storing <code>segs</code> and <code>mask</code> together leaves the original and every other chunk as they were.</li> <li>Storing <code>segs</code> and <code>mask</code> together leaves the original and every other chunk as they were.</li>
</ul> </ul>
<p><strong>Done when:</strong> the tests pass and a probe run can store its results into a scratch library's files.</p> <p><strong>Done when:</strong> the tests pass and a probe run can store its results into a scratch library's files.</p>
<div class="note"><strong>Result (2026-10-10).</strong>
<ul>
<li><strong>Tests:</strong> 24 new checks pass, in the import suite's "segment chunks" group.</li>
<li><strong>A real library:</strong> on a scratch library of 25 photos and frames plus 6 imported videos and GIFs, <code>segment-probe -store</code> stored <code>segs</code> and <code>mask</code> in all 27 images, and skipped the 4 videos. A second run reads every one back.</li>
<li><strong>Size:</strong> a mask is 0.8 to 6.4 KB and the <code>segs</code> text 200 to 400 bytes, so the whole library grew by 141 KB. Open question 3 is answered: 1024 on the long side is cheap.</li>
<li><strong>For phase 4:</strong> the conversion from a <code>Segment_Result</code> to the two chunks (<code>segment_chunks</code>) lives in <code>tools/segment_probe.jai</code> for now. Phase 4 moves it into the app's scan.</li>
</ul></div>
</div> </div>
<div class="phase"> <div class="phase">
@ -389,7 +396,7 @@ then runs: (id, length) pairs over the whole map, row by row, wrapping a
<ol> <ol>
<li><strong>COCO panoptic or ADE20K?</strong> COCO counts things (people, pets, cars); ADE20K knows more indoor objects and scene parts, but has no instances. The default is COCO. Revisit after phase 1 by trying both on a sample of your photo libraries.</li> <li><strong>COCO panoptic or ADE20K?</strong> COCO counts things (people, pets, cars); ADE20K knows more indoor objects and scene parts, but has no instances. The default is COCO. Revisit after phase 1 by trying both on a sample of your photo libraries.</li>
<li><strong>Where to host the exported .onnx</strong> for <code>fetch_models.jai</code>: a private Hugging Face repo, a release asset, or committing it as the FFmpeg DLLs are. At about 400 MB for Swin-B, committing it is probably too much.</li> <li><strong>Where to host the exported .onnx</strong> for <code>fetch_models.jai</code>: a private Hugging Face repo, a release asset, or committing it as the FFmpeg DLLs are. At about 400 MB for Swin-B, committing it is probably too much.</li>
<li><strong>The mask resolution cap.</strong> 1024 on the long side is a guess. Phase 3 should measure chunk sizes on real photos at 512, 1024 and 2048.</li> <li><strong>The mask resolution cap.</strong> <span class="tag decided">Answered</span> 1024 on the long side costs 0.8 to 6.4 KB per photo (phase 3), so there is no reason to go lower.</li>
<li><strong>Snagit and screenshot folders.</strong> They stay off by default, and COCO classes mean little on UI. Turning one on is allowed, but the Segmentation window could warn that most of the folder is screenshots, judged from <code>Format</code> and <code>AppName</code>.</li> <li><strong>Snagit and screenshot folders.</strong> They stay off by default, and COCO classes mean little on UI. Turning one on is allowed, but the Segmentation window could warn that most of the folder is screenshots, judged from <code>Format</code> and <code>AppName</code>.</li>
<li><strong>Re-scanning on a model change.</strong> A new model name means a new key, so old results stay until erased. Should the scan offer to erase other models' masks to save space? Probably yes, as an explicit button rather than automatically.</li> <li><strong>Re-scanning on a model change.</strong> A new model name means a new key, so old results stay until erased. Should the scan offer to erase other models' masks to save space? Probably yes, as an explicit button rather than automatically.</li>
</ol> </ol>

View File

@ -362,6 +362,118 @@ mcap_tests :: () {
for string.[source, path, image_path, extracted] file_delete(it); for string.[source, path, image_path, extracted] file_delete(it);
} }
// The segmentation chunks (modules/Capture_File/segment_chunks.jai): segs text, the mask's run-length
// encoding, and storing both in one rewrite.
segment_chunk_tests :: () {
group("segment chunks");
s: Mcap_Segments;
s.engine = "onnxruntime 1.30.0, CUDA";
s.date = "2026-10-12T09:30:00Z";
s.image = "rendered";
s.image_width, s.image_height = 4032, 3024;
s.classes = "5c1e9a0d77b2f413";
s.segments.allocator = temp;
array_add(*s.segments, .{1, 0.981, 0.2140, 1210, 380, 900, 2210, "person"});
array_add(*s.segments, .{2, 0.5, 0.0001, 0, 0, 1, 1, "a \"quoted\" class"});
text := mcap_segments_text(s);
back, ok, error := mcap_segments_parse(text,, temp);
expect(ok && back.engine == s.engine && back.date == s.date && back.image == "rendered" && back.image_width == 4032 && back.image_height == 3024 && back.classes == s.classes, "segs header round trip: %", error);
expect(back.segments.count == 2, "both segments back: %", back.segments.count);
if back.segments.count == 2 {
a := back.segments[0];
expect(a.id == 1 && abs(a.score - 0.981) < 0.0005 && abs(a.area - 0.214) < 0.00005 && a.x == 1210 && a.y == 380 && a.width == 900 && a.height == 2210 && a.class == "person", "a segment's values: %", a);
expect(back.segments[1].class == "a \"quoted\" class", "a class with quotes: %", back.segments[1].class);
}
_, newer_ok := mcap_segments_parse("@version 2\nsegment: 1 0.9 0.1 0 0 1 1 \"x\"\n",, temp);
expect(!newer_ok, "a later version is refused");
junk, junk_ok := mcap_segments_parse("@version 1\nsegment: 1 0.9 \"too few numbers\"\nsegment: 0 0.9 0.1 0 0 1 1 \"id 0\"\nsegment: 3 0.9 0.1 0 0 1 1 \"fine\"\nfuture: kept quiet\n",, temp);
expect(junk_ok && junk.segments.count == 1 && junk.segments[0].class == "fine", "lines that cannot be read are skipped");
// Masks: the edge cases of the runs.
round_trip :: (w: s32, h: s32, map: [] u8, what: string, loc := #caller_location) -> s64 {
bytes := mcap_mask_bytes(w, h, map);
mask, ok, error := mcap_mask_parse(bytes,, temp);
expect(ok && mask.width == w && mask.height == h && cast(string) mask.map == cast(string) map, "%: %", what, error, loc = loc);
return bytes.count;
}
W, H :: 1024, 768;
everything := NewArray(W * H, u8,, temp);
for * everything it.* = 7;
size := round_trip(W, H, everything, "one segment over everything");
expect(size == 6 + 1 + 3, "one run of 786432 is 10 bytes, its length in 3: %", size);
nothing := NewArray(W * H, u8,, temp);
round_trip(W, H, nothing, "no segments");
stripes := NewArray(W * H, u8,, temp); // runs that cross rows: three rows and a bit each
for * stripes it.* = cast(u8) ((it_index / (W * 3 + 5)) % 256);
round_trip(W, H, stripes, "runs wrapping across rows, every id to 255");
noise := NewArray(37 * 11, u8,, temp); // runs of 1, and a map with odd sides
for * noise it.* = cast,trunc(u8) (it_index * 2654435761 >> 7);
round_trip(37, 11, noise, "runs of one");
one := u8.[255];
round_trip(1, 1, one, "one pixel, the largest id");
long := NewArray(127 + 128 + 16384 + 1, u8,, temp);
for * long it.* = ifx it_index < 127 then cast(u8) 1 else ifx it_index < 255 then cast(u8) 2 else ifx it_index < 255 + 16384 then cast(u8) 3 else cast(u8) 4;
round_trip(cast(s32) long.count, 1, long, "runs of 127, 128 and 16384");
// Bad masks are refused, not misread.
good := mcap_mask_bytes(37, 11, noise);
bad := array_copy(good,, temp);
bad[0] = 2;
_, ok = mcap_mask_parse(bad,, temp);
expect(!ok, "an unknown format");
_, ok = mcap_mask_parse(array_view(good, 0, good.count - 1),, temp);
expect(!ok, "cut short");
short := array_copy(good,, temp);
short[1] = 38; // a wider map than the runs cover
_, ok = mcap_mask_parse(short,, temp);
expect(!ok, "runs that do not cover it");
over := array_copy(good,, temp);
over[1] = 36; // narrower: the runs overflow it
_, ok = mcap_mask_parse(over,, temp);
expect(!ok, "runs that overflow it");
// Stored together with mcap_store, in one rewrite, and erased together.
#if OS == .WINDOWS folder := tprint("%/mcap-tests", windows_expand_environment("%TEMP%"));
else folder := "/tmp/mcap-tests";
make_directory_if_it_does_not_exist(folder, recursive = true);
path := tprint("%/segments.mcap", folder);
m: Mcap;
m.text = "[[file \"metadata.json\"]]\nWindowName: \"photo.jpg\"\n";
m.preview = .{width = 2, height = 1, png = cast([] u8) "preview"};
m.original_name = "photo.jpg";
m.original = cast([] u8) "the photo's bytes, as they were";
m.front_chunks = .[.{tag = mcap_tag("labl"), key = "siglip", data = cast([] u8) "labels"}];
m.back_chunks = .[.{tag = mcap_tag("zzzz"), key = "later", data = cast([] u8) "a chunk this build does not know"}];
bytes := mcap_serialize(m,, temp);
mcap_write(path, bytes);
MODEL :: "mask2former-swin-base-coco-panoptic";
map := NewArray(37 * 11, u8,, temp);
for * map it.* = cast(u8) (it_index % 3);
stored, store_error := mcap_store(path, front = .[mcap_segments_chunk(MODEL, s)], back = .[mcap_mask_chunk(MODEL, 37, 11, map)]);
after, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
expect(stored && parsed, "stored: %", store_error);
segs_chunk := mcap_find_chunk(after.front_chunks, mcap_tag(MCAP_SEGMENTS_TAG), MODEL);
mask_chunk := mcap_find_chunk(after.back_chunks, mcap_tag(MCAP_MASK_TAG), MODEL);
expect(segs_chunk && mask_chunk, "segs in front and mask in back");
expect(cast(string) after.original == cast(string) m.original && after.front_chunks.count == 2 && after.back_chunks.count == 2, "the original and every other chunk kept");
expect(mcap_find_chunk(after.back_chunks, mcap_tag("zzzz"), "later") != null, "a chunk this build does not know is kept");
if mask_chunk {
mask, mask_ok := mcap_mask_parse(mask_chunk.data,, temp);
expect(mask_ok && cast(string) mask.map == cast(string) map, "the stored mask reads back");
}
// Again: replaced, not added.
mcap_store(path, front = .[mcap_segments_chunk(MODEL, s)], back = .[mcap_mask_chunk(MODEL, 37, 11, map)]);
again, _ := mcap_parse(cast([] u8) read_entire_file(path,, temp));
expect(again.front_chunks.count == 2 && again.back_chunks.count == 2, "storing again replaces them");
erased, erase_error, changed := mcap_erase(path, tags = .[MCAP_SEGMENTS_TAG, MCAP_MASK_TAG]);
gone, _ := mcap_parse(cast([] u8) read_entire_file(path,, temp));
expect(erased && changed && gone.front_chunks.count == 1 && gone.back_chunks.count == 1, "erased together, the rest kept: %", erase_error);
file_delete(path);
}
// What mcap_chunks_size counts, here where it cannot be seen. // What mcap_chunks_size counts, here where it cannot be seen.
mcap_chunks_size_for_test :: (chunks: [] Mcap_Chunk) -> s64 { mcap_chunks_size_for_test :: (chunks: [] Mcap_Chunk) -> s64 {
size: s64 = 4; size: s64 = 4;
@ -382,5 +494,6 @@ run_import_tests :: (builder: *String_Builder = null) -> bool {
folder_tests(); folder_tests();
video_tests(); video_tests();
mcap_tests(); mcap_tests();
segment_chunk_tests();
return report(); return report();
} }

View File

@ -1,9 +1,10 @@
// segment-probe: what modules/Segment makes of image files. For each FILE, its segments (class, score, // segment-probe: what modules/Segment makes of image files. For each FILE, its segments (class, score,
// area); with -out FOLDER, the segments drawn over the image as a PNG, to look at; with -check FOLDER, // area); with -out FOLDER, the segments drawn over the image as a PNG, to look at; with -check FOLDER,
// how they compare with PyTorch's own segmentation of the same image (NAME.ref and NAME.ref.txt, from // how they compare with PyTorch's own segmentation of the same image (NAME.ref and NAME.ref.txt, from
// tools/export_mask2former.py --reference). // tools/export_mask2former.py --reference). A FILE may be an .mcap: the image it is shown as is read
// (a video is skipped), and with -store its segments are stored in it (segs and mask chunks).
// //
// Build: jai first.jai - segment_probe Run: bin/segment-probe FILE... [-out FOLDER] [-check FOLDER] [-cpu] [-dump-input FOLDER] // Build: jai first.jai - segment_probe Run: bin/segment-probe FILE... [-out FOLDER] [-check FOLDER] [-store] [-cpu] [-dump-input FOLDER]
main :: () { main :: () {
args := get_command_line_arguments(); args := get_command_line_arguments();
@ -12,6 +13,7 @@ main :: () {
models_folder := tprint("%segment-models", path_strip_filename(get_path_of_running_executable())); models_folder := tprint("%segment-models", path_strip_filename(get_path_of_running_executable()));
model_name := "mask2former-swin-base-coco-panoptic"; model_name := "mask2former-swin-base-coco-panoptic";
prefer_gpu := true; prefer_gpu := true;
store := false;
i := 1; i := 1;
while i < args.count { while i < args.count {
if args[i] == "-out" && i + 1 < args.count { out_folder = args[i + 1]; i += 2; continue; } if args[i] == "-out" && i + 1 < args.count { out_folder = args[i + 1]; i += 2; continue; }
@ -19,6 +21,7 @@ main :: () {
if args[i] == "-models" && i + 1 < args.count { models_folder = args[i + 1]; i += 2; continue; } if args[i] == "-models" && i + 1 < args.count { models_folder = args[i + 1]; i += 2; continue; }
if args[i] == "-model" && i + 1 < args.count { model_name = args[i + 1]; i += 2; continue; } if args[i] == "-model" && i + 1 < args.count { model_name = args[i + 1]; i += 2; continue; }
if args[i] == "-cpu" { prefer_gpu = false; i += 1; continue; } if args[i] == "-cpu" { prefer_gpu = false; i += 1; continue; }
if args[i] == "-store" { store = true; i += 1; continue; }
if args[i] == "-dump-input" && i + 1 < args.count { dump_folder = args[i + 1]; i += 2; continue; } if args[i] == "-dump-input" && i + 1 < args.count { dump_folder = args[i + 1]; i += 2; continue; }
array_add(*files, args[i]); array_add(*files, args[i]);
i += 1; i += 1;
@ -47,15 +50,35 @@ main :: () {
images: [..] Image; images: [..] Image;
names: [..] string; names: [..] string;
whiches: [..] string; // for an .mcap, which of its images: "rendered" or "original"
t := current_time_monotonic(); t := current_time_monotonic();
for path: group { for path: group {
data, read_ok := read_entire_file(path, log_errors = false); bytes: [] u8;
if !read_ok { print("%: cannot be read\n", path); failures += 1; continue; } which := "original";
image, decoded, decode_error := image_decode(cast([] u8) data); // turned by its EXIF orientation, as mcap shows it data: string;
if ends_with_nocase(path, ".mcap") {
// The image the capture is shown as; never a video's.
m, shown, shown_which, read_ok, read_error := mcap_read_shown_image(path,, temp);
if !read_ok { print("%: %\n", path, read_error); failures += 1; continue; }
if contains(m.text, "VideoCodec:") { print("%: a video, skipped (segmentation is for images)\n", path_filename(path)); continue; }
stored := mcap_find_chunk(m.front_chunks, mcap_tag(MCAP_SEGMENTS_TAG), model_name);
if stored {
s, s_ok := mcap_segments_parse(cast(string) stored.data,, temp);
if s_ok print("%: already holds % segments by % (%, %)\n", path_filename(path), s.segments.count, model_name, s.date, s.engine);
}
bytes, which = shown, shown_which;
} else {
read_ok: bool;
data, read_ok = read_entire_file(path, log_errors = false);
if !read_ok { print("%: cannot be read\n", path); failures += 1; continue; }
bytes = cast([] u8) data;
}
image, decoded, decode_error := image_decode(bytes); // turned by its EXIF orientation, as mcap shows it
free(data); free(data);
if !decoded { print("%: cannot decode: %\n", path, decode_error); failures += 1; continue; } if !decoded { print("%: cannot decode: %\n", path, decode_error); failures += 1; continue; }
array_add(*images, image); array_add(*images, image);
array_add(*names, path); array_add(*names, path);
array_add(*whiches, which);
} }
decode_ms += ms(t); decode_ms += ms(t);
if !images.count continue; if !images.count continue;
@ -85,6 +108,12 @@ main :: () {
for result.segments print(" % % %\n", formatFloat(it.score, trailing_width = 3), pad(engine.classes[it.class].name, 22), percent(it.pixels / map_area)); for result.segments print(" % % %\n", formatFloat(it.score, trailing_width = 3), pad(engine.classes[it.class].name, 22), percent(it.pixels / map_area));
if out_folder write_overlay(engine, image, result, tprint("%/%.segments.png", out_folder, path_strip_extension(path_filename(path)))); if out_folder write_overlay(engine, image, result, tprint("%/%.segments.png", out_folder, path_strip_extension(path_filename(path))));
if store && ends_with_nocase(path, ".mcap") {
segs, mask := segment_chunks(engine, result, model_name, whiches[n], image.width, image.height);
stored, store_error := mcap_store(path, front = .[segs], back = .[mask]);
if stored print(" stored: % bytes of segs, % bytes of mask\n", segs.data.count, mask.data.count);
else { print(" cannot store: %\n", store_error); failures += 1; }
}
if check_folder { if check_folder {
c := compare(engine, result, tprint("%/%.ref", check_folder, path_filename(path)), tprint("%/%.ref.txt", check_folder, path_filename(path))); c := compare(engine, result, tprint("%/%.ref", check_folder, path_filename(path)), tprint("%/%.ref.txt", check_folder, path_filename(path)));
if !c.ok { print(" check: %\n", c.error); failures += 1; continue; } if !c.ok { print(" check: %\n", c.error); failures += 1; continue; }
@ -115,6 +144,29 @@ main :: () {
exit(xx ifx failures then 1 else 0); exit(xx ifx failures then 1 else 0);
} }
// A result as the chunks an .mcap stores (modules/Capture_File/segment_chunks.jai), for an image of
// image_width x image_height: boxes in its pixels, segments largest first. Temp.
segment_chunks :: (engine: *Segment_Engine, result: Segment_Result, model: string, which: string, image_width: s32, image_height: s32) -> segs: Mcap_Chunk, mask: Mcap_Chunk {
s: Mcap_Segments;
s.engine = tprint("onnxruntime %, %", ort.version, ifx engine.provider == .CUDA then "CUDA" else "CPU");
s.date = mcap_ocr_now();
s.image = which;
s.image_width, s.image_height = image_width, image_height;
s.classes = tprint("%", formatInt(engine.classes_hash, base = 16, minimum_digits = 16));
s.segments.allocator = temp;
sx := cast(float64) image_width / result.width;
sy := cast(float64) image_height / result.height;
map_area := cast(float64) result.width * result.height;
for result.segments {
x := cast(s32) (it.x0 * sx);
y := cast(s32) (it.y0 * sy);
array_add(*s.segments, .{id = it.id, score = it.score, area = cast(float) (it.pixels / map_area), x = x, y = y,
width = cast(s32) (it.x1 * sx + 0.5) - x, height = cast(s32) (it.y1 * sy + 0.5) - y, class = engine.classes[it.class].name});
}
quick_sort(s.segments, (a: Mcap_Segment, b: Mcap_Segment) -> s64 { return ifx a.area > b.area then -1 else ifx a.area < b.area then 1 else 0; });
return mcap_segments_chunk(model, s), mcap_mask_chunk(model, result.width, result.height, result.map);
}
CONFIDENT :: 0.6; // a segment PyTorch scores this or more must be matched CONFIDENT :: 0.6; // a segment PyTorch scores this or more must be matched
Comparison :: struct { Comparison :: struct {
@ -233,4 +285,7 @@ pad :: (text: string, width: s64) -> string {
#import "System"; #import "System";
#import "Image_Decode"; #import "Image_Decode";
#import "Segment"; #import "Segment";
#import "Capture_File";
#import "onnxruntime";
#import "Sort";
#import "stb_image_write"; #import "stb_image_write";