mcap/tools/segment_probe.jai
Musa Mahmood 3e6e733a9d [0.0.13] Segmentation phase 4: per-folder opt-in, the segmentation scan, -segment
- .library-folders in the library (src/app/library_folders.jai): each library folder's options,
  starting with segment: true. Every folder starts off; unknown keys are kept when it is rewritten.
- The Segmentation window (Segments, above the grid): each folder's Segment checkbox, how many of
  its images are segmented, and Erase per folder; a button to segment the folders set to segment.
- The segmentation scan (src/app/segment_ui.jai) runs on the OCR thread as its own task, with the
  OCR scan's progress, pause and cancel: images only (never videos), not yet segmented, in folders
  set to segment. The engine loads with the first scan (2.4 GB of GPU memory). Each image's segs and
  mask are stored in one rewrite and its catalog entry loaded again.
- Importing into a folder set to segment segments the new images; the Import window's checkbox is
  the folder's setting.
- -segment segments and exits; -erase segments, and -erase all now includes segments.
- The catalog reads each capture's segs (class list hash, segment count). The exit overlay names the
  segmentation scan and model. segment_mcap_chunks moved into modules/Segment, shared with
  segment-probe. The quoting helpers of Capture_File's text formats are public.
- Tests: .library-folders read and written back (quoted names, unknown keys, versions).
2026-10-10 04:13:03 +01:00

267 lines
13 KiB
Plaintext

