mcap/src/app/segment_ui.jai
Musa Mahmood 56588ff5e3 [0.0.15] Segmentation phase 6: the segments overlay in the Capture window, colours to choose
- The Capture window draws a segmented image's segments over it: each class in its colour, a shade
  of it for each instance, edges darker; under the OCR boxes, at any fit and zoom. A viewer worker
  reads the segs and mask chunks (never the images); the main thread makes the texture from the
  mask when the capture, the opacity or the classes hidden change.
- Above the image: Segments on or off, the opacity, and a legend of the classes (largest first,
  with counts): untick one to hide it, right click it to search has: for it. Hovering the image
  shows the segment's class, score and share of the image.
- Colours: a class's own is a hue from its name's hash. Its legend swatch is a colour picker
  (ImGui ColorEdit3) to choose another, kept in %APPDATA%\mcap\segment-colours.config for every
  image and library; right click it to go back to its own.
- Tests: the overlay's pixels (shades, edges, hidden classes, opacity), reading a file's segments
  for it, and chosen colours.
- This completes segmentation-plan.html's six phases.
2026-10-10 04:44:05 +01:00

735 lines
32 KiB
Plaintext

// Segmentation in the app (modules/Segment): the segmentation scan, the Segmentation window, and
// -segment. A scan reads only images in the library folders set to segment (app/library_folders.jai),
// never a video, and stores each one's segments in its .mcap: a segs chunk in front, which the
// catalog reads, and a mask chunk at the back (modules/Capture_File/segment_chunks.jai).
//
// The scan runs on the OCR thread (app/ocr_ui.jai), as a task of its own, so it shares the OCR scan's
// progress, pause and cancel, and one thread uses the GPU at a time. The engine is made by the first
// scan, not at startup: it takes some 2.4 GB of GPU memory, and most sessions never segment anything.
SEGMENT_WINDOW :: "Segmentation";
SEGMENT_MODEL :: "mask2former-swin-base-coco-panoptic"; // the one segmentation model; its chunks are keyed by this
SEGMENT_SCAN_BATCH :: 8; // images read and segmented together (the engine runs them 4 at a time on the GPU)
segment_ui: struct {
// Owned by the OCR thread while ocr_ui.thread_busy, like ocr_ui.engine.
engine: *Segment_Engine;
engine_error: string;
// Main thread.
window_open: bool;
scan_requested: bool; // -segment has started its scan
logged_step: s64; // -segment's last progress line, in twentieths
erase_folder: string; // the folder whose segments the erase popup asks about; heap
erase_asked: bool;
// The window's counts per library folder, worked out again when the catalog changes.
counted_generation: s64 = -1;
counts: [..] Segment_Folder_Count;
overlay_state: Segment_Overlay_State; // the Capture window's overlay; main thread
}
Segment_Overlay_State :: struct {
show := true;
opacity: float = 0.45;
hidden: [..] string; // classes not drawn, by name, for every capture; heap
colours: [..] Segment_Colour; // classes given their own colour in the legend; kept in SEGMENT_COLOURS_FILE
overlay: *Segment_Overlay; // the capture's shown, or being replaced
entry: *Catalog_Entry; // the catalog entry it was asked for: a new one is read again
loading: bool;
texture: Gpu_Texture;
dirty: bool; // the texture is out of date
}
Segment_Folder_Count :: struct {
folder: string; // a view of viewer.folders' string, good until they change
images: s64; // captures that are not videos
segmented: s64; // of them, with this model's segments
catalogued: bool; // every capture in it has its catalog entry
}
// A class's colour, chosen in the Capture window's legend. Heap.
Segment_Colour :: struct {
class: string;
rgb: [3] u8;
}
// The colours chosen, one line each: colour: "person" #e05a47. In the app's data folder, beside the
// label list: a way of looking, so the same in every library.
SEGMENT_COLOURS_FILE :: "segment-colours.config";
segment_colours_path :: () -> string {
return tprint("%/%", app_program_data_dir(), SEGMENT_COLOURS_FILE);
}
segment_colours_load :: () {
o := *segment_ui.overlay_state;
o.colours.allocator = context.default_allocator;
text, ok := read_entire_file(segment_colours_path(), log_errors = false,, temp);
if !ok return;
for raw: split(text, "\n",, temp) {
line := trim(trim_right(raw, "\r"));
if !begins_with(line, "colour:") continue; // @version 1, comments, and keys a later build adds
class, rest, class_ok := mcap_read_quoted(trim(advance(line, 7)),, temp);
rest = trim(rest);
if !class_ok || rest.count != 7 || rest[0] != #char "#" continue;
value, value_ok := string_to_int(advance(rest, 1), 16, u32);
if !value_ok continue;
segment_colour_set(class, .[cast(u8) (value >> 16), cast(u8) ((value >> 8) & 0xFF), cast(u8) (value & 0xFF)], save = false);
}
}
segment_colours_save :: () {
builder: String_Builder;
builder.allocator = temp;
append(*builder, "@version 1\n# The colours of segment classes in the Capture window, chosen in its legend. A class not\n# listed has its own colour, from its name.\n");
for segment_ui.overlay_state.colours {
print_to_builder(*builder, "colour: % #%\n", mcap_quote(it.class), formatInt((cast(u32) it.rgb[0] << 16) | (cast(u32) it.rgb[1] << 8) | it.rgb[2], base = 16, minimum_digits = 6));
}
if !write_entire_file(segment_colours_path(), *builder) log_error("[Segment] Could not write %.", segment_colours_path());
}
// Gives class its own colour; or, with default, takes it back to the one from its name.
segment_colour_set :: (class: string, rgb: [3] u8, default := false, save := true) {
o := *segment_ui.overlay_state;
for * o.colours {
if it.class != class continue;
if default {
free(it.class,, context.default_allocator);
array_ordered_remove_by_index(*o.colours, it_index);
} else {
it.rgb = rgb;
}
if save segment_colours_save();
o.dirty = true;
return;
}
if default return;
array_add(*o.colours, .{copy_string(class,, context.default_allocator), rgb});
if save segment_colours_save();
o.dirty = true;
}
segment_models_folder :: () -> string {
return tprint("%segment-models", path_strip_filename(get_path_of_running_executable()));
}
// ---------------------------------------------------------------- startup and shutdown
segment_ui_startup :: () {
segment_ui.counts.allocator = context.default_allocator;
segment_ui.overlay_state.hidden.allocator = context.default_allocator;
segment_colours_load();
library_folders_load();
}
// After ocr_ui_shutdown has stopped the OCR thread.
segment_ui_shutdown :: () {
o := *segment_ui.overlay_state; // the viewer's workers have stopped; the texture goes with the device
segment_overlay_free(o.overlay);
segment_overlay_free(viewer.segments_done);
for o.hidden free(it,, context.default_allocator);
array_free(o.hidden);
for o.colours free(it.class,, context.default_allocator);
array_free(o.colours);
segment_destroy(segment_ui.engine);
segment_ui.engine = null;
free(segment_ui.engine_error,, context.default_allocator);
free(segment_ui.erase_folder,, context.default_allocator);
array_free(segment_ui.counts);
}
// ---------------------------------------------------------------- the scan
// Starts a scan of the images not yet segmented in the folders set to segment, or of those among only
// (an import's). False, with a toast, when there is nothing to do or the OCR thread is busy.
segment_scan_start :: (only: [] s32 = .[], quiet := false) -> bool {
if ocr_ui.thread_busy return false;
scan := *ocr_ui.scan;
for scan.items free(it.path);
array_reset(*scan.items);
free(scan.model);
scan.* = .{};
scan.items.allocator = context.default_allocator;
scan.model = copy_string(SEGMENT_MODEL,, context.default_allocator);
scan.segmenting = true;
current := ifx segment_ui.engine then segment_classes_text(segment_ui.engine) else ""; // the OCR thread is idle: the engine is ours
indices: [..] s32;
indices.allocator = temp;
if only.count array_add(*indices, ..only);
else for viewer.captures array_add(*indices, cast(s32) it_index);
for indices {
capture := *viewer.captures[it];
if capture.removed || !library_folder_segments(capture.folder) continue;
entry := capture.catalog;
if !entry || entry.media == .VIDEO continue; // images only; the catalog says which
// Done already, unless with another class list than the engine's (when it is up to say).
if entry.segment_classes && (!current || entry.segment_classes == current) continue;
array_add(*scan.items, .{index = it, path = copy_string(capture.path,, context.default_allocator)});
}
scan.total = scan.items.count;
if !scan.total {
if !quiet toast_show(.Success, "Every image in the folders set to segment has been segmented");
return false;
}
scan.running = true;
scan.start = current_time_monotonic();
return ocr_ui_start(.{kind = .SEGMENT});
}
// On the OCR thread: the SEGMENT task. Makes the engine the first time, then segments the scan's items
// a batch at a time, storing each.
segment_ui_scan_task :: () {
scan := *ocr_ui.scan;
defer atomic_swap(*scan.running, false);
if !segment_ui.engine {
start := current_time_monotonic();
engine, ok, error := segment_create(segment_models_folder(), SEGMENT_MODEL);
free(segment_ui.engine_error);
segment_ui.engine_error = ifx ok then "" else copy_string(error);
if !ok {
log_error("[Segment] Could not start %: %", SEGMENT_MODEL, error);
atomic_swap(*scan.failed, scan.total);
atomic_swap(*scan.done, scan.total);
return;
}
segment_ui.engine = engine;
log("[Segment] % on % in % ms.%", SEGMENT_MODEL, engine.provider, to_milliseconds(current_time_monotonic() - start), ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
}
engine := segment_ui.engine;
// Cancelling (or closing the app) stops it between batches of the engine's, rather than waiting the
// scan's batch out. What it had not segmented is left as it was.
engine.cancel = *scan.cancel;
defer engine.cancel = null;
position := 0;
while position < scan.items.count {
while scan.paused && !scan.cancel sleep_milliseconds(100);
if scan.cancel break;
count := min(SEGMENT_SCAN_BATCH, scan.items.count - position);
errors := segment_read_and_store(engine, array_view(scan.items, position, count));
if scan.cancel { // cut short: what it stored stays stored and is counted; the rest is left, not failed
for errors {
if !it atomic_add(*scan.done, 1);
free(it);
}
break;
}
for errors if it { atomic_add(*scan.failed, 1); log_error("[Segment scan] % %", scan.items[position + it_index].path, it); free(it); }
atomic_add(*scan.done, count);
position += count;
reset_temporary_storage();
}
scan.elapsed_seconds = to_float64_seconds(current_time_monotonic() - scan.start) - scan.paused_seconds;
log("[Segment scan] % of % images segmented with % in % (% failed)%.", scan.done, scan.total, SEGMENT_MODEL,
ocr_duration_text(scan.elapsed_seconds), scan.failed, ifx scan.cancel then ", cancelled" else "");
}
// On the OCR thread: segments the items' images and stores each one's segments, handing its new
// catalog entry to the viewer. An error message (heap) for each that failed, "" for the others. A
// video is skipped, not failed: the catalog that chose it may have been out of date.
segment_read_and_store :: (engine: *Segment_Engine, items: [] Scan_Item) -> [] string {
errors := NewArray(items.count, string);
Prepared :: struct { image: Image; which: string; ok: bool; }
prepared := NewArray(items.count, Prepared,, temp);
defer for * prepared if it.ok image_free(*it.image);
inputs: [..] Segment_Image;
inputs.allocator = temp;
input_of: [..] s64; // which item each input is
input_of.allocator = temp;
for item, n: items {
m, bytes, which, ok, error := mcap_read_shown_image(item.path,, temp);
if !ok { errors[n] = copy_string(error); continue; }
if contains(m.text, "VideoCodec:") continue; // a video (app/import.jai): images only
image, decoded, decode_error := image_decode(bytes); // turned by its EXIF orientation, as the Capture window shows it
if !decoded { errors[n] = sprint("its image cannot be decoded: %", decode_error); continue; }
prepared[n] = .{image, which, true};
array_add(*inputs, .{image.pixels, image.width, image.height, image.width * 4});
array_add(*input_of, n);
}
if !inputs.count return errors;
results := segment_images(engine, inputs,, temp);
for result, r: results {
n := input_of[r];
item := items[n];
if result.failed { errors[n] = copy_string(result.error); continue; }
p := prepared[n];
segs, mask := segment_mcap_chunks(engine, result, p.which, p.image.width, p.image.height);
stored: bool;
store_error: string;
for 1..OCR_STORE_ATTEMPTS {
stored, store_error = mcap_store(item.path, front = .[segs], back = .[mask]);
if stored break;
sleep_milliseconds(250);
}
if !stored { errors[n] = sprint("cannot be stored: %", store_error); continue; }
entry, load_error := catalog_load(item.path);
if entry {
lock(*viewer.mutex);
array_add(*viewer.catalog_updates, .{item.index, entry});
unlock(*viewer.mutex);
} else {
errors[n] = sprint("was segmented, but could not be read back: %", load_error);
}
}
return errors;
}
// -segment: segments the folders set to segment once the catalog has loaded, and ends the app when
// done (0, or 1 if any image failed).
segment_ui_segment_and_exit :: (busy: bool) {
finish :: (code: s32, format: string, args: ..Any) {
if code log_error(format, ..args);
else log(format, ..args);
app.exit_code = code;
app.should_exit = true;
app.segment_and_exit = false;
}
if viewer.scan_error { finish(1, "[Segment scan] %", viewer.scan_error); return; }
if !segment_ui.scan_requested {
// After the catalog, and after the engines made at startup, which use the same thread.
if busy || viewer.catalog_loaded < viewer.captures.count || !ocr_ui.engine_tried || !labels_ui.engine_tried return;
segment_ui.scan_requested = true;
segment_ui.window_open = true; // its progress bar
if !segment_scan_start(quiet = true) finish(0, "[Segment scan] -segment: nothing to segment (no image in a folder set to segment needs it).");
return;
}
scan := *ocr_ui.scan;
if scan.running {
done, total, failed, rate, left, elapsed := ocr_scan_progress();
step := max(total / 20, 1);
if done / step > segment_ui.logged_step {
segment_ui.logged_step = done / step;
log("[Segment scan] % segmented, % failed, % elapsed, % left, % images/s.", ocr_scan_progress_text(done, total), failed, ocr_duration_text(elapsed), ocr_duration_text(left), FF(rate, 1));
}
return;
}
if busy return;
finish(ifx scan.failed || segment_ui.engine_error then cast(s32) 1 else 0, "[Segment scan] -segment is done: % of % segmented in %, % failed.%",
scan.done - scan.failed, scan.total, ocr_duration_text(scan.elapsed_seconds), scan.failed, ifx segment_ui.engine_error then tprint(" %", segment_ui.engine_error) else "");
}
// ---------------------------------------------------------------- the Segmentation window
segment_ui_frame :: () { using ImGui;
busy := ocr_ui.thread_busy;
if app.segment_and_exit segment_ui_segment_and_exit(busy);
segment_erase_popup();
if !segment_ui.window_open return;
SetNextWindowSize(.{640, 520}, .FirstUseEver);
if Begin(SEGMENT_WINDOW, *segment_ui.window_open) {
segment_window_contents();
}
End();
}
segment_window_contents :: () { using ImGui;
busy := ocr_ui.thread_busy;
scan := *ocr_ui.scan;
// The model.
model_file := tprint("%/%.onnx", segment_models_folder(), SEGMENT_MODEL);
if !busy && segment_ui.engine {
Text("% on %", SEGMENT_MODEL, ifx segment_ui.engine.provider == .CUDA then "the GPU" else "the CPU");
if segment_ui.engine.gpu_explanation TextColored(app_theme.palette.dim, "%", segment_ui.engine.gpu_explanation);
} else if !busy && segment_ui.engine_error {
TextColored(app_theme.palette.warning, "% could not start: %", SEGMENT_MODEL, segment_ui.engine_error);
} else if !file_exists(model_file) {
TextColored(app_theme.palette.warning, "The model is not there (%): make it with tools/export_mask2former.py (see modules/Segment/README.md) and rebuild.", model_file);
} else {
Text("%", SEGMENT_MODEL);
TextColored(app_theme.palette.dim, "Loads when a scan starts (about 2.4 GB of GPU memory).");
}
TextColored(app_theme.palette.dim, "Finds what is where in each image: people, animals, things, and stuff such as sky, wall and grass. Only in the folders set to segment, and never in videos.");
Spacing();
// The library folders.
segment_update_counts();
if BeginTable("folders", 4, .Borders | .RowBg | .SizingStretchProp) {
TableSetupColumn("Library folder", .WidthStretch);
TableSetupColumn("Segment", .WidthFixed);
TableSetupColumn("Segmented", .WidthFixed);
TableSetupColumn("", .WidthFixed);
TableHeadersRow();
for segment_ui.counts {
PushID(cast(s32) it_index);
TableNextRow();
TableNextColumn();
TextUnformatted(ifx it.folder then it.folder else "(the library's top)");
TableNextColumn();
on := library_folder_segments(it.folder);
if busy && scan.segmenting BeginDisabled();
if Checkbox("##segment", *on) library_folder_set_segment(it.folder, on);
if busy && scan.segmenting EndDisabled();
if IsItemHovered(.AllowWhenDisabled) SetTooltip(ifx on then "Scans read this folder's images" else "Scans leave this folder alone");
TableNextColumn();
if it.catalogued Text("% of %", it.segmented, it.images);
else TextColored(app_theme.palette.dim, "% of % ...", it.segmented, it.images);
TableNextColumn();
can_erase := it.segmented > 0 && !busy;
if !can_erase BeginDisabled();
if SmallButton("Erase") {
free(segment_ui.erase_folder,, context.default_allocator);
segment_ui.erase_folder = copy_string(it.folder,, context.default_allocator);
segment_ui.erase_asked = true;
}
if !can_erase EndDisabled();
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Takes this folder's segments out of its files (asks first)");
PopID();
}
EndTable();
}
gone := library_folders_gone();
for gone {
TextColored(app_theme.palette.dim, "\"%\" is set to segment, but the library has no folder by that name.", it);
SameLine();
PushID(temp_c_string(it));
if SmallButton("Forget it") library_folder_forget(it);
PopID();
}
Spacing();
// The scan.
if scan.running && scan.segmenting {
Text("Segmenting:");
ocr_scan_progress_ui();
return;
}
any_on := false;
for segment_ui.counts if library_folder_segments(it.folder) any_on = true;
catalog_ready := viewer.catalog_loaded >= viewer.captures.count;
can_scan := !busy && catalog_ready && any_on;
if !can_scan BeginDisabled();
if Button("Segment the folders set to segment") segment_scan_start();
if !can_scan EndDisabled();
if IsItemHovered(.AllowWhenDisabled) {
if !any_on SetTooltip("Tick Segment for a folder first");
else if !catalog_ready SetTooltip("Once the catalog has loaded");
else if busy SetTooltip("After what the OCR thread is doing (a scan, or loading a model)");
else SetTooltip("Reads every image in those folders not yet segmented, storing each as it goes: a scan can stop and start again");
}
if scan.segmenting && scan.total && scan.done {
TextColored(app_theme.palette.dim, "Last scan: % of % images in %, % failed.", scan.done, scan.total, ocr_duration_text(scan.elapsed_seconds), scan.failed);
}
}
// The window's per-folder counts, worked out again only when the catalog has changed.
segment_update_counts :: () {
if segment_ui.counted_generation == viewer.catalog_generation && segment_ui.counts.count == viewer.folders.count return;
segment_ui.counted_generation = viewer.catalog_generation;
array_reset_keeping_memory(*segment_ui.counts);
for viewer.folders array_add(*segment_ui.counts, .{folder = it, catalogued = true});
for viewer.captures {
if it.removed continue;
count: *Segment_Folder_Count;
for * c: segment_ui.counts if c.folder == it.folder { count = c; break; }
if !count continue;
entry := it.catalog;
if !entry { count.catalogued = false; continue; }
if entry.media == .VIDEO continue;
count.images += 1;
if entry.segment_classes count.segmented += 1;
}
}
// ---------------------------------------------------------------- the Capture window's overlay
//
// The segments of the capture shown, over its image: each segment in its class's colour (a shade of
// it for each instance, so two people side by side differ), its edges darker, with the class under
// the mouse in a tooltip. A viewer worker reads the segs and mask chunks (never the images); the
// main thread makes the texture from the mask, again only when the capture, the opacity or the
// classes hidden change.
// A capture's segments as read for the overlay. Heap, with everything in it.
Segment_Overlay :: struct {
index: s32 = -1;
error: string; // why there is none
width, height: s32; // the mask's
map: [] u8; // a segment id per pixel; 0: none
segments: [] Mcap_Segment; // largest first
by_id: [256] s16; // into segments, for each id; -1: none
}
// On a viewer worker: the capture's segments by SEGMENT_MODEL, or why not.
segment_overlay_read :: (path: string) -> *Segment_Overlay {
overlay := New(Segment_Overlay,, context.default_allocator);
for * overlay.by_id it.* = -1;
m, original, ok, error := mcap_read_without_original(path,, temp);
if !ok { overlay.error = copy_string(error,, context.default_allocator); return overlay; }
segs := mcap_find_chunk(m.front_chunks, mcap_tag(MCAP_SEGMENTS_TAG), SEGMENT_MODEL);
mask := mcap_find_chunk(m.back_chunks, mcap_tag(MCAP_MASK_TAG), SEGMENT_MODEL);
if !segs || !mask { overlay.error = copy_string("it has not been segmented",, context.default_allocator); return overlay; }
segments, segments_ok, segments_error := mcap_segments_parse(cast(string) segs.data,, temp);
map, map_ok, map_error := mcap_mask_parse(mask.data,, temp);
if !segments_ok || !map_ok { overlay.error = sprint("its segments cannot be read: %", ifx segments_error then segments_error else map_error,, context.default_allocator); return overlay; }
push_allocator(context.default_allocator);
overlay.width, overlay.height = map.width, map.height;
overlay.map = array_copy(map.map);
overlay.segments = NewArray(segments.segments.count, Mcap_Segment);
for segments.segments {
overlay.segments[it_index] = it;
overlay.segments[it_index].class = copy_string(it.class);
overlay.by_id[it.id] = cast(s16) it_index;
}
return overlay;
}
segment_overlay_free :: (overlay: *Segment_Overlay) {
if !overlay return;
push_allocator(context.default_allocator);
free(overlay.error);
array_free(overlay.map);
for overlay.segments free(it.class);
array_free(overlay.segments);
free(overlay);
}
// Main thread, each frame: takes what the workers read, asks for the capture shown, and makes the
// texture when it is out of date.
segment_overlay_frame :: () {
o := *segment_ui.overlay_state;
lock(*viewer.mutex);
done := viewer.segments_done;
arrived := viewer.segments_done_request != 0 && viewer.segments_done_request == viewer.segments_request;
viewer.segments_done = null;
viewer.segments_done_request = 0;
unlock(*viewer.mutex);
if arrived {
segment_overlay_free(o.overlay);
o.overlay = done;
o.loading = false;
o.dirty = true;
} else {
segment_overlay_free(done);
}
// Nothing shown: let it all go.
index := viewer.full_index;
entry: *Catalog_Entry;
if viewer.full_open && index >= 0 && index < viewer.captures.count && !viewer.captures[index].removed entry = viewer.captures[index].catalog;
wanted := o.show && entry && entry.segment_classes.count > 0;
if !wanted {
gpu_texture_release(*o.texture);
if !viewer.full_open { segment_overlay_free(o.overlay); o.overlay = null; o.entry = null; o.loading = false; }
return;
}
// The capture shown, read again when its catalog entry is new (stepped to, or segmented again).
if o.entry != entry {
o.entry = entry;
o.loading = true;
lock(*viewer.mutex);
viewer.segments_wanted = index;
viewer.segments_request += 1;
unlock(*viewer.mutex);
signal(*viewer.wake);
}
if o.dirty && o.overlay && o.overlay.index == index {
o.dirty = false;
segment_overlay_build();
}
}
// The device went (graphics/DX11.jai): the texture with it; made again from the mask held.
segment_overlay_device_lost :: () {
o := *segment_ui.overlay_state;
gpu_texture_release(*o.texture);
o.dirty = true;
}
// The row above the image: the toggle, the opacity, and the classes in it, each to hide or show
// (right click to search for it).
segment_overlay_controls :: (index: s32) { using ImGui;
if index < 0 || index >= viewer.captures.count return;
entry := viewer.captures[index].catalog;
if !entry || !entry.segment_classes return;
o := *segment_ui.overlay_state;
if Checkbox("Segments", *o.show) o.dirty = true;
if IsItemHovered() SetTooltip("What segmentation found in this image, drawn over it (%)", SEGMENT_MODEL);
if !o.show { SameLine(); TextColored(app_theme.palette.dim, "% found", entry.segment_count); return; }
SameLine();
SetNextItemWidth(140);
percent := o.opacity * 100;
if SliderFloat("##opacity", *percent, 10, 90, "opacity %.0f") { o.opacity = percent / 100; o.dirty = true; }
if o.loading { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); }
if o.overlay && o.overlay.index == index && o.overlay.error { SameLine(); TextColored(app_theme.palette.warning, "%", o.overlay.error); }
// The legend: each class once, largest first, with its colour and how many of it.
right_edge := GetCursorScreenPos().x + GetContentRegionAvail().x;
first := true;
for segment, n: entry.segments {
seen := false;
for 0..n - 1 if entry.segments[it].class == segment.class { seen = true; break; }
if seen continue;
count := 0;
for entry.segments if it.class == segment.class count += 1;
label := ifx count > 1 then tprint("% x%##legend%", segment.class, count, n) else tprint("%##legend%", segment.class, n);
width := GetFrameHeight() + GetStyle().ItemInnerSpacing.x + GetFrameHeight() + GetStyle().ItemInnerSpacing.x + CalcTextSize(temp_c_string(label), null, true).x;
if !first {
SameLine();
if GetCursorScreenPos().x + width > right_edge NewLine();
}
first = false;
// Its colour: click to choose another (kept for every capture), right click to go back to its own.
PushID(temp_c_string(segment.class));
base := segment_class_base_colour(segment.class);
rgb := float.[base[0] / 255.0, base[1] / 255.0, base[2] / 255.0];
if ColorEdit3("##colour", *rgb, .NoInputs | .NoLabel | .NoAlpha | .NoOptions | .PickerHueWheel) {
segment_colour_set(segment.class, .[cast(u8) (rgb[0] * 255 + 0.5), cast(u8) (rgb[1] * 255 + 0.5), cast(u8) (rgb[2] * 255 + 0.5)], save = false);
}
if IsItemDeactivatedAfterEdit() segment_colours_save(); // once the picker closes, not every frame of a drag
chosen := false;
for o.colours if it.class == segment.class chosen = true;
if BeginPopupContextItem("##reset") {
if MenuItem("Back to the default colour", null, false, chosen) segment_colour_set(segment.class, .[0, 0, 0], default = true);
EndPopup();
}
PopID();
SameLine(0, GetStyle().ItemInnerSpacing.x);
shown := !array_find(o.hidden, segment.class);
if Checkbox(temp_c_string(label), *shown) {
if shown { found, at_hidden := array_find(o.hidden, segment.class); if found { free(o.hidden[at_hidden],, context.default_allocator); array_unordered_remove_by_index(*o.hidden, at_hidden); } }
else array_add(*o.hidden, copy_string(segment.class,, context.default_allocator));
o.dirty = true;
}
if IsItemClicked(.Right) viewer_set_search(tprint("has:\"%\"", segment.class));
if IsItemHovered() SetTooltip("Shows or hides %; right click to search has:\"%\"", segment.class, segment.class);
}
}
// Over the image drawn from image_min to image_max: the overlay's texture, and the segment under the mouse.
segment_overlay_draw :: (index: s32, image_min: ImGui.ImVec2, image_max: ImGui.ImVec2) { using ImGui;
o := *segment_ui.overlay_state;
overlay := o.overlay;
if !o.show || !overlay || overlay.index != index || !o.texture.handle return;
draw := GetWindowDrawList();
draw.AddImage(draw, gpu_texture_ref(o.texture), image_min, image_max);
if !IsItemHovered() return; // the image
mouse := GetMousePos();
w, h := image_max.x - image_min.x, image_max.y - image_min.y;
if w <= 0 || h <= 0 return;
x := cast(s32) ((mouse.x - image_min.x) / w * overlay.width);
y := cast(s32) ((mouse.y - image_min.y) / h * overlay.height);
if x < 0 || y < 0 || x >= overlay.width || y >= overlay.height return;
id := overlay.map[cast(s64) y * overlay.width + x];
at := overlay.by_id[id];
if !id || at < 0 return;
segment := overlay.segments[at];
if array_find(o.hidden, segment.class) return;
SetTooltip("% score % % of the image", segment.class, FF(segment.score, 2), join(tprint("%", FF(segment.area * 100, 1)), "%",, temp));
}
// A class's colour (the same in every capture), a shade of it for its instance'th segment in a
// capture: the next ones darker, then round again.
segment_class_colour :: (class: string, instance: s64) -> [3] u8 {
base := segment_class_base_colour(class);
shade := 1 - 0.18 * cast(float) (instance % 4);
return .[cast(u8) (base[0] * shade), cast(u8) (base[1] * shade), cast(u8) (base[2] * shade)];
}
// The colour chosen for class in the legend; else its own, from a hash of its name.
segment_class_base_colour :: (class: string) -> [3] u8 {
for segment_ui.overlay_state.colours if it.class == class return it.rgb;
return segment_class_default_colour(class);
}
// A hue from a hash of the name, at a fixed saturation and brightness.
segment_class_default_colour :: (class: string) -> [3] u8 {
hash := fnv1a_hash(class.data, class.count);
hue := cast(float) (hash % 360) / 60.0;
saturation := 0.65;
value := 0.95;
sector := cast(s32) hue;
f := hue - sector;
p := value * (1 - saturation);
q := value * (1 - saturation * f);
t := value * (1 - saturation * (1 - f));
rgb: [3] float;
if sector == {
case 0; rgb = .[value, t, p];
case 1; rgb = .[q, value, p];
case 2; rgb = .[p, value, t];
case 3; rgb = .[p, q, value];
case 4; rgb = .[t, p, value];
case; rgb = .[value, p, q];
}
return .[cast(u8) (rgb[0] * 255), cast(u8) (rgb[1] * 255), cast(u8) (rgb[2] * 255)];
}
// The overlay as RGBA pixels at the mask's size (temp): each pixel in its segment's colour at the
// opacity, its edges darker and stronger; hidden classes and no segment left clear.
segment_overlay_pixels :: (overlay: *Segment_Overlay, hidden: [] string, opacity: float) -> [] u8 {
w, h := overlay.width, overlay.height;
fill, edge: [256] [4] u8; // by segment id
alpha := cast(u8) clamp(opacity * 255, 0, 255);
edge_alpha := cast(u8) clamp(opacity * 255 * 1.6 + 40, 0, 255);
for segment, n: overlay.segments {
if array_find(hidden, segment.class) continue;
instance := 0;
for 0..n - 1 if overlay.segments[it].class == segment.class instance += 1;
c := segment_class_colour(segment.class, instance);
fill[segment.id] = .[c[0], c[1], c[2], alpha];
edge[segment.id] = .[c[0] / 3, c[1] / 3, c[2] / 3, edge_alpha];
}
pixels := NewArray(cast(s64) w * h * 4, u8,, temp);
for y: 0..h - 1 {
row := cast(s64) y * w;
for x: 0..w - 1 {
i := row + x;
id := overlay.map[i];
if !id continue;
on_edge := (x > 0 && overlay.map[i - 1] != id) || (x + 1 < w && overlay.map[i + 1] != id) ||
(y > 0 && overlay.map[i - w] != id) || (y + 1 < h && overlay.map[i + w] != id);
colour := ifx on_edge then edge[id] else fill[id];
memcpy(pixels.data + i * 4, colour.data, 4);
}
}
return pixels;
}
#scope_file
// The texture, from the mask held.
segment_overlay_build :: () {
o := *segment_ui.overlay_state;
gpu_texture_release(*o.texture);
overlay := o.overlay;
if !overlay || !overlay.map.count return;
pixels := segment_overlay_pixels(overlay, o.hidden, o.opacity);
o.texture = gpu_texture_create(pixels.data, overlay.width, overlay.height);
}
#scope_export
// The confirmation for erasing one folder's segments, as a modal popup; at the top level, each frame.
segment_erase_popup :: () { using ImGui;
POPUP :: "Erase segments?";
if segment_ui.erase_asked && !IsPopupOpen(POPUP) OpenPopup(POPUP);
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
folder := segment_ui.erase_folder;
Text("Erase the segments of every image in %?", ifx folder then folder else "the library's top");
TextColored(app_theme.palette.dim, "Each file holding them is rewritten without them; its images, text, OCR and labels stay as they are.");
TextColored(app_theme.palette.warning, "Only segmenting the folder again brings them back.");
Spacing();
if Button("Erase") {
segment_ui.erase_asked = false;
CloseCurrentPopup();
segment_ui.window_open = true; // the progress bar is in the OCR window, opened by the erase
ocr_ui.window_open = true;
ocr_erase_start(.SEGMENTS, folder = folder, one_folder = true);
}
SameLine();
if Button("Cancel") || IsKeyPressed(.Escape) {
segment_ui.erase_asked = false;
CloseCurrentPopup();
}
EndPopup();
}