// 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,
// how they compare with PyTorch's own segmentation of the same image (NAME.ref and NAME.ref.txt, from
// 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] [-store] [-cpu] [-dump-input FOLDER]
main :: () {
args := get_command_line_arguments();
files: [..] string;
out_folder, check_folder, dump_folder: string;
models_folder := tprint("%segment-models", path_strip_filename(get_path_of_running_executable()));
model_name := "mask2former-swin-base-coco-panoptic";
prefer_gpu := true;
store := false;
i := 1;
while i < args.count {
if args[i] == "-out" && i + 1 < args.count { out_folder = args[i + 1]; i += 2; continue; }
if args[i] == "-check" && i + 1 < args.count { check_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] == "-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; }
array_add(*files, args[i]);
i += 1;
}
if !files.count { print("Usage: segment-probe FILE... [-out FOLDER] [-check FOLDER] [-cpu] [-model NAME] [-models FOLDER]\n"); exit(2); }
if out_folder make_directory_if_it_does_not_exist(out_folder, recursive = true);
start := current_time_monotonic();
engine, ok, error := segment_create(models_folder, model_name, prefer_gpu);
if !ok { print("Cannot start %: %\n", model_name, error); exit(1); }
defer segment_destroy(engine);
print("% on %, % classes (made in % ms)%\n", model_name, engine.provider, engine.classes.count, ms(start),
ifx engine.gpu_explanation then tprint(": %", engine.gpu_explanation) else "");
failures := 0;
checked, matched, extra: s64;
worst_iou: float64 = 1;
agree, total_pixels, missed_confident: s64;
model_ms, decode_ms: float64;
done := 0;
GROUP :: 8; // images decoded and segmented together
position := 0;
while position < files.count {
group := array_view(files, position, min(GROUP, files.count - position));
position += group.count;
images: [..] Image;
names: [..] string;
whiches: [..] string; // for an .mcap, which of its images: "rendered" or "original"
t := current_time_monotonic();
for path: group {
bytes: [] u8;
which := "original";
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);
if !decoded { print("%: cannot decode: %\n", path, decode_error); failures += 1; continue; }
array_add(*images, image);
array_add(*names, path);
array_add(*whiches, which);
}
decode_ms += ms(t);
if !images.count continue;
inputs := NewArray(images.count, Segment_Image,, temp);
for images inputs[it_index] = .{it.pixels, it.width, it.height, it.width * 4};
if dump_folder { // the model's input, as raw floats, to compare with the checkpoint's processor
make_directory_if_it_does_not_exist(dump_folder, recursive = true);
size := engine.model.image_size;
floats := NewArray(3 * size * size, float,, temp);
for inputs {
segment_prepare(engine, it, floats.data);
write_entire_file(tprint("%/%.input", dump_folder, path_filename(names[it_index])), floats.data, floats.count * size_of(float));
}
}
t = current_time_monotonic();
results := segment_images(engine, inputs,, temp);
model_ms += ms(t);
for result, n: results {
path := names[n];
image := images[n];
done += 1;
if result.failed { print("%: failed: %\n", path, result.error); failures += 1; continue; }
print("% (% x %, map % x %): % segments\n", path_filename(path), image.width, image.height, result.width, result.height, result.segments.count);
map_area := cast(float64) result.width * result.height;
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 store && ends_with_nocase(path, ".mcap") {
segs, mask := segment_mcap_chunks(engine, result, 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 {
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; }
print(" check: pixels agreeing on their class %; segments: % of % matched, worst IoU %%\n", percent(c.agree / cast(float64) c.pixels),
c.matched, c.checked, formatFloat(c.worst, trailing_width = 4), ifx c.extra then tprint(", % extra", c.extra) else "");
checked += c.checked; matched += c.matched; extra += c.extra; worst_iou = min(worst_iou, c.worst);
agree += c.agree; total_pixels += c.pixels; missed_confident += c.missed_confident;
}
}
for * images image_free(it);
reset_temporary_storage();
}
print("\n% images: decoding % ms each, segmenting % ms each (model and post-processing, batch %)\n", done,
formatFloat(decode_ms / max(done, 1), trailing_width = 0), formatFloat(model_ms / max(done, 1), trailing_width = 0),
ifx engine.provider == .CUDA then engine.model.batch_gpu else engine.model.batch_cpu);
per := max(engine.stats.images, 1) * 1000.0;
print(" of which: preparing % ms, the model % ms, making segments % ms, per image\n", formatFloat(engine.stats.prepare_us / per, trailing_width = 1),
formatFloat(engine.stats.model_us / per, trailing_width = 1), formatFloat(engine.stats.post_us / per, trailing_width = 1));
if check_folder && total_pixels {
print("check: pixels agreeing on their class %; segments: % of % matched, % extra, worst IoU %\n",
percent(agree / cast(float64) total_pixels), matched, checked, extra, formatFloat(worst_iou, trailing_width = 4));
// Pass: nearly every pixel the same class, and every segment PyTorch is fairly sure of found. A
// decoder or a rounding a step apart can tip a segment near a threshold either way.
print("check: % confident segments (score at least %) missed\n", missed_confident, CONFIDENT);
if cast(float64) agree < total_pixels * 0.995 || missed_confident failures += 1;
}
exit(xx ifx failures then 1 else 0);
}
CONFIDENT :: 0.6; // a segment PyTorch scores this or more must be matched
Comparison :: struct {
ok: bool;
error: string;
pixels, agree: s64; // pixels, and those given the same class (or none) by both
checked, matched, extra: s64; // PyTorch's segments, those with one of ours of the same class at IoU >= 0.5, and ours left over
missed_confident: s64; // PyTorch's segments scoring CONFIDENT or more that ours do not match
worst: float64 = 1; // the lowest IoU of a PyTorch segment with its best match
}
// Ours against PyTorch's: classes pixel by pixel, and each of its segments against our best of the same class.
compare :: (engine: *Segment_Engine, result: Segment_Result, ref_path: string, info_path: string) -> Comparison {
c: Comparison;
data, ok := read_entire_file(ref_path, log_errors = false,, temp);
if !ok { c.error = tprint("no reference % (tools/export_mask2former.py --reference)", ref_path); return c; }
if data.count < 8 || slice(data, 0, 4) != "M2FR" { c.error = tprint("% is not a reference", ref_path); return c; }
w := (cast(s32) data[4]) + ((cast(s32) data[5]) << 8);
h := (cast(s32) data[6]) + ((cast(s32) data[7]) << 8);
if w != result.width || h != result.height { c.error = tprint("the reference is % x %, ours % x %", w, h, result.width, result.height); return c; }
ref_map := cast([] u8) advance(data, 8);
if ref_map.count != cast(s64) w * h { c.error = "the reference is cut short"; return c; }
// Its segments' classes, by id.
info, info_ok := read_entire_file(info_path, log_errors = false,, temp);
if !info_ok { c.error = tprint("no %", info_path); return c; }
ref_class: [256] s32;
for * ref_class it.* = -1;
ref_score: [256] float64;
ref_ids: [..] u8;
ref_ids.allocator = temp;
for line: split(info, "\n",, temp) {
parts := split(trim(line), " ",, temp);
if parts.count < 2 continue;
id := string_to_int(parts[0]);
ref_class[id] = cast(s32) string_to_int(parts[1]);
if parts.count >= 3 ref_score[id] = string_to_float64(parts[2]);
array_add(*ref_ids, cast(u8) id);
}
our_class: [256] s32;
for * our_class it.* = -1;
for result.segments our_class[it.id] = it.class;
c.pixels = ref_map.count;
for 0..ref_map.count - 1 if ref_class[ref_map[it]] == our_class[result.map[it]] c.agree += 1;
used: [256] bool;
for rid: ref_ids {
c.checked += 1;
best: float64;
best_id: u8;
for result.segments {
if it.class != ref_class[rid] continue;
both, either: s64;
for p: 0..ref_map.count - 1 {
a := ref_map[p] == rid;
b := result.map[p] == it.id;
if a && b both += 1;
if a || b either += 1;
}
iou := ifx either then cast(float64) both / either else 1.0;
if iou > best { best, best_id = iou, it.id; }
}
if best >= 0.5 { c.matched += 1; used[best_id] = true; }
else if ref_score[rid] >= CONFIDENT c.missed_confident += 1;
if best < 0.5 print(" check: PyTorch's % (score %) has no match here (best IoU %)\n", engine.classes[ref_class[rid]].name, formatFloat(ref_score[rid], trailing_width = 3), formatFloat(best, trailing_width = 3));
c.worst = min(c.worst, best);
}
for result.segments if !used[it.id] c.extra += 1;
c.ok = true;
return c;
}
// The segments in their classes' colours over the image, at the map's size, as a PNG.
write_overlay :: (engine: *Segment_Engine, image: Image, result: Segment_Result, path: string) {
w, h := result.width, result.height;
pixels := NewArray(cast(s64) w * h * 4, u8,, temp);
for y: 0..h - 1 for x: 0..w - 1 {
sx := min(cast(s32) ((x + 0.5) * image.width / w), image.width - 1);
sy := min(cast(s32) ((y + 0.5) * image.height / h), image.height - 1);
source := image.pixels + (cast(s64) sy * image.width + sx) * 4;
dest := pixels.data + (cast(s64) y * w + x) * 4;
id := result.map[cast(s64) y * w + x];
if id {
colour := segment_colour(result.segments[id - 1].class);
for c: 0..2 dest[c] = cast(u8) ((cast(s32) source[c] + colour[c]) / 2);
} else {
for c: 0..2 dest[c] = source[c] / 3; // no segment: darkened
}
dest[3] = 255;
}
stbi_write_png(temp_c_string(path), w, h, 4, pixels.data, w * 4);
}
ms :: (since: Apollo_Time) -> float64 {
return to_float64_seconds(current_time_monotonic() - since) * 1000;
}
percent :: (fraction: float64) -> string {
return join(tprint("%", formatFloat(fraction * 100, trailing_width = 1)), "%",, temp);
}
pad :: (text: string, width: s64) -> string {
if text.count >= width return text;
builder: String_Builder;
builder.allocator = temp;
append(*builder, text);
for 1..width - text.count append(*builder, " ");
return builder_to_string(*builder,, temp);
}
#import "Basic";
#import "String";
#import "File";
#import "File_Utilities";
#import "System";
#import "Image_Decode";
#import "Segment";
#import "Capture_File";
#import "stb_image_write";