[0.0.5] Labels: SigLIP 2 labels and image vectors in .mcap version 3 chunks; label: and like: search

This commit is contained in:
Musa Mahmood 2026-10-08 11:06:55 +01:00
parent 3c6da5b53d
commit cf60dfa535
32 changed files with 2241 additions and 253 deletions

2
.gitignore vendored
View File

@ -23,3 +23,5 @@ Thumbs.db
/modules/onnxruntime/macos/ /modules/onnxruntime/macos/
/modules/onnxruntime/download/ /modules/onnxruntime/download/
/modules/OCR/models/ /modules/OCR/models/
# Labels: downloaded, not committed (modules/Labels/fetch_models.jai)
/modules/Labels/models/

View File

@ -31,12 +31,14 @@ and everything around them, ready for an app to be written into `src/app/app_ui.
## The capture viewer ## The capture viewer
`src/app/viewer.jai`, `catalog.jai` and `ocr_ui.jai`: every `.mcap` in `D:\SnagitIndex\mcap` as a grid `src/app/viewer.jai`, `catalog.jai`, `ocr_ui.jai` and `labels_ui.jai`: every `.mcap` in
of previews, and one capture at full size in the Capture window. `D:\SnagitIndex\mcap` as a grid of previews, and one capture at full size in the Capture window.
- **Search** above the grid: every word must match, anywhere in the OCR text, window title, URL, app - **Search** above the grid: every word must match, anywhere in the OCR text, window title, URL, app
name or annotation text; `"a phrase"` matches as one; `ocr:`, `title:`, `url:`, `app:` and `note:` name, annotation text or labels; `"a phrase"` matches as one; `ocr:`, `title:`, `url:`, `app:`,
limit a word to one field. In the Capture window the matching OCR lines are highlighted, with Next `note:` and `label:` limit a word to one field. `like:"a circuit board"` searches by meaning: the
captures whose image is nearest the phrase, best first (the label model's text encoder makes the
phrase's vector; each labelled capture's image vector is in its file). In the Capture window the matching OCR lines are highlighted, with Next
and Previous. Matching runs on a pool of background threads (`app/background.jai`), as do the and Previous. Matching runs on a pool of background threads (`app/background.jai`), as do the
Stats window's numbers, so typing never waits on them; the grid shows the last results until the Stats window's numbers, so typing never waits on them; the grid shows the last results until the
new ones are back, usually within a frame. new ones are back, usually within a frame.
@ -45,7 +47,9 @@ of previews, and one capture at full size in the Capture window.
- **OCR** (`modules/OCR`): the OCR window shows whether it runs on the GPU and, if not, what to install, - **OCR** (`modules/OCR`): the OCR window shows whether it runs on the GPU and, if not, what to install,
and switches model (kept as `model` under `[ocr]` in the settings file). **Scan the library** reads and switches model (kept as `model` under `[ocr]` in the settings file). **Scan the library** reads
every capture shown that the model has not read yet, storing each reading in its file as it goes, every capture shown that the model has not read yet, storing each reading in its file as it goes,
so it can pause, cancel and pick up again. `-library FOLDER` shows another folder of `.mcap` files; so it can pause, cancel and pick up again; with **Labels too** it labels them in the same pass. The
Capture window's **OCR** button reads one capture; a capture read by several models keeps each
reading, and the window can show any of them. `-library FOLDER` shows another folder of `.mcap` files;
`-scan` scans it with the chosen model once it has loaded and exits (0, or 1 if any capture `-scan` scans it with the chosen model once it has loaded and exits (0, or 1 if any capture
failed), for a scan without the UI, such as on a copy. failed), for a scan without the UI, such as on a copy.
- **Delete:** hovering a thumbnail shows a trash button in its corner. The confirmation offers moving - **Delete:** hovering a thumbnail shows a trash button in its corner. The confirmation offers moving
@ -59,8 +63,17 @@ of previews, and one capture at full size in the Capture window.
- **Stats** above the grid: what OCR found in the captures shown, by the chosen model: captures with - **Stats** above the grid: what OCR found in the captures shown, by the chosen model: captures with
text, lines, words, characters, confidence, words per capture, a year table, the captures with text, lines, words, characters, confidence, words per capture, a year table, the captures with
the most words (click to open) and the commonest words (click to search). `-stats` logs the same the most words (click to open) and the commonest words (click to search). `-stats` logs the same
numbers once the catalog has loaded, and exits. The Capture window's **OCR** button reads one capture; numbers once the catalog has loaded, and exits. `-search QUERY` starts with QUERY in the search box
a capture read by several models keeps each reading, and the window can show any of them. (with `-stats`, the numbers are for its matches, and the first ten are logged). Stats also counts
each capture's best label (click one to search for it).
- **Labels** (`modules/Labels`, SigLIP 2 base at 384 px): each capture is scored against a label list,
`labels.config` in the app's data folder (`%APPDATA%\mcap`): a name and a prompt per label, a fuller
description scoring better than a word. A starter list is written the first time; edits are
picked up while the app runs. A scan stores each capture's five best labels and its image vector in
its file (`labl` and `embd` chunks, see `modules/Capture_File/README.md`), so a changed list is
scored again from the stored vectors without reading the images. The Labels window shows the list
and how much of the library is labelled; the Capture window shows the capture's labels. `label:`
matches a capture's best label, and any scoring at least half as much.
## Building ## Building

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.4"; VERSION :: "0.0.5";
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.4"; VERSION :: "0.0.5";
// 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";
@ -283,6 +283,15 @@ copy_assets :: () {
} else { } else {
log("OCR: the models have not been fetched; run jai fetch_models.jai in modules/OCR for OCR."); log("OCR: the models have not been fetched; run jai fetch_models.jai in modules/OCR for OCR.");
} }
// Labels: its models in bin/label-models, the same way.
make_directory_if_it_does_not_exist(tprint("%/label-models", build_directory));
copy_if_changed("modules/Labels/models.config", tprint("%/label-models/models.config", build_directory));
if file_exists("modules/Labels/models") {
for file_list("modules/Labels/models") copy_if_changed(it, tprint("%/label-models/%", build_directory, path_filename(it)));
} else {
log("Labels: the models have not been fetched; run jai fetch_models.jai in modules/Labels for labels.");
}
} }
copy_if_changed :: (from: string, to: string) { copy_if_changed :: (from: string, to: string) {

View File

@ -1,36 +1,44 @@
# The .mcap file # The .mcap file
One capture in one file: a fixed magic, a file version stamp, the capture's data as One capture in one file: a fixed magic, a file version stamp, the capture's data as
[Capture Text](capture_text.md), what OCR read in it, a small preview, then the images. There is no ZIP [Capture Text](capture_text.md), what OCR read in it, what other models added (chunks), a small preview,
and no compression around it; the images keep their own PNG compression. then the images, then any large chunks. There is no ZIP and no compression around it; the images keep
their own PNG compression.
The parts a library browser needs (header, text, OCR and preview) come first, so it reads the start of The parts a library browser needs (header, text, OCR, front chunks and preview) come first, so it reads
each file and never touches the images; a search, which needs no preview, reads less still. the start of each file and never touches the images; a search, which needs no preview, reads less still.
## Layout, version 2 ## Layout, version 3
All integers are little-endian. Sizes count bytes. All integers are little-endian. Sizes count bytes.
| Field | Type | | | Field | Type | |
| --- | --- | --- | | --- | --- | --- |
| magic | 8 bytes | `89 4D 43 41 50 0D 0A 1A`, which is `\x89MCAP\r\n\x1A`. Says this is an .mcap, in every version. | | magic | 8 bytes | `89 4D 43 41 50 0D 0A 1A`, which is `\x89MCAP\r\n\x1A`. Says this is an .mcap, in every version. |
| file version | u32 | `2`. Version 1 files are still read; see below. | | file version | u32 | `3`. Versions 1 and 2 are still read; see below. |
| text size, text | u32, bytes | Capture Text, UTF-8: every JSON file of the capture, as `[[file]]` sections. | | text size, text | u32, bytes | Capture Text, UTF-8: every JSON file of the capture, as `[[file]]` sections. |
| OCR size, OCR | u32, bytes | OCR text, UTF-8 (below): what each OCR model read. Size 0 when none has. | | OCR size, OCR | u32, bytes | OCR text, UTF-8 (below): what each OCR model read. Size 0 when none has. |
| front chunks | chunk list | Small things models added, such as labels and an image vector (below). |
| preview width, height | u16, u16 | In pixels. | | preview width, height | u16, u16 | In pixels. |
| preview size, preview | u32, bytes | PNG. Size 0 when there is none. | | preview size, preview | u32, bytes | PNG. Size 0 when there is none. |
| original name size, name | u16, bytes | The original image's name in the .snagx, such as `{GUID}.png`; the page's `CaptureBackgroundImage` names it too. | | original name size, name | u16, bytes | The original image's name in the .snagx, such as `{GUID}.png`; the page's `CaptureBackgroundImage` names it too. |
| original size, original | u32, bytes | The capture as taken: PNG, exactly as it was in the .snagx. | | original size, original | u32, bytes | The capture as taken: PNG, exactly as it was in the .snagx. |
| rendered kind | u8 | 0: none. 1: the same as the original (stored once). 2: its own, below. | | rendered kind | u8 | 0: none. 1: the same as the original (stored once). 2: its own, below. |
| rendered size, rendered | u32, bytes | Only for kind 2. The capture with its annotations drawn in (Snagit's `thumbnail.png`): PNG, exactly as it was. | | rendered size, rendered | u32, bytes | Only for kind 2. The capture with its annotations drawn in (Snagit's `thumbnail.png`): PNG, exactly as it was. |
| back chunks | chunk list | Large things models added, such as segmentation masks: after the images, so reading the front never reaches them. |
The file ends there: anything after the last part is a mistake. The file ends there: anything after the last part is a mistake.
The magic borrows PNG's: the first byte has its high bit set, so a channel that strips it shows; `\r\n` The magic borrows PNG's: the first byte has its high bit set, so a channel that strips it shows; `\r\n`
shows line endings being converted; and `\x1A` stops `type` on Windows. shows line endings being converted; and `\x1A` stops `type` on Windows.
**Version 1** is the same without the OCR size and OCR. A reader takes both; anything written is A **chunk list** is a u32 count, then each chunk: a 4-byte tag of ASCII letters saying what it is
version 2, so a version 1 file becomes version 2 the first time OCR is stored in it. (`labl`, `embd`), a u32 size, and that many bytes: a u16 key size, the key (whose it is: a model's
name), and the chunk's own data. A file holds at most one chunk per tag and key.
**Version 2** is version 3 without the two chunk lists; **version 1** is version 2 without the OCR size
and OCR. A reader takes all three; anything written is version 3, so an older file becomes version 3
the first time something is stored in it.
## The parts ## The parts
@ -38,6 +46,10 @@ version 2, so a version 1 file becomes version 2 the first time OCR is stored in
and `{GUID}.backup.json` when there is one. and `{GUID}.backup.json` when there is one.
- **OCR.** One section per OCR model, so a capture read by several models keeps every reading; - **OCR.** One section per OCR model, so a capture read by several models keeps every reading;
reading it again with a model replaces only that model's section. See *OCR text* below. reading it again with a model replaces only that model's section. See *OCR text* below.
- **Chunks.** What other models add, each keyed by the model's name, so models do not overwrite each
other; storing a chunk again replaces only the one with its tag and key. A chunk a reader does not
know is kept as it is when the file is rewritten, so a later build can add kinds without a new file
version. See *Label chunks* below.
- **Preview.** The rendered image (or the original, when there is no rendered one) scaled so its longest - **Preview.** The rendered image (or the original, when there is no rendered one) scaled so its longest
side is at most 320 pixels, as a PNG. An image already that small is used as it is, never scaled up. side is at most 320 pixels, as a PNG. An image already that small is used as it is, never scaled up.
- **Original and rendered.** A capture with no annotations has a rendered image identical to the original, - **Original and rendered.** A capture with no annotations has a rendered image identical to the original,
@ -91,3 +103,32 @@ A line that cannot be read is skipped, as are keys this version does not know. *
had no corners (`line: x y width height confidence "text"`); it is still read, with the box's corners. had no corners (`line: x y width height confidence "text"`); it is still read, with the box's corners.
The OCR text carries its own version, so this did not change the .mcap file version. `mcap_store_ocr` The OCR text carries its own version, so this did not change the .mcap file version. `mcap_store_ocr`
(`modules/Capture_File/ocr_text.jai`) stores a section in a file. (`modules/Capture_File/ocr_text.jai`) stores a section in a file.
## Label chunks
A label model (an image-text encoder such as SigLIP 2) adds two front chunks, keyed by its name.
**`labl`**: the capture's best labels from a label list, as text in the style above:
```
@version 1
engine: "onnxruntime 1.30.0, CUDA"
date: 2026-10-08T21:04:11Z
list: 9c4f0b2d81e3a675
label: 0.6720 "Settings"
label: 0.0951 "Installer"
```
| Key | Value |
| --- | --- |
| `engine`, `date` | As in OCR text. |
| `list` | Which label list they were scored against: a 64-bit FNV-1a hash of its names and prompts. Labels from another list are out of date. |
| `label` | A score, 0 to 1 (the model's own sigmoid; small, but their order is what counts), and the label's name, quoted with `""` for a quote. Best first. |
**`embd`**: the image's vector, binary: a u32 length, a u8 format (1: 16-bit floats), then the values,
little-endian. It is a unit vector, so any phrase's vector (from the same model's text encoder) scores
against it with a dot product: labels from a new list, or a search by meaning, need no new pass over
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;
`label_chunks.jai` reads and writes these two.

View File

@ -0,0 +1,111 @@
// The chunks a label model adds to a capture (.mcap version 3), each keyed by the model's name:
//
// "labl", in front: its labels, as text in the style of the Ex4 text formats
//
// @version 1
// engine: "onnxruntime 1.30.0, CUDA"
// date: 2026-10-08T21:04:11Z
// list: 9c4f0b2d81e3a675
// label: 0.0647 "Terminal"
// label: 0.0234 "Schematic"
//
// list: says which label list they were scored against (a hash of its names and prompts), so labels from an
// older list can be told apart. label: a score (0 to 1, the model's sigmoid) and a name, quoted
// with "" for a quote; best first. A line that cannot be read is skipped, as are unknown keys.
//
// "embd", in front: the image's vector, binary: u32 length, u8 format (1: 16-bit floats), then the
// values, little-endian. A unit vector, so a phrase's vector scores against it with a dot product.
MCAP_LABELS_TAG :: "labl";
MCAP_EMBEDDING_TAG :: "embd";
MCAP_LABELS_TEXT_VERSION :: 1;
MCAP_EMBEDDING_HALF :: 1;
Mcap_Label :: struct {
score: float;
name: string;
}
Mcap_Labels :: struct {
engine: string; // what ran it, such as "onnxruntime 1.30.0, CUDA"
date: string; // when, UTC: 2026-10-08T21:04:11Z
list: string; // the label list's hash
labels: [..] Mcap_Label; // best first
}
// A labl chunk's text. Temp.
mcap_labels_text :: (labels: Mcap_Labels) -> string {
builder: String_Builder;
builder.allocator = temp;
print_to_builder(*builder, "@version %\n", MCAP_LABELS_TEXT_VERSION);
print_to_builder(*builder, "engine: %\n", mcap_quote(labels.engine));
print_to_builder(*builder, "date: %\n", labels.date);
print_to_builder(*builder, "list: %\n", labels.list);
for labels.labels print_to_builder(*builder, "label: % %\n", formatFloat(it.score, trailing_width = 4, zero_removal = .NO), mcap_quote(it.name));
return builder_to_string(*builder,, temp);
}
// A labl chunk's labels; strings and arrays from the context allocator.
mcap_labels_parse :: (text: string) -> Mcap_Labels, ok: bool, error: string {
result: Mcap_Labels;
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_LABELS_TEXT_VERSION return result, false, tprint("its labels are version %, but this build reads up to %", rest, MCAP_LABELS_TEXT_VERSION);
version_seen = true;
continue;
}
if !version_seen return result, false, "its labels 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 "list"; result.list = copy_string(value);
case "label";
space := find_index_from_left(value, #char " ");
if space < 0 continue;
score, score_ok := string_to_float(slice(value, 0, space));
name, after, name_ok := mcap_read_quoted(trim_left(advance(value, space)));
if score_ok && name_ok && !trim(after).count array_add(*result.labels, .{score, copy_string(name)});
}
}
return result, true, "";
}
// An embd chunk's data for a vector stored as 16-bit floats. Temp.
mcap_embedding_bytes :: (half: [] u16) -> [] u8 {
bytes := NewArray(5 + half.count * 2, u8,, temp);
(cast(*u32) bytes.data).* = cast(u32) half.count;
bytes[4] = MCAP_EMBEDDING_HALF;
memcpy(bytes.data + 5, half.data, half.count * 2);
return bytes;
}
// An embd chunk's vector, as 16-bit floats; a copy from the context allocator.
mcap_embedding_parse :: (data: [] u8) -> [] u16, ok: bool, error: string {
if data.count < 5 return .[], false, "its embedding is cut short";
count := cast(s64) (cast(*u32) data.data).*;
if data[4] != MCAP_EMBEDDING_HALF return .[], false, tprint("its embedding is in format %, which this build does not read", data[4]);
if data.count != 5 + count * 2 return .[], false, "its embedding's length does not match its size";
half := NewArray(count, u16);
memcpy(half.data, data.data + 5, count * 2); // the data need not be aligned
return half, true, "";
}
// The chunk for labels by model, ready to store with mcap_store_chunk. Its data is temp.
mcap_labels_chunk :: (model: string, labels: Mcap_Labels) -> Mcap_Chunk {
return .{tag = mcap_tag(MCAP_LABELS_TAG), key = model, data = cast([] u8) mcap_labels_text(labels)};
}
mcap_embedding_chunk :: (model: string, half: [] u16) -> Mcap_Chunk {
return .{tag = mcap_tag(MCAP_EMBEDDING_TAG), key = model, data = mcap_embedding_bytes(half)};
}

View File

@ -1,5 +1,10 @@
// .mcap: one capture in one file. A fixed magic, a file version stamp, the capture's Capture Text, // .mcap: one capture in one file. A fixed magic, a file version stamp, the capture's Capture Text,
// its OCR text (version 2), a small preview, then the images. See docs/mcap.md for the layout. // its OCR text (version 2), its front chunks (version 3), a small preview, the images, then its back
// chunks (version 3). See docs/mcap.md for the layout.
//
// Chunks hold what models add (labels, embeddings, later masks), each named by a tag and a key (the
// model's name): small ones in front, where a library browser reads them without the images, large
// ones at the back. Chunks this build does not know are kept as they are when a file is rewritten.
// //
// Reading gives views into the bytes it was given; nothing is copied. Images stay PNG. // Reading gives views into the bytes it was given; nothing is copied. Images stay PNG.
// //
@ -7,7 +12,7 @@
// the same files. // the same files.
MCAP_MAGIC :: u8.[0x89, #char "M", #char "C", #char "A", #char "P", 0x0D, 0x0A, 0x1A]; MCAP_MAGIC :: u8.[0x89, #char "M", #char "C", #char "A", #char "P", 0x0D, 0x0A, 0x1A];
MCAP_VERSION :: 2; // what is written; every version from 1 up is read MCAP_VERSION :: 3; // what is written; every version from 1 up is read
MCAP_EXTENSION :: "mcap"; MCAP_EXTENSION :: "mcap";
MCAP_PREVIEW_MAX_SIDE :: 320; // the preview's longest side, in pixels MCAP_PREVIEW_MAX_SIDE :: 320; // the preview's longest side, in pixels
@ -23,15 +28,47 @@ Mcap_Image :: struct {
png: [] u8; // empty when there is none png: [] u8; // empty when there is none
} }
// What a model added to a capture, in its own format. A file holds one chunk per tag and key.
Mcap_Chunk :: struct {
tag: [4] u8; // what it is, in four ASCII letters, such as "labl" or "embd"
key: string; // whose: the model's name
data: [] u8;
}
Mcap :: struct { Mcap :: struct {
version: u32 = MCAP_VERSION; // as read; always MCAP_VERSION when written version: u32 = MCAP_VERSION; // as read; always MCAP_VERSION when written
text: string; // Capture Text text: string; // Capture Text
ocr: string; // OCR text (ocr_text.jai), one section per model; "" when none, and always in version 1 ocr: string; // OCR text (ocr_text.jai), one section per model; "" when none, and always in version 1
front_chunks: [] Mcap_Chunk; // small: read with the front. None before version 3
preview: Mcap_Image; preview: Mcap_Image;
original_name: string; // the original image's name in the .snagx, such as {GUID}.png original_name: string; // the original image's name in the .snagx, such as {GUID}.png
original: [] u8; // PNG original: [] u8; // PNG
rendered_kind: Mcap_Rendered_Kind; rendered_kind: Mcap_Rendered_Kind;
rendered: [] u8; // PNG; OWN only, see mcap_rendered rendered: [] u8; // PNG; OWN only, see mcap_rendered
back_chunks: [] Mcap_Chunk; // large: after the images. None before version 3
}
// "labl" as a tag.
mcap_tag :: (text: string) -> [4] u8 {
assert(text.count == 4, "a chunk tag is four letters");
tag: [4] u8;
for 0..3 tag[it] = text[it];
return tag;
}
mcap_tag_text :: (tag: [4] u8) -> string {
return tprint("%", to_string(tag.data, 4));
}
// The chunk with tag and key in chunks, or null.
mcap_find_chunk :: (chunks: [] Mcap_Chunk, tag: [4] u8, key: string) -> *Mcap_Chunk {
for * chunks if mcap_same_tag(it.tag, tag) && it.key == key return it;
return null;
}
mcap_same_tag :: (a: [4] u8, b: [4] u8) -> bool {
for a if it != b[it_index] return false;
return true;
} }
// The rendered image (Snagit's thumbnail.png) whichever way it is stored; empty when there is none. // The rendered image (Snagit's thumbnail.png) whichever way it is stored; empty when there is none.
@ -48,8 +85,9 @@ mcap_rendered :: (m: Mcap) -> [] u8 {
// The file's bytes. A part too large for its size field fails the whole file rather than writing one // The file's bytes. A part too large for its size field fails the whole file rather than writing one
// that cannot be read back. // that cannot be read back.
mcap_serialize :: (m: Mcap) -> Serializer, ok: bool, error: string { mcap_serialize :: (m: Mcap) -> Serializer, ok: bool, error: string {
size := MCAP_HEADER_SIZE + 4 + m.text.count + 4 + m.ocr.count + 8 + m.preview.png.count + 2 + m.original_name.count + 4 + m.original.count + 1; size := MCAP_HEADER_SIZE + 4 + m.text.count + 4 + m.ocr.count + mcap_chunks_size(m.front_chunks) + 8 + m.preview.png.count + 2 + m.original_name.count + 4 + m.original.count + 1;
if m.rendered_kind == .OWN size += 4 + m.rendered.count; if m.rendered_kind == .OWN size += 4 + m.rendered.count;
size += mcap_chunks_size(m.back_chunks);
s := new_serializer(size); s := new_serializer(size);
magic := MCAP_MAGIC; magic := MCAP_MAGIC;
@ -58,6 +96,8 @@ mcap_serialize :: (m: Mcap) -> Serializer, ok: bool, error: string {
if !AddString(*s, m.text, u32) return s, false, "the text is too large for its size field"; if !AddString(*s, m.text, u32) return s, false, "the text is too large for its size field";
if !AddString(*s, m.ocr, u32) return s, false, "the OCR text is too large for its size field"; if !AddString(*s, m.ocr, u32) return s, false, "the OCR text is too large for its size field";
ok, error := mcap_add_chunks(*s, m.front_chunks);
if !ok return s, false, error;
Add(*s, m.preview.width); Add(*s, m.preview.width);
Add(*s, m.preview.height); Add(*s, m.preview.height);
if !AddSizedArray(*s, m.preview.png, u32) return s, false, "the preview is too large for its size field"; if !AddSizedArray(*s, m.preview.png, u32) return s, false, "the preview is too large for its size field";
@ -65,6 +105,8 @@ mcap_serialize :: (m: Mcap) -> Serializer, ok: bool, error: string {
if !AddSizedArray(*s, m.original, u32) return s, false, "the original image is too large for its size field"; if !AddSizedArray(*s, m.original, u32) return s, false, "the original image is too large for its size field";
Add(*s, m.rendered_kind); Add(*s, m.rendered_kind);
if m.rendered_kind == .OWN && !AddSizedArray(*s, m.rendered, u32) return s, false, "the rendered image is too large for its size field"; if m.rendered_kind == .OWN && !AddSizedArray(*s, m.rendered, u32) return s, false, "the rendered image is too large for its size field";
ok, error = mcap_add_chunks(*s, m.back_chunks);
if !ok return s, false, error;
assert(s.count == size); assert(s.count == size);
return s, true, ""; return s, true, "";
@ -94,14 +136,18 @@ mcap_parse :: (data: [] u8) -> Mcap, ok: bool, error: string {
m.original = ReadSizedArray(*d, u8, u32); m.original = ReadSizedArray(*d, u8, u32);
m.rendered_kind = Read(*d, Mcap_Rendered_Kind); m.rendered_kind = Read(*d, Mcap_Rendered_Kind);
if d.ok && m.rendered_kind == .OWN m.rendered = ReadSizedArray(*d, u8, u32); if d.ok && m.rendered_kind == .OWN m.rendered = ReadSizedArray(*d, u8, u32);
if d.ok && m.version >= 3 {
m.back_chunks, ok, error = mcap_read_chunks(*d);
if !ok return m, false, error;
}
if !d.ok return m, false, "the file ends early (it is truncated or damaged)"; if !d.ok return m, false, "the file ends early (it is truncated or damaged)";
if cast(u8) m.rendered_kind > cast(u8) Mcap_Rendered_Kind.OWN return m, false, tprint("the rendered image is of unknown kind %", cast(u8) m.rendered_kind); if cast(u8) m.rendered_kind > cast(u8) Mcap_Rendered_Kind.OWN return m, false, tprint("the rendered image is of unknown kind %", cast(u8) m.rendered_kind);
if d.cursor != d.count return m, false, tprint("% bytes follow the last part", d.count - d.cursor); if d.cursor != d.count return m, false, tprint("% bytes follow the last part", d.count - d.cursor);
return m, true, ""; return m, true, "";
} }
// Only the front of the file, as a library browser needs it: the header, text, OCR text and, with // Only the front of the file, as a library browser needs it: the header, text, OCR text, front chunks
// preview, the preview. One read of the start of the file, and a few more only when a part runs past // and, with preview, the preview. One read of the start of the file, and a few more only when a part runs past
// it. The images are left unread and empty. The parts are views into a buffer from the context allocator. // it. The images are left unread and empty. The parts are views into a buffer from the context allocator.
mcap_read_front :: (path: string, preview := true) -> Mcap, ok: bool, error: string { mcap_read_front :: (path: string, preview := true) -> Mcap, ok: bool, error: string {
m: Mcap; m: Mcap;
@ -112,7 +158,7 @@ mcap_read_front :: (path: string, preview := true) -> Mcap, ok: bool, error: str
if !length_ok return m, false, "cannot be read"; if !length_ok return m, false, "cannot be read";
wanted := min(length, MCAP_FRONT_READ_SIZE); wanted := min(length, MCAP_FRONT_READ_SIZE);
for attempt: 1..5 { // each further read reaches at least one more size field for attempt: 1..MCAP_FRONT_READS {
buffer := NewArray(wanted, u8, initialized = false); buffer := NewArray(wanted, u8, initialized = false);
if !file_read(file, buffer.data, wanted) { array_free(buffer); return m, false, "cannot be read"; } if !file_read(file, buffer.data, wanted) { array_free(buffer); return m, false, "cannot be read"; }
@ -121,22 +167,25 @@ mcap_read_front :: (path: string, preview := true) -> Mcap, ok: bool, error: str
if ok return m, true, ""; if ok return m, true, "";
if d.ok || wanted == length { array_free(buffer); return m, false, error; } // not a matter of too few bytes if d.ok || wanted == length { array_free(buffer); return m, false, error; } // not a matter of too few bytes
// Short: the sizes read so far say how much is needed. // Short: the sizes read so far say how far the front reaches at least. Read that and as much
// again as the first read, so the size fields after it (the OCR, the chunk count, each chunk's
// size) come in the same read rather than one read each.
needed := mcap_front_size(buffer, preview); needed := mcap_front_size(buffer, preview);
array_free(buffer); array_free(buffer);
if needed <= wanted || needed > length return m, false, "the file ends early (it is truncated or damaged)"; if needed <= wanted || needed > length return m, false, "the file ends early (it is truncated or damaged)";
wanted = needed; wanted = min(needed + MCAP_FRONT_READ_SIZE, length);
if !file_set_position(file, 0) return m, false, "cannot be read"; if !file_set_position(file, 0) return m, false, "cannot be read";
} }
return m, false, "cannot be read"; return m, false, tprint("its front was not all read in % reads", MCAP_FRONT_READS);
} }
#scope_module #scope_module
MCAP_HEADER_SIZE :: 12; // magic and version MCAP_HEADER_SIZE :: 12; // magic and version
MCAP_FRONT_READ_SIZE :: 64 * 1024; // holds the header, text and preview of nearly every capture MCAP_FRONT_READ_SIZE :: 64 * 1024; // holds the header, text and preview of nearly every capture
MCAP_FRONT_READS :: 16; // reads of the front at most: each reaches past at least one more size field
// Header, text, OCR text and, with preview, the preview. // Header, text, OCR text, front chunks and, with preview, the preview.
mcap_read_front_part :: (d: *Deserializer, m: *Mcap, preview := true) -> ok: bool, error: string { mcap_read_front_part :: (d: *Deserializer, m: *Mcap, preview := true) -> ok: bool, error: string {
magic := ReadArrayView(d, MCAP_MAGIC.count, u8); magic := ReadArrayView(d, MCAP_MAGIC.count, u8);
if !d.ok return false, "is not an .mcap file (it is shorter than the magic)"; if !d.ok return false, "is not an .mcap file (it is shorter than the magic)";
@ -148,6 +197,12 @@ mcap_read_front_part :: (d: *Deserializer, m: *Mcap, preview := true) -> ok: boo
m.text = ReadSizedString(d, u32); m.text = ReadSizedString(d, u32);
if m.version >= 2 m.ocr = ReadSizedString(d, u32); // version 1 has no OCR block if m.version >= 2 m.ocr = ReadSizedString(d, u32); // version 1 has no OCR block
if d.ok && m.version >= 3 { // nor versions before 3 chunks
ok: bool;
error: string;
m.front_chunks, ok, error = mcap_read_chunks(d);
if !ok return false, error;
}
if !preview { if !preview {
if !d.ok return false, "the file ends early (it is truncated or damaged)"; if !d.ok return false, "the file ends early (it is truncated or damaged)";
return true, ""; return true, "";
@ -176,13 +231,131 @@ mcap_front_size :: (prefix: [] u8, preview := true) -> s64 {
} }
if !skip_sized(prefix, *position) return position; // the text if !skip_sized(prefix, *position) return position; // the text
if version >= 2 && !skip_sized(prefix, *position) return position; // the OCR text if version >= 2 && !skip_sized(prefix, *position) return position; // the OCR text
if version >= 3 { // the front chunks
if position + 4 > prefix.count return position + 4;
d.cursor = position;
count := Read(*d, u32);
position += 4;
for 1..count {
position += 4; // the tag
if !skip_sized(prefix, *position) return position; // its size and payload
}
}
if !preview return position; if !preview return position;
position += 4; // the preview's width and height position += 4; // the preview's width and height
skip_sized(prefix, *position); // the preview skip_sized(prefix, *position); // the preview
return position; return position;
} }
// Chunks: a u32 count, then each as its tag, a u32 size, and that many bytes: a u16 key size, the
// key, and the data.
mcap_chunks_size :: (chunks: [] Mcap_Chunk) -> s64 {
size: s64 = 4;
for chunks size += 4 + 4 + 2 + it.key.count + it.data.count;
return size;
}
mcap_add_chunks :: (s: *Serializer, chunks: [] Mcap_Chunk) -> ok: bool, error: string {
Add(s, cast(u32) chunks.count);
for chunks {
payload := 2 + it.key.count + it.data.count;
if it.key.count > 0xFFFF || payload > 0xFFFF_FFFF return false, tprint("the % chunk of % is too large for its size field", mcap_tag_text(it.tag), it.key);
tag := it.tag;
AddArray_NoSize(s, tag);
Add(s, cast(u32) payload);
AddString(s, it.key, u16);
AddArray_NoSize(s, it.data);
}
return true, "";
}
// Views into d's bytes; the list itself from the context allocator.
mcap_read_chunks :: (d: *Deserializer) -> [] Mcap_Chunk, ok: bool, error: string {
count := Read(d, u32);
if !d.ok return .[], false, "the file ends early (it is truncated or damaged)";
// Running out of bytes clears d.ok, as Read does: a front read then reads further, a whole parse fails.
if cast(s64) count * 10 > d.count - d.cursor { d.ok = false; return .[], false, tprint("it says it has % chunks, more than its bytes can hold", count); }
chunks := NewArray(count, Mcap_Chunk);
for * chunk: chunks {
tag := ReadArrayView(d, 4, u8);
payload := Read(d, u32);
if !d.ok || payload < 2 return chunks, false, "a chunk runs past the end of the file (it is truncated or damaged)";
if cast(s64) payload > d.count - d.cursor { d.ok = false; return chunks, false, "a chunk runs past the end of the file (it is truncated or damaged)"; }
for 0..3 chunk.tag[it] = tag[it];
chunk.key = ReadSizedString(d, u16);
if !d.ok || 2 + chunk.key.count > cast(s64) payload return chunks, false, "a chunk's key runs past the chunk";
chunk.data = ReadArrayView(d, payload - 2 - chunk.key.count, u8);
}
if !d.ok return chunks, false, "the file ends early (it is truncated or damaged)";
return chunks, true, "";
}
#scope_export
// 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
// same tag and key (every other chunk kept, known to this build or not). Upgrades the file to the
// current version. Rewritten beside itself and swapped in (mcap_write), so a failure leaves it as it was.
mcap_store :: (path: string, ocr: *Mcap_Ocr_Section = null, front: [] Mcap_Chunk = .[]) -> ok: bool, error: string {
data, read_ok := read_entire_file(path, log_errors = false);
if !read_ok return false, "cannot be read";
defer free(data);
m, parsed, parse_error := mcap_parse(cast([] u8) data);
if !parsed return false, parse_error;
push_allocator(temp);
if ocr {
sections, ok, error := mcap_ocr_parse(m.ocr);
if !ok return false, error;
replaced := false;
for * sections if it.model == ocr.model { it.* = ocr.*; replaced = true; break; }
if !replaced array_add(*sections, ocr.*);
m.ocr = mcap_ocr_text(sections);
}
m.front_chunks = mcap_chunks_with(m.front_chunks, front);
m.version = MCAP_VERSION;
serializer, serialized, serialize_error := mcap_serialize(m);
if !serialized return false, serialize_error;
written, write_error := mcap_write(path, serializer);
return written, write_error;
}
// Stores chunk alone: in front, or with back after the images. See mcap_store.
mcap_store_chunk :: (path: string, chunk: Mcap_Chunk, back := false) -> ok: bool, error: string {
if !back {
ok, error := mcap_store(path, front = .[chunk]);
return ok, error;
}
data, read_ok := read_entire_file(path, log_errors = false);
if !read_ok return false, "cannot be read";
defer free(data);
m, parsed, parse_error := mcap_parse(cast([] u8) data);
if !parsed return false, parse_error;
push_allocator(temp);
m.back_chunks = mcap_chunks_with(m.back_chunks, .[chunk]);
m.version = MCAP_VERSION;
serializer, serialized, serialize_error := mcap_serialize(m);
if !serialized return false, serialize_error;
written, write_error := mcap_write(path, serializer);
return written, write_error;
}
// chunks with each of added in place of the one with its tag and key, or after them. Temp.
mcap_chunks_with :: (chunks: [] Mcap_Chunk, added: [] Mcap_Chunk) -> [] Mcap_Chunk {
list: [..] Mcap_Chunk;
list.allocator = temp;
array_add(*list, ..chunks);
for added {
existing := mcap_find_chunk(list, it.tag, it.key);
if existing existing.* = it;
else array_add(*list, it);
}
return list;
}
#load "ocr_text.jai"; #load "ocr_text.jai";
#load "label_chunks.jai";
#import "Basic"; #import "Basic";
#import "File"; #import "File";

View File

@ -75,7 +75,7 @@ mcap_ocr_parse :: (text: string) -> [..] Mcap_Ocr_Section, ok: bool, error: stri
value := trim(advance(line, colon + 1)); value := trim(advance(line, colon + 1));
if key == { if key == {
case "engine"; case "engine";
quoted, _, ok := ocr_read_quoted(value); quoted, _, ok := mcap_read_quoted(value);
if ok section.engine = copy_string(quoted); if ok section.engine = copy_string(quoted);
case "image"; case "image";
parts := split(value, " ",, temp); parts := split(value, " ",, temp);
@ -102,13 +102,13 @@ mcap_ocr_text :: (sections: [] Mcap_Ocr_Section) -> string {
print_to_builder(*builder, "@version %\n", MCAP_OCR_TEXT_VERSION); print_to_builder(*builder, "@version %\n", MCAP_OCR_TEXT_VERSION);
for sections { for sections {
print_to_builder(*builder, "\n[ocr %]\n", it.model); print_to_builder(*builder, "\n[ocr %]\n", it.model);
print_to_builder(*builder, "engine: %\n", ocr_quote(it.engine)); print_to_builder(*builder, "engine: %\n", mcap_quote(it.engine));
print_to_builder(*builder, "image: % % %\n", it.image, it.image_width, it.image_height); print_to_builder(*builder, "image: % % %\n", it.image, it.image_width, it.image_height);
print_to_builder(*builder, "date: %\n", it.date); print_to_builder(*builder, "date: %\n", it.date);
for line: it.lines { for line: it.lines {
c := mcap_ocr_corners(line); c := mcap_ocr_corners(line);
print_to_builder(*builder, "line: % % % % % % % % % % % % % %\n", line.x, line.y, line.width, line.height, print_to_builder(*builder, "line: % % % % % % % % % % % % % %\n", line.x, line.y, line.width, line.height,
c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], formatFloat(line.confidence, trailing_width = 3, zero_removal = .NO), ocr_quote(line.text)); c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], formatFloat(line.confidence, trailing_width = 3, zero_removal = .NO), mcap_quote(line.text));
} }
} }
return builder_to_string(*builder,, temp); return builder_to_string(*builder,, temp);
@ -118,25 +118,8 @@ mcap_ocr_text :: (sections: [] Mcap_Ocr_Section) -> string {
// others, and upgrades the file to the current version. The file is rewritten beside itself and // others, and upgrades the file to the current version. The file is rewritten beside itself and
// swapped in (mcap_write), so a failure leaves it as it was. // swapped in (mcap_write), so a failure leaves it as it was.
mcap_store_ocr :: (path: string, section: Mcap_Ocr_Section) -> ok: bool, error: string { mcap_store_ocr :: (path: string, section: Mcap_Ocr_Section) -> ok: bool, error: string {
data, read_ok := read_entire_file(path, log_errors = false); ok, error := mcap_store(path, *section);
if !read_ok return false, "cannot be read"; return ok, error;
defer free(data);
m, parsed, parse_error := mcap_parse(cast([] u8) data);
if !parsed return false, parse_error;
push_allocator(temp);
sections, ok, error := mcap_ocr_parse(m.ocr);
if !ok return false, error;
replaced := false;
for * sections if it.model == section.model { it.* = section; replaced = true; break; }
if !replaced array_add(*sections, section);
m.ocr = mcap_ocr_text(sections);
m.version = MCAP_VERSION;
serializer, serialized, serialize_error := mcap_serialize(m);
if !serialized return false, serialize_error;
written, write_error := mcap_write(path, serializer);
return written, write_error;
} }
// The time now as the date: key writes it, UTC. Temp. // The time now as the date: key writes it, UTC. Temp.
@ -146,10 +129,10 @@ mcap_ocr_now :: () -> string {
return tprint("%-%-%T%:%:%Z", t.year, two(t.month_starting_at_0 + 1), two(t.day_of_month_starting_at_0 + 1), two(t.hour), two(t.minute), two(t.second)); return tprint("%-%-%T%:%:%Z", t.year, two(t.month_starting_at_0 + 1), two(t.day_of_month_starting_at_0 + 1), two(t.hour), two(t.minute), two(t.second));
} }
#scope_file #scope_module // the quoting is shared with label_chunks.jai
// Quoted, a quote written twice, a control character as a space. Temp. // Quoted, a quote written twice, a control character as a space. Temp.
ocr_quote :: (text: string) -> string { mcap_quote :: (text: string) -> string {
builder: String_Builder; builder: String_Builder;
builder.allocator = temp; builder.allocator = temp;
append(*builder, #char "\""); append(*builder, #char "\"");
@ -164,7 +147,7 @@ ocr_quote :: (text: string) -> string {
} }
// One quoted string ("" for a quote) at the start of s: its text (context allocator) and what follows. // One quoted string ("" for a quote) at the start of s: its text (context allocator) and what follows.
ocr_read_quoted :: (s: string) -> text: string, rest: string, ok: bool { mcap_read_quoted :: (s: string) -> text: string, rest: string, ok: bool {
if !s.count || s[0] != #char "\"" return "", s, false; if !s.count || s[0] != #char "\"" return "", s, false;
builder: String_Builder; builder: String_Builder;
i := 1; i := 1;
@ -195,7 +178,7 @@ ocr_parse_line :: (value: string, version: s64) -> Mcap_Ocr_Line, bool {
it.* = number; it.* = number;
rest = advance(rest, space); rest = advance(rest, space);
} }
text, after, ok := ocr_read_quoted(trim_left(rest)); text, after, ok := mcap_read_quoted(trim_left(rest));
if !ok || trim(after).count return result, false; if !ok || trim(after).count return result, false;
result.x, result.y = cast(s32) numbers[0], cast(s32) numbers[1]; result.x, result.y = cast(s32) numbers[0], cast(s32) numbers[1];
result.width, result.height = cast(s32) numbers[2], cast(s32) numbers[3]; result.width, result.height = cast(s32) numbers[2], cast(s32) numbers[3];

52
modules/Labels/README.md Normal file
View File

@ -0,0 +1,52 @@
# Labels
Images and text in one space, from an image-text encoder: SigLIP 2 base at 384 px (Google, Apache
2.0), as converted to ONNX by onnx-community. An image can then be scored against any phrase: a label
list, or words typed into a search. It runs ONNX models through onnxruntime
([`modules/onnxruntime`](../onnxruntime)), sharing its session and CUDA code with
[`modules/OCR`](../OCR).
## Setting up
```
cd modules/onnxruntime && jai fetch.jai # if OCR has not fetched it already
cd modules/Labels && jai fetch_models.jai # the vision model (fp32, 373 MB), the text model (int8, 283 MB), tokenizer.json (34 MB)
```
Each file's SHA-256 is checked. Building the app copies the models into `bin/label-models`.
## Using it
```jai
#import "Labels";
engine, ok, error := labels_create(models_folder, "siglip2-base-patch16-384"); // prefer_gpu = true
images := labels_embed_images(engine, pictures); // [] Labels_Image (RGBA): one unit vector each, engine.dimensions long
phrases := labels_embed_texts(engine, .["a photo of a cat", "a screenshot of a terminal"]);
score := labels_score(engine, images.data, phrases.data); // 0 to 1: the model's sigmoid
labels_destroy(engine);
```
- **Images** are resized to 384 x 384 (not keeping their shape) with PIL's bilinear filter, which
averages over each output pixel's footprint when shrinking, then scaled to -1..1: as SigLIP's own
processor does. The vision model runs on CUDA when it can, else on the CPU.
- **Text** is lowercased (ASCII letters) and tokenized with Gemma's tokenizer (`tokenizer.jai`: BPE
with byte fallback, read from `tokenizer.json`), cut or padded to 64 tokens. The text model runs
on the CPU: short inputs, a few at a time.
- **Scores** are `sigmoid(e^logit_scale * cosine + logit_bias)`, with the trained model's
`logit_scale` and `logit_bias` (in `models.config`; the ONNX encoders do not have them). They are
small for most phrases; their order is what counts. Descriptive prompts ("a screenshot of a code
editor showing source code") score far better than single words.
- `labels_to_half` and `labels_from_half` turn a vector into 16-bit floats, as `.mcap` files store
them, and back.
## Checked against the reference
- **The tokenizer** gives the same ids as Hugging Face's `tokenizers` (0.23.2) on the same
`tokenizer.json`, for 336 strings: label phrases, punctuation, digits, accents, Chinese, Japanese,
emoji, tabs and newlines, and text past 64 tokens.
- **The vectors** match SigLIP's own preprocessing through the same ONNX files (Python, onnxruntime
and PIL): image vectors at a cosine of 0.9998 or more, text vectors exactly, scores within 0.001.
On the RTX 5080 laptop, an image takes about 18 ms on the GPU (preparing it included) and 190 ms on
the CPU; a phrase about 5 ms.

View File

@ -0,0 +1,67 @@
// Downloads the files models.config names into models/, checking each one's SHA-256. The models
// are not in git; run this after cloning. Files already there with the right hash are skipped.
//
// jai fetch_models.jai (about 690 MB)
//
// SigLIP 2 (Google, Apache 2.0), as converted to ONNX by onnx-community:
// https://huggingface.co/onnx-community/siglip2-base-patch16-384-ONNX
SOURCE :: "https://huggingface.co/onnx-community/siglip2-base-patch16-384-ONNX/resolve/main";
Model_File :: struct { name: string; source_path: string; sha256: string; }
FILES :: Model_File.[
.{"siglip2-base-patch16-384-vision.onnx", "onnx/vision_model.onnx", "49ae4958b1098ca995e929d646f7be05a69c65e6344beae07d58c6598ffc5210"},
.{"siglip2-base-patch16-384-text-int8.onnx", "onnx/text_model_int8.onnx", "30a94b43b9ddc39c04ab67f80cba4b808262682b2fa1a2a8b924bf1e7bd84c61"},
.{"siglip2-tokenizer.json", "tokenizer.json", "cb9140fae3ac5122c972d37adf83e1248471a38147ad76f8215c8872c6fd8322"},
];
#run,stallable { if !fetch() exit(1); }
fetch :: () -> bool {
set_build_options_dc(.{do_output = false});
here := trim_right(path_strip_filename(#file), "/\\");
folder := tprint("%/models", here);
make_directory_if_it_does_not_exist(folder);
for FILES {
path := tprint("%/%", folder, it.name);
if file_exists(path) && sha256_of(path) == it.sha256 { log("% is already here.", it.name); continue; }
url := tprint("%/%", SOURCE, it.source_path);
log("Downloading %...", url);
result := run_command("curl", "-L", "--fail", "--progress-bar", "-o", path, url);
if result.exit_code != 0 { log_error("curl failed for % (exit code %).", it.name, result.exit_code); return false; }
hash := sha256_of(path);
if hash != it.sha256 {
log_error("% has SHA-256 %, expected %; it was deleted.", it.name, hash, it.sha256);
file_delete(path);
return false;
}
}
log("The models are in %.", folder);
return true;
}
// Lowercase hex, or "" when it could not be worked out.
sha256_of :: (path: string) -> string {
#if OS == .WINDOWS {
result, output := run_command("certutil", "-hashfile", path, "SHA256", capture_and_return_output = true);
if result.exit_code != 0 return "";
lines := split(output, "\n");
if lines.count < 2 return "";
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
} else {
command := ifx OS == .MACOS then string.["shasum", "-a", "256", path] else string.["sha256sum", path];
result, output := run_command(..command, capture_and_return_output = true);
if result.exit_code != 0 return "";
return to_lower_copy(trim(split(output, " ")[0]));
}
}
#import "Basic";
#import "Compiler";
#import "File";
#import "File_Utilities";
#import "Process";
#import "String";

View File

@ -0,0 +1,23 @@
@version 1
# Label models: image-text encoders that place images and text in one space, so an image can be
# scored against any phrase. Each [model NAME] names the files it needs from this folder
# (fetch_models.jai downloads them) and how it is run; labels_create takes the NAME.
#
# title shown to people
# vision, text, tokenizer file names, quoted: the image encoder, the text encoder, tokenizer.json
# image_size images are resized to this square for the vision model (px)
# max_tokens text is cut or padded to this many tokens
# logit_scale, logit_bias a phrase's score for an image is sigmoid(e^logit_scale * cosine + logit_bias),
# from the trained model (they are not in the ONNX encoders)
# image_batch images run through the vision model together
[model siglip2-base-patch16-384]
title: "SigLIP 2 base, 384 px"
vision: "siglip2-base-patch16-384-vision.onnx"
text: "siglip2-base-patch16-384-text-int8.onnx"
tokenizer: "siglip2-tokenizer.json"
image_size: 384
max_tokens: 64
logit_scale: 4.726024
logit_bias: -16.772243
image_batch: 8

381
modules/Labels/module.jai Normal file
View File

@ -0,0 +1,381 @@
// Labels: images and text in one space, from an image-text encoder (SigLIP 2), so an image can be
// scored against any phrase: a list of labels, or words typed into a search.
//
// engine, ok, error := labels_create(models_folder, "siglip2-base-patch16-384");
// images := labels_embed_images(engine, pictures); // one unit vector per picture
// phrases := labels_embed_texts(engine, .["code editor", "schematic"]);
// score := labels_score(engine, image_vector, phrase_vector); // 0 to 1
//
// The models are listed in models_folder/models.config (fetch_models.jai downloads them) and run
// with onnxruntime (modules/onnxruntime): the vision model on an NVIDIA GPU through CUDA when it
// can, else on the CPU; the text model, small inputs and only a few at a time, on the CPU.
Labels_Provider :: enum { CPU; CUDA; }
// Tightly packed or not: stride is the bytes from one row to the next.
Labels_Image :: struct {
pixels: *u8; // RGBA
width, height, stride: s32;
}
Labels_Model_Info :: struct {
name: string;
title: string;
vision, text, tokenizer: string; // file names in the models folder
image_size: s32 = 384;
max_tokens: s32 = 64;
logit_scale: float = 0;
logit_bias: float = 0;
image_batch: s32 = 8;
}
Labels_Engine :: struct {
model: Labels_Model_Info;
provider: Labels_Provider; // the vision model's
gpu_explanation: string; // why the GPU is not in use; "" when it is
env: *OrtEnv;
memory_info: *OrtMemoryInfo;
vision, text: Ort_Session;
tokenizer: *Gemma_Tokenizer;
dimensions: s32; // of every vector
}
LABELS_MODELS_CONFIG :: "models.config";
// The models models.config lists, in its order; from the context allocator.
labels_list_models :: (models_folder: string) -> [] Labels_Model_Info, error: string {
models: [..] Labels_Model_Info;
path := tprint("%/%", models_folder, LABELS_MODELS_CONFIG);
text, ok := read_entire_file(path, log_errors = false,, temp);
if !ok return models, tprint("Cannot read %.", path);
model: *Labels_Model_Info;
version_seen := false;
line_number := 0;
for raw: split(text, "\n",, temp) {
line_number += 1;
line := trim(labels_strip_comment(raw));
if !line.count continue;
if begins_with(line, "@version") { version_seen = true; continue; }
if !version_seen return models, tprint("% does not start with @version 1.", path);
if begins_with(line, "[model ") && ends_with(line, "]") {
model = array_add(*models);
model.* = .{};
model.name = copy_string(trim(slice(line, 7, line.count - 8)));
continue;
}
colon := find_index_from_left(line, #char ":");
if colon < 0 || !model { log_error("[Labels] %:%: expected `key: value` under a [model NAME]. Ignored.", path, line_number); continue; }
key := trim(slice(line, 0, colon));
value := trim(advance(line, colon + 1));
if value.count >= 2 && value[0] == #char "\"" && value[value.count - 1] == #char "\"" value = slice(value, 1, value.count - 2);
number, number_ok := string_to_float(value);
if key == {
case "title"; model.title = copy_string(value);
case "vision"; model.vision = copy_string(value);
case "text"; model.text = copy_string(value);
case "tokenizer"; model.tokenizer = copy_string(value);
case "image_size"; if number_ok model.image_size = cast(s32) number;
case "max_tokens"; if number_ok model.max_tokens = cast(s32) number;
case "logit_scale"; if number_ok model.logit_scale = number;
case "logit_bias"; if number_ok model.logit_bias = number;
case "image_batch"; if number_ok model.image_batch = cast(s32) number;
case; log_error("[Labels] %:%: unknown key %. Ignored.", path, line_number, key);
}
}
return models, "";
}
// An engine for the model named in models_folder/models.config; its vision model on CUDA with
// prefer_gpu when every library that needs is present, else on the CPU.
labels_create :: (models_folder: string, model_name: string, prefer_gpu := true) -> *Labels_Engine, ok: bool, error: string {
push_allocator(context.default_allocator);
loaded, load_error := ort_load();
if !loaded return null, false, load_error;
models, models_error := labels_list_models(models_folder);
if models_error return null, false, models_error;
model: *Labels_Model_Info;
for * models if it.name == model_name { model = it; break; }
if !model return null, false, tprint("% has no [model %].", LABELS_MODELS_CONFIG, model_name);
engine := New(Labels_Engine);
engine.model = model.*;
api := ort.api;
ok, error := ort_check(api.CreateEnv(.ORT_LOGGING_LEVEL_WARNING, "labels", *engine.env));
if !ok { labels_destroy(engine); return null, false, error; }
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
engine.tokenizer, ok, error = tokenizer_load(tprint("%/%", models_folder, model.tokenizer));
if !ok { labels_destroy(engine); return null, false, error; }
HINT :: "run `jai fetch_models.jai` in modules/Labels and rebuild";
cores := cast(s32) max(get_number_of_processors(.ALL_LOGICAL), 1);
vision_path := tprint("%/%", models_folder, model.vision);
use_gpu := false;
if prefer_gpu {
available, explanation := ort_cuda_check("Labelling");
use_gpu = available;
if !available engine.gpu_explanation = copy_string(explanation);
} else {
engine.gpu_explanation = copy_string("Labelling runs on the CPU, as it was asked to.");
}
if use_gpu {
ort_gpu_lock(); // one CUDA engine made or released at a time (modules/onnxruntime/session.jai)
engine.vision, ok, error = ort_create_session(engine.env, vision_path, true, 2, output = "pooler_output", missing_hint = HINT);
if ok ok, error = labels_warm_up(engine);
if !ok ort_release_session(*engine.vision);
ort_gpu_unlock();
if ok {
engine.provider = .CUDA;
} else {
engine.gpu_explanation = sprint("Labelling runs on the CPU: the CUDA libraries were found, but onnxruntime could not start on the GPU:\n%", error);
use_gpu = false;
}
}
if !use_gpu {
engine.vision, ok, error = ort_create_session(engine.env, vision_path, false, cores, output = "pooler_output", missing_hint = HINT);
if !ok { labels_destroy(engine); return null, false, error; }
engine.provider = .CPU;
}
engine.text, ok, error = ort_create_session(engine.env, tprint("%/%", models_folder, model.text), false, max(cores / 2, 1), output = "pooler_output", missing_hint = HINT);
if !ok { labels_destroy(engine); return null, false, error; }
// The vectors' size, from a run of the text model on an empty text.
probe := labels_embed_texts(engine, .[""],, temp);
if !engine.dimensions { labels_destroy(engine); return null, false, "The text model gave no vector."; }
return engine, true, "";
}
labels_destroy :: (engine: *Labels_Engine) {
if !engine return;
if engine.provider == .CUDA ort_gpu_lock();
ort_release_session(*engine.vision);
if engine.provider == .CUDA ort_gpu_unlock();
ort_release_session(*engine.text);
tokenizer_free(engine.tokenizer,, context.default_allocator);
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
if engine.env ort.api.ReleaseEnv(engine.env);
free(engine.gpu_explanation,, context.default_allocator);
free(engine,, context.default_allocator);
}
labels_gpu_status :: (engine: *Labels_Engine) -> in_use: bool, explanation: string {
return engine.provider == .CUDA, engine.gpu_explanation;
}
// One unit vector per image, engine.dimensions long, one after another; from allocator. An image
// that fails gives a zero vector, and error says why (the last error).
labels_embed_images :: (engine: *Labels_Engine, images: [] Labels_Image, allocator := context.allocator) -> [] float, error: string {
size := engine.model.image_size;
dims := engine.dimensions;
result := NewArray(images.count * dims, float, initialized = true,, allocator);
error: string;
plane := cast(s64) size * size;
batch := cast(s64) max(engine.model.image_batch, 1);
input := NewArray(plane * 3 * batch, float,, context.default_allocator);
defer array_free(input,, context.default_allocator);
first := 0;
while first < images.count {
count := min(batch, images.count - first);
for 0..count - 1 labels_prepare_image(images[first + it], size, input.data + plane * 3 * it);
shape := s64.[count, 3, size, size];
out, ok, run_error := ort_run_float(*engine.vision, engine.memory_info, array_view(input, 0, plane * 3 * count), shape);
if !ok {
error = tprint("%", run_error);
} else {
if out.dim_count == 2 && out.dims[0] == count && out.dims[1] == dims {
for n: 0..count - 1 labels_normalize_into(out.data + n * dims, dims, result.data + (first + n) * dims);
} else {
error = tprint("The vision model gave an output of shape %, not [%, %].", array_view(out.dims, 0, out.dim_count), count, dims);
}
ort_release_output(*out);
}
first += count;
}
return result, error;
}
// One unit vector per text, as labels_embed_images gives them; from allocator.
labels_embed_texts :: (engine: *Labels_Engine, texts: [] string, allocator := context.allocator) -> [] float, error: string {
if !texts.count return .[], "";
tokens := engine.model.max_tokens;
ids := NewArray(texts.count * tokens, s64,, temp);
for text, n: texts {
encoded := tokenizer_encode(engine.tokenizer, text, tokens);
memcpy(ids.data + n * tokens, encoded.data, tokens * size_of(s64));
}
shape := s64.[texts.count, tokens];
out, ok, run_error := ort_run(*engine.text, engine.memory_info, ids.data, ids.count * size_of(s64), .ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64, shape);
if !ok return .[], tprint("%", run_error);
defer ort_release_output(*out);
if out.dim_count != 2 || out.dims[0] != texts.count return .[], tprint("The text model gave an output of shape %.", array_view(out.dims, 0, out.dim_count));
dims := cast(s32) out.dims[1];
if !engine.dimensions engine.dimensions = dims;
result := NewArray(texts.count * dims, float,, allocator);
for n: 0..texts.count - 1 labels_normalize_into(out.data + n * dims, dims, result.data + n * dims);
return result, "";
}
// How well a phrase fits an image, 0 to 1, from their unit vectors: the model's own sigmoid.
labels_score :: (engine: *Labels_Engine, image: *float, text: *float) -> float {
return labels_score(engine.model, image, text, engine.dimensions);
}
labels_score :: (model: Labels_Model_Info, image: *float, text: *float, dimensions: s64) -> float {
cosine: float;
for 0..dimensions - 1 cosine += image[it] * text[it];
logit := exp(model.logit_scale) * cosine + model.logit_bias;
return 1.0 / (1.0 + exp(-logit));
}
// A vector as half floats, for storing; and back. Temp / allocator.
labels_to_half :: (vector: [] float) -> [] u16 {
result := NewArray(vector.count, u16,, temp);
for vector result[it_index] = labels_float_to_half(it);
return result;
}
labels_from_half :: (half: [] u16, allocator := context.allocator) -> [] float {
result := NewArray(half.count, float,, allocator);
for half result[it_index] = float16_to_float32(cast(Float16) it);
return result;
}
// A float as 16-bit float bits: the Float16 module's float_to_half_fast3 (Fabian Giesen's, public
// domain), copied with #no_aoc. It relies on unsigned wraparound, which a build with arithmetic
// overflow checks (the app's release build) stops at; the module's own is not marked.
labels_float_to_half :: (value: float32) -> u16 #no_aoc {
f32_infinity :: cast(u32) 255 << 23;
f16_infinity :: cast(u32) 31 << 23;
magic : float32 : 0h0780_0000;
sign_mask : u32 : 0x8000_0000;
round_mask : u32 : 0xffff_f000;
using bits: union { f: float32; u: u32; }
f = value;
sign := u & sign_mask;
u ^= sign;
half: u16;
if u >= f32_infinity {
half = ifx u > f32_infinity then cast(u16) 0x7e00 else 0x7c00; // NaN to a quiet NaN, infinity to infinity
} else { // normal, denormal or zero
u &= round_mask;
f *= magic;
u -= round_mask;
if u > f16_infinity u = f16_infinity; // too large: infinity
half = cast(u16) (u >> 13);
}
return half | cast(u16) (sign >> 16);
}
#scope_module
// Runs the vision model once, on a blank image, when it is made. As OCR's engines are (see
// ocr_warm_up): with cuBLASLt 13.8, the process's first CUDA session destroyed without ever having
// run left the next one to crash on its first run.
labels_warm_up :: (engine: *Labels_Engine) -> ok: bool, error: string {
size := engine.model.image_size;
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
defer array_free(input,, context.default_allocator);
out, ok, error := ort_run_float(*engine.vision, engine.memory_info, input, s64.[1, 3, size, size]);
if !ok return false, tprint("The first run on the GPU failed: %", error);
ort_release_output(*out);
return true, "";
}
// The image resized to size x size (not keeping its shape, as SigLIP's processor does) with PIL's
// bilinear filter, which averages over each output pixel's footprint when shrinking; then as planar
// RGB, scaled to -1..1, at dest.
labels_prepare_image :: (image: Labels_Image, size: s32, dest: *float) {
// Horizontal pass into rows of size floats per channel, then vertical.
across := labels_resample_weights(image.width, size);
down := labels_resample_weights(image.height, size);
rows := NewArray(cast(s64) image.height * size * 3, float,, temp);
for y: 0..image.height - 1 {
row := image.pixels + cast(s64) y * image.stride;
for x: 0..size - 1 {
w := *across[x];
r, g, b: float;
for k: 0..w.weights.count - 1 {
p := row + (w.first + k) * 4;
r += p[0] * w.weights[k]; g += p[1] * w.weights[k]; b += p[2] * w.weights[k];
}
at := (cast(s64) y * size + x) * 3;
rows[at] = r; rows[at + 1] = g; rows[at + 2] = b;
}
}
plane := cast(s64) size * size;
for y: 0..size - 1 {
w := *down[y];
for x: 0..size - 1 {
r, g, b: float;
for k: 0..w.weights.count - 1 {
at := (cast(s64) (w.first + k) * size + x) * 3;
r += rows[at] * w.weights[k]; g += rows[at + 1] * w.weights[k]; b += rows[at + 2] * w.weights[k];
}
i := cast(s64) y * size + x;
dest[i] = clamp(r, 0, 255) / 127.5 - 1; // (v / 255 - 0.5) / 0.5
dest[i + plane] = clamp(g, 0, 255) / 127.5 - 1;
dest[i + plane * 2] = clamp(b, 0, 255) / 127.5 - 1;
}
}
}
Resample_Weights :: struct { first: s32; weights: [] float; }
// For each of out positions, the input positions it takes and their weights: a triangle filter
// widened by the scale when shrinking, as PIL's bilinear resize. Temp.
labels_resample_weights :: (in: s32, out: s32) -> [] Resample_Weights {
result := NewArray(out, Resample_Weights,, temp);
scale := cast(float) in / out;
filter_scale := max(scale, 1.0);
support := filter_scale; // the triangle reaches 1 unit, scaled
for i: 0..out - 1 {
centre := (i + 0.5) * scale;
first := max(cast(s32) (centre - support + 0.5), 0);
last := min(cast(s32) (centre + support + 0.5), in);
weights := NewArray(max(last - first, 1), float,, temp);
total: float;
for k: 0..last - first - 1 {
x := (first + k - centre + 0.5) / filter_scale;
w := max(0.0, 1 - abs(x));
weights[k] = w;
total += w;
}
if total > 0 for * weights it.* /= total;
else { first = clamp(cast(s32) centre, 0, in - 1); weights[0] = 1; }
result[i] = .{first, weights};
}
return result;
}
labels_normalize_into :: (source: *float, dims: s64, dest: *float) {
length: float;
for 0..dims - 1 length += source[it] * source[it];
length = sqrt(length);
scale := ifx length > 0 then 1 / length else 0;
for 0..dims - 1 dest[it] = source[it] * scale;
}
labels_strip_comment :: (line: string) -> string {
in_quotes := false;
for i: 0..line.count - 1 {
c := line[i];
if c == #char "\"" in_quotes = !in_quotes;
else if c == #char "#" && !in_quotes && (i == 0 || line[i - 1] == #char " " || line[i - 1] == #char "\t") return slice(line, 0, i);
}
return line;
}
#load "tokenizer.jai";
#import "Basic";
#import "String";
#import "File";
#import "Math";
#import "System";
#import "Hash_Table";
#import "Float16";
#import "onnxruntime";

View File

@ -0,0 +1,248 @@
// The tokenizer SigLIP 2's text model was trained with: Gemma's, a SentencePiece-style BPE with
// byte fallback, read from its tokenizer.json. What it does, as that file says:
//
// - spaces become "▁" (the normalizer), and the whole text is then one word;
// - each character is a piece, or when the vocabulary has no such piece, its UTF-8 bytes as
// <0xXX> pieces (byte fallback);
// - neighbouring pieces are merged, the pair with the lowest rank in the merges list first and the
// leftmost of equal ranks, until no listed pair is left;
// - <eos> goes at the end, and <pad> fills it out to max_length (64, as SigLIP 2 was trained).
//
// Before all that, text is lowercased, as SigLIP 2 was trained with lowercased text. Only ASCII
// letters are lowered here; other letters keep their case.
Gemma_Tokenizer :: struct {
text: string; // tokenizer.json, which unescaped pieces point into
vocab: Table(string, s32); // piece to id
merges: Table(u64, Merge); // left id << 32 | right id, to the merge
byte_ids: [256] s32; // <0x00>..<0xFF>
unk, eos, pad: s32;
}
Merge :: struct { rank: s32; result: s32; }
GEMMA_SPACE :: "\xE2\x96\x81"; // ▁, U+2581
// Reads tokenizer.json. Everything from the context allocator; tokenizer_free frees it.
tokenizer_load :: (path: string) -> *Gemma_Tokenizer, ok: bool, error: string {
text, read := read_entire_file(path, log_errors = false);
if !read return null, false, tprint("Cannot read %.", path);
t := New(Gemma_Tokenizer);
t.text = text;
// "vocab": { "<pad>": 0, ... }
p := find_index_from_left(text, "\"vocab\"");
if p < 0 { tokenizer_free(t); return null, false, tprint("% has no vocab.", path); }
rest := advance(text, p + 7);
if !json_expect(*rest, #char ":") || !json_expect(*rest, #char "{") { tokenizer_free(t); return null, false, tprint("% has a vocab that is not an object.", path); }
table_ensure_space(*t.vocab, 300_000);
while true {
json_skip_space(*rest);
if rest.count && rest[0] == #char "}" { advance(*rest); break; }
piece, ok := json_string(*rest);
if !ok || !json_expect(*rest, #char ":") { tokenizer_free(t); return null, false, tprint("% has a vocab entry that cannot be read.", path); }
json_skip_space(*rest);
id, number_ok, after := string_to_int(rest, T = s64);
if !number_ok { tokenizer_free(t); return null, false, tprint("% has a vocab id that cannot be read.", path); }
rest = after;
table_set(*t.vocab, piece, cast(s32) id);
json_skip_space(*rest);
if rest.count && rest[0] == #char "," advance(*rest);
}
// "merges": [ ["a", "b"], ... ]
p = find_index_from_left(rest, "\"merges\"");
if p < 0 { tokenizer_free(t); return null, false, tprint("% has no merges.", path); }
rest = advance(rest, p + 8);
if !json_expect(*rest, #char ":") || !json_expect(*rest, #char "[") { tokenizer_free(t); return null, false, tprint("% has merges that are not a list.", path); }
table_ensure_space(*t.merges, 800_000);
rank: s32 = 0;
joined: [..] u8; // each merge's two pieces end to end, to look up their result
defer array_free(joined);
while true {
json_skip_space(*rest);
if rest.count && rest[0] == #char "]" { advance(*rest); break; }
if !json_expect(*rest, #char "[") { tokenizer_free(t); return null, false, tprint("% has a merge that is not a pair (older \"a b\" merges are not read).", path); }
left, left_ok := json_string(*rest);
json_expect(*rest, #char ",");
right, right_ok := json_string(*rest);
if !left_ok || !right_ok || !json_expect(*rest, #char "]") { tokenizer_free(t); return null, false, tprint("% has a merge that cannot be read.", path); }
json_skip_space(*rest);
if rest.count && rest[0] == #char "," advance(*rest);
left_found, left_id := table_find(*t.vocab, left);
right_found, right_id := table_find(*t.vocab, right);
joined.count = 0;
array_add(*joined, ..cast([] u8) left);
array_add(*joined, ..cast([] u8) right);
result_found, result_id := table_find(*t.vocab, cast(string) joined);
if left_found && right_found && result_found {
table_set(*t.merges, ((cast(u64) left_id) << 32) | cast(u64) cast(u32) right_id, .{rank, result_id});
}
rank += 1;
}
found: bool;
found, t.unk = table_find(*t.vocab, "<unk>");
if !found { tokenizer_free(t); return null, false, tprint("% has no <unk>.", path); }
found, t.eos = table_find(*t.vocab, "<eos>");
if !found { tokenizer_free(t); return null, false, tprint("% has no <eos>.", path); }
found, t.pad = table_find(*t.vocab, "<pad>");
if !found { tokenizer_free(t); return null, false, tprint("% has no <pad>.", path); }
for 0..255 {
byte_found, id := table_find(*t.vocab, tprint("<0x%>", formatInt(it, base = 16, minimum_digits = 2)));
t.byte_ids[it] = ifx byte_found then id else t.unk;
}
return t, true, "";
}
tokenizer_free :: (t: *Gemma_Tokenizer) {
if !t return;
// Pieces with escapes were copied; the others point into text.
for _, piece: t.vocab if !tokenizer_points_into(t, piece) free(piece);
table_deinit(*t.vocab);
table_deinit(*t.merges);
free(t.text);
free(t);
}
// text as max_length token ids: its pieces (at most max_length - 1), <eos>, then <pad>. Temp.
tokenizer_encode :: (t: *Gemma_Tokenizer, text: string, max_length := 64) -> [] s64 {
// Lowercased, spaces as ▁.
normal: String_Builder;
normal.allocator = temp;
for 0..text.count - 1 {
c := text[it];
if c == #char " " append(*normal, GEMMA_SPACE);
else if c >= #char "A" && c <= #char "Z" append(*normal, c + (#char "a" - #char "A"));
else append(*normal, c);
}
word := builder_to_string(*normal,, temp);
// One piece per character; byte pieces for those the vocabulary lacks.
symbols: [..] s32;
symbols.allocator = temp;
i := 0;
while i < word.count {
length := utf8_length(word[i]);
if i + length > word.count length = word.count - i; // a cut-off sequence: its bytes
found, id := table_find(*t.vocab, slice(word, i, length));
if found array_add(*symbols, id);
else for b: 0..length - 1 array_add(*symbols, t.byte_ids[word[i + b]]);
i += length;
}
// Merges, lowest rank first, the leftmost of equal ranks.
while symbols.count > 1 {
best := -1;
best_merge: Merge;
for 0..symbols.count - 2 {
found, merge := table_find(*t.merges, ((cast(u64) symbols[it]) << 32) | cast(u64) cast(u32) symbols[it + 1]);
if found && (best < 0 || merge.rank < best_merge.rank) { best = it; best_merge = merge; }
}
if best < 0 break;
symbols[best] = best_merge.result;
array_ordered_remove_by_index(*symbols, best + 1);
}
ids := NewArray(max_length, s64,, temp);
kept := min(symbols.count, max_length - 1);
for 0..kept - 1 ids[it] = symbols[it];
ids[kept] = t.eos;
for kept + 1..max_length - 1 ids[it] = t.pad;
return ids;
}
#scope_file
// The length of the UTF-8 sequence a byte starts: 1 for ASCII and stray continuation bytes.
utf8_length :: (lead: u8) -> s64 {
if lead < 0xC0 return 1;
if lead < 0xE0 return 2;
if lead < 0xF0 return 3;
return 4;
}
tokenizer_points_into :: (t: *Gemma_Tokenizer, s: string) -> bool {
return s.data >= t.text.data && s.data < t.text.data + t.text.count;
}
json_skip_space :: (s: *string) {
while s.count && (s.*[0] == #char " " || s.*[0] == #char "\n" || s.*[0] == #char "\r" || s.*[0] == #char "\t") advance(s);
}
json_expect :: (s: *string, c: u8) -> bool {
json_skip_space(s);
if !s.count || s.*[0] != c return false;
advance(s);
return true;
}
// A JSON string at the start of s: a view into s when it has no escapes, else unescaped into a
// copy from the context allocator.
json_string :: (s: *string) -> string, ok: bool {
json_skip_space(s);
if !s.count || s.*[0] != #char "\"" return "", false;
advance(s);
end := 0;
escaped := false;
while end < s.count && s.*[end] != #char "\"" {
if s.*[end] == #char "\\" { escaped = true; end += 1; }
end += 1;
}
if end >= s.count return "", false;
raw := slice(s.*, 0, end);
advance(s, end + 1);
if !escaped return raw, true;
builder: String_Builder;
i := 0;
while i < raw.count {
c := raw[i];
if c != #char "\\" { append(*builder, c); i += 1; continue; }
if i + 1 >= raw.count return "", false;
e := raw[i + 1];
i += 2;
if e == {
case #char "n"; append(*builder, #char "\n");
case #char "t"; append(*builder, #char "\t");
case #char "r"; append(*builder, #char "\r");
case #char "b"; append(*builder, 8);
case #char "f"; append(*builder, 12);
case #char "u";
code, ok := hex4(raw, i);
if !ok return "", false;
i += 4;
if code >= 0xD800 && code < 0xDC00 && i + 6 <= raw.count && raw[i] == #char "\\" && raw[i + 1] == #char "u" {
low, low_ok := hex4(raw, i + 2);
if low_ok && low >= 0xDC00 && low < 0xE000 { code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); i += 6; }
}
append_utf8(*builder, code);
case; append(*builder, e); // \" \\ \/
}
}
return builder_to_string(*builder), true;
}
hex4 :: (s: string, at: s64) -> u32, ok: bool {
if at + 4 > s.count return 0, false;
value: u32;
for 0..3 {
c := s[at + it];
digit: u32;
if c >= #char "0" && c <= #char "9" digit = c - #char "0";
else if c >= #char "a" && c <= #char "f" digit = c - #char "a" + 10;
else if c >= #char "A" && c <= #char "F" digit = c - #char "A" + 10;
else return 0, false;
value = value * 16 + digit;
}
return value, true;
}
append_utf8 :: (builder: *String_Builder, code: u32) {
if code < 0x80 { append(builder, cast(u8) code); return; }
if code < 0x800 { append(builder, cast(u8) (0xC0 | (code >> 6))); append(builder, cast(u8) (0x80 | (code & 0x3F))); return; }
if code < 0x10000 { append(builder, cast(u8) (0xE0 | (code >> 12))); append(builder, cast(u8) (0x80 | ((code >> 6) & 0x3F))); append(builder, cast(u8) (0x80 | (code & 0x3F))); return; }
append(builder, cast(u8) (0xF0 | (code >> 18))); append(builder, cast(u8) (0x80 | ((code >> 12) & 0x3F)));
append(builder, cast(u8) (0x80 | ((code >> 6) & 0x3F))); append(builder, cast(u8) (0x80 | (code & 0x3F)));
}

View File

@ -79,7 +79,7 @@ new size, and the server model ran 5 times slower. Several copies of the session
CUDA streams, made both models slower. GPU memory still peaks near full with the server model; that CUDA streams, made both models slower. GPU memory still peaks near full with the server model; that
is for a later change. is for a later change.
`ocr_create` looks for each of these first (`cuda_check.jai`) and says which are missing, rather `ocr_create` looks for each of these first (`modules/onnxruntime/cuda.jai`, shared with Labels) and says which are missing, rather
than letting onnxruntime fail later and obscurely. If they are all there but CUDA still cannot than letting onnxruntime fail later and obscurely. If they are all there but CUDA still cannot
start, it runs on the CPU and keeps onnxruntime's message. start, it runs on the CPU and keeps onnxruntime's message.

View File

@ -83,13 +83,6 @@ ocr_strip_comment :: (line: string) -> string {
return line; return line;
} }
Ocr_Session :: struct {
session: *OrtSession;
input_name, output_name: *u8; // heap C strings
input_shape: [4] s64; // as the model declares it: -1 for a size it leaves open
input_rank: s64;
}
// The model's sessions on the GPU or the CPU: detector, recognizer and, when it has one, orientation // The model's sessions on the GPU or the CPU: detector, recognizer and, when it has one, orientation
// classifier. On failure, the reason; the sessions made so far are left for ocr_release_sessions. // classifier. On failure, the reason; the sessions made so far are left for ocr_release_sessions.
ocr_create_sessions :: (engine: *Ocr_Engine, models_folder: string, gpu: bool, intra_op_threads: s32) -> ok: bool, error: string { ocr_create_sessions :: (engine: *Ocr_Engine, models_folder: string, gpu: bool, intra_op_threads: s32) -> ok: bool, error: string {
@ -141,102 +134,16 @@ ocr_warm_up :: (engine: *Ocr_Engine) -> ok: bool, error: string {
return true, ""; return true, "";
} }
// A session for the model file; with CUDA when gpu is set. On failure, the reason. // A session for the model file, set up as Ocr_Options says (modules/onnxruntime/session.jai).
ocr_create_session :: (env: *OrtEnv, path: string, gpu: bool, intra_op_threads: s32, ocr_options: Ocr_Options) -> Ocr_Session, ok: bool, error: string { ocr_create_session :: (env: *OrtEnv, path: string, gpu: bool, intra_op_threads: s32, ocr_options: Ocr_Options) -> Ocr_Session, ok: bool, error: string {
result: Ocr_Session; settings := Ort_Gpu_Settings.{arena_extend_strategy = ocr_options.cuda_arena_extend_strategy, cudnn_max_workspace = ocr_options.cudnn_max_workspace};
api := ort.api; session, ok, error := ort_create_session(env, path, gpu, intra_op_threads, settings, missing_hint = "run `jai fetch_models.jai` in modules/OCR and rebuild");
options: *OrtSessionOptions; return session, ok, error;
ok, error := ort_check(api.CreateSessionOptions(*options));
if !ok return result, false, error;
defer api.ReleaseSessionOptions(options);
ort_check(api.SetSessionGraphOptimizationLevel(options, .ORT_ENABLE_ALL));
ort_check(api.SetIntraOpNumThreads(options, intra_op_threads));
ort_check(api.SetInterOpNumThreads(options, 1));
if gpu {
cuda: *OrtCUDAProviderOptionsV2;
ok, error = ort_check(api.CreateCUDAProviderOptions(*cuda));
if !ok return result, false, error;
defer api.ReleaseCUDAProviderOptions(cuda);
// Every image is a different size: an exhaustive cuDNN search would be redone for each new shape.
keys := (*u8).["device_id", "cudnn_conv_algo_search", "arena_extend_strategy", "cudnn_conv_use_max_workspace"];
values := (*u8).["0", "HEURISTIC", temp_c_string(ocr_options.cuda_arena_extend_strategy), temp_c_string(ifx ocr_options.cudnn_max_workspace then "1" else "0")];
ok, error = ort_check(api.UpdateCUDAProviderOptions(cuda, keys.data, values.data, xx keys.count));
if !ok return result, false, error;
ok, error = ort_check(api.SessionOptionsAppendExecutionProvider_CUDA_V2(options, cuda));
if !ok return result, false, error;
} }
if !file_exists(path) return result, false, tprint("% is not there; run `jai fetch_models.jai` in modules/OCR and rebuild.", path); // onnxruntime's session and run, under the names the OCR code has always used.
ok, error = ort_check(api.CreateSession(env, ort_path(path), options, *result.session)); Ocr_Session :: Ort_Session;
if !ok return result, false, error; Ocr_Output :: Ort_Output;
ocr_run :: ort_run_float;
allocator: *OrtAllocator; ocr_release_output :: ort_release_output;
ort_check(api.GetAllocatorWithDefaultOptions(*allocator)); ocr_release_session :: ort_release_session;
name: *u8;
ort_check(api.SessionGetInputName(result.session, 0, allocator, *name));
result.input_name = to_c_string(to_string(name),, context.default_allocator);
ort_check(api.AllocatorFree(allocator, name));
ort_check(api.SessionGetOutputName(result.session, 0, allocator, *name));
result.output_name = to_c_string(to_string(name),, context.default_allocator);
ort_check(api.AllocatorFree(allocator, name));
input_type: *OrtTypeInfo;
if ort_check(api.SessionGetInputTypeInfo(result.session, 0, *input_type)) {
tensor_info: *OrtTensorTypeAndShapeInfo; // belongs to input_type
if ort_check(api.CastTypeInfoToTensorInfo(input_type, *tensor_info)) && tensor_info {
rank: u64;
ort_check(api.GetDimensionsCount(tensor_info, *rank));
result.input_rank = min(cast(s64) rank, result.input_shape.count);
ort_check(api.GetDimensions(tensor_info, result.input_shape.data, xx result.input_rank));
}
api.ReleaseTypeInfo(input_type);
}
return result, true, "";
}
ocr_release_session :: (s: *Ocr_Session) {
if s.session ort.api.ReleaseSession(s.session);
free(s.input_name,, context.default_allocator);
free(s.output_name,, context.default_allocator);
s.* = .{};
}
// A run's output: its values stay valid until ocr_release_output.
Ocr_Output :: struct {
value: *OrtValue;
data: *float;
dims: [4] s64;
dim_count: s64;
}
// Runs the model on a float tensor of shape (the session may be shared between threads).
ocr_run :: (s: *Ocr_Session, memory_info: *OrtMemoryInfo, data: [] float, shape: [] s64) -> Ocr_Output, ok: bool, error: string {
out: Ocr_Output;
api := ort.api;
input: *OrtValue;
ok, error := ort_check(api.CreateTensorWithDataAsOrtValue(memory_info, data.data, xx (data.count * size_of(float)), shape.data, xx shape.count, .ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, *input));
if !ok return out, false, error;
defer api.ReleaseValue(input);
ok, error = ort_check(api.Run(s.session, null, *s.input_name, *input, 1, *s.output_name, 1, *out.value));
if !ok return out, false, error;
raw: *void;
ort_check(api.GetTensorMutableData(out.value, *raw));
out.data = cast(*float) raw;
info: *OrtTensorTypeAndShapeInfo;
ort_check(api.GetTensorTypeAndShape(out.value, *info));
count: u64;
ort_check(api.GetDimensionsCount(info, *count));
out.dim_count = min(cast(s64) count, out.dims.count);
ort_check(api.GetDimensions(info, out.dims.data, xx out.dim_count));
api.ReleaseTensorTypeAndShapeInfo(info);
return out, true, "";
}
ocr_release_output :: (out: *Ocr_Output) {
if out.value ort.api.ReleaseValue(out.value);
out.* = .{};
}

View File

@ -107,7 +107,7 @@ ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, op
use_gpu := false; use_gpu := false;
if prefer_gpu { if prefer_gpu {
available, explanation := ocr_cuda_check(); available, explanation := ort_cuda_check("OCR"); // modules/onnxruntime/cuda.jai
use_gpu = available; use_gpu = available;
if !available engine.gpu_explanation = copy_string(explanation); if !available engine.gpu_explanation = copy_string(explanation);
} else { } else {
@ -117,13 +117,15 @@ ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, op
if use_gpu { if use_gpu {
// A few CPU threads prepare images and decode results while the GPU runs the networks. // A few CPU threads prepare images and decode results while the GPU runs the networks.
engine.workers = max(options.gpu_workers, 1); engine.workers = max(options.gpu_workers, 1);
ort_gpu_lock(); // one CUDA engine made or released at a time (modules/onnxruntime/session.jai)
sessions_ok, sessions_error := ocr_create_sessions(engine, models_folder, true, 2); sessions_ok, sessions_error := ocr_create_sessions(engine, models_folder, true, 2);
if sessions_ok sessions_ok, sessions_error = ocr_warm_up(engine); if sessions_ok sessions_ok, sessions_error = ocr_warm_up(engine);
if !sessions_ok ocr_release_sessions(engine);
ort_gpu_unlock();
if sessions_ok { if sessions_ok {
engine.provider = .CUDA; engine.provider = .CUDA;
engine.rec_batch = max(engine.model.rec_batch_gpu, 1); engine.rec_batch = max(engine.model.rec_batch_gpu, 1);
} else { } else {
ocr_release_sessions(engine);
engine.gpu_explanation = sprint("OCR runs on the CPU: the CUDA libraries were found, but onnxruntime could not start on the GPU:\n%", sessions_error); engine.gpu_explanation = sprint("OCR runs on the CPU: the CUDA libraries were found, but onnxruntime could not start on the GPU:\n%", sessions_error);
use_gpu = false; use_gpu = false;
} }
@ -151,7 +153,9 @@ ocr_gpu_status :: (engine: *Ocr_Engine) -> in_use: bool, explanation: string {
ocr_destroy :: (engine: *Ocr_Engine) { ocr_destroy :: (engine: *Ocr_Engine) {
if !engine return; if !engine return;
if engine.provider == .CUDA ort_gpu_lock();
ocr_release_sessions(engine); ocr_release_sessions(engine);
if engine.provider == .CUDA ort_gpu_unlock();
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info); if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
if engine.env ort.api.ReleaseEnv(engine.env); if engine.env ort.api.ReleaseEnv(engine.env);
free(engine.gpu_explanation,, context.default_allocator); free(engine.gpu_explanation,, context.default_allocator);
@ -356,7 +360,6 @@ run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void
#load "models.jai"; #load "models.jai";
#load "detect.jai"; #load "detect.jai";
#load "recognize.jai"; #load "recognize.jai";
#load "cuda_check.jai";
#import "Basic"; #import "Basic";
#import "String"; #import "String";

View File

@ -1,4 +1,5 @@
// Whether onnxruntime's CUDA provider can run here, and if not, what is missing and how to fix it. // Whether onnxruntime's CUDA provider can run here, and if not, what is missing and how to fix it.
// Shared by the modules that run models (OCR, Labels); what names the feature in the explanations.
// The provider needs the NVIDIA driver, parts of the CUDA 13 toolkit and cuDNN 9 for CUDA 13; it // The provider needs the NVIDIA driver, parts of the CUDA 13 toolkit and cuDNN 9 for CUDA 13; it
// fails late and obscurely without them, so each library is looked for first, by the name the // fails late and obscurely without them, so each library is looked for first, by the name the
// provider loads it by (from onnxruntime_providers_cuda.dll's imports and the names it loads). // provider loads it by (from onnxruntime_providers_cuda.dll's imports and the names it loads).
@ -46,23 +47,23 @@ Cuda_Part :: enum { DRIVER; TOOLKIT; CUDNN; }
} }
// Whether the CUDA provider should be tried, and when not, an explanation for people: what is // Whether the CUDA provider should be tried, and when not, an explanation for people: what is
// missing, and the steps that fix it. Temp. // missing, and the steps that fix it, starting "<what> runs on the CPU". Temp.
ocr_cuda_check :: () -> available: bool, explanation: string { ort_cuda_check :: (what: string) -> available: bool, explanation: string {
#if OS == .MACOS { #if OS == .MACOS {
return false, "OCR runs on the CPU: onnxruntime has no CUDA on macOS."; return false, tprint("% runs on the CPU: onnxruntime has no CUDA on macOS.", what);
} }
provider_path := tprint("%/%", path_strip_filename(ort.library_path), CUDA_PROVIDER_LIBRARY); provider_path := tprint("%/%", path_strip_filename(ort.library_path), CUDA_PROVIDER_LIBRARY);
if !file_exists(provider_path) { if !file_exists(provider_path) {
return false, tprint("OCR runs on the CPU: the onnxruntime beside the program is the CPU-only build (% is not there).\nTo use an NVIDIA GPU, run `jai fetch.jai` in modules/onnxruntime (without `cpu`) and rebuild.", CUDA_PROVIDER_LIBRARY); return false, tprint("% runs on the CPU: the onnxruntime beside the program is the CPU-only build (% is not there).\nTo use an NVIDIA GPU, run `jai fetch.jai` in modules/onnxruntime (without `cpu`) and rebuild.", what, CUDA_PROVIDER_LIBRARY);
} }
#if OS == .WINDOWS ocr_add_cudnn_to_path(); #if OS == .WINDOWS ort_add_cudnn_to_path();
missing_driver, missing_toolkit, missing_cudnn: [..] string; missing_driver, missing_toolkit, missing_cudnn: [..] string;
missing_driver.allocator, missing_toolkit.allocator, missing_cudnn.allocator = temp, temp, temp; missing_driver.allocator, missing_toolkit.allocator, missing_cudnn.allocator = temp, temp, temp;
for CUDA_LIBRARIES { for CUDA_LIBRARIES {
if ocr_library_loads(it.name) continue; if ort_library_loads(it.name) continue;
if it.part == { if it.part == {
case .DRIVER; array_add(*missing_driver, it.name); case .DRIVER; array_add(*missing_driver, it.name);
case .TOOLKIT; array_add(*missing_toolkit, it.name); case .TOOLKIT; array_add(*missing_toolkit, it.name);
@ -74,13 +75,13 @@ ocr_cuda_check :: () -> available: bool, explanation: string {
builder: String_Builder; builder: String_Builder;
builder.allocator = temp; builder.allocator = temp;
if missing_driver.count { if missing_driver.count {
append(*builder, "OCR runs on the CPU: no NVIDIA driver was found ("); print_to_builder(*builder, "% runs on the CPU: no NVIDIA driver was found (", what);
append(*builder, join(..missing_driver, ", ",, temp)); append(*builder, join(..missing_driver, ", ",, temp));
append(*builder, "), so there is no NVIDIA GPU to use."); append(*builder, "), so there is no NVIDIA GPU to use.");
return false, builder_to_string(*builder,, temp); return false, builder_to_string(*builder,, temp);
} }
append(*builder, "OCR runs on the CPU. The NVIDIA GPU can run it once these are installed:\n"); print_to_builder(*builder, "% runs on the CPU. The NVIDIA GPU can run it once these are installed:\n", what);
step := 1; step := 1;
if missing_toolkit.count { if missing_toolkit.count {
print_to_builder(*builder, "\n%. The CUDA Toolkit 13.x, which has % that are missing.\n", step, join(..missing_toolkit, ", ",, temp)); print_to_builder(*builder, "\n%. The CUDA Toolkit 13.x, which has % that are missing.\n", step, join(..missing_toolkit, ", ",, temp));
@ -107,39 +108,37 @@ ocr_cuda_check :: () -> available: bool, explanation: string {
#scope_module #scope_module
ocr_library_loads :: (name: string) -> bool { // Whether the library loads; one that does stays loaded, for the life of the process. Freeing it
// again let a check on one thread unload cuBLASLt or cuDNN while onnxruntime on another thread was
// loading or using it, which crashed there (a DEP access violation in the unloaded library's memory).
// They are about to be used anyway.
ort_library_loads :: (name: string) -> bool {
#if OS == .WINDOWS { #if OS == .WINDOWS {
// The search order the CUDA provider's own loads use: beside the program, the system folders, PATH. // The search order the CUDA provider's own loads use: beside the program, the system folders, PATH.
module := LoadLibraryW(utf8_to_wide(name,, temp)); return LoadLibraryW(utf8_to_wide(name,, temp)) != null;
if !module return false;
FreeLibrary(module);
return true;
} else { } else {
handle := dlopen(temp_c_string(name), RTLD_LAZY); return dlopen(temp_c_string(name), RTLD_LAZY) != null;
if !handle return false;
dlclose(handle);
return true;
} }
} }
#if OS == .WINDOWS { #if OS == .WINDOWS {
// When cuDNN does not load, puts the newest Program Files\NVIDIA\CUDNN\v9.*\bin\13.*\x64 (or // When cuDNN does not load, puts the newest Program Files\NVIDIA\CUDNN\v9.*\bin\13.*\x64 (or
// bin\13.*, as older releases lay it out) holding cudnn64_9.dll at the front of this process's PATH. // bin\13.*, as older releases lay it out) holding cudnn64_9.dll at the front of this process's PATH.
ocr_add_cudnn_to_path :: () { ort_add_cudnn_to_path :: () {
if ocr_library_loads("cudnn64_9.dll") return; if ort_library_loads("cudnn64_9.dll") return;
program_files := ocr_environment_variable("ProgramW6432"); program_files := ort_environment_variable("ProgramW6432");
if !program_files program_files = ocr_environment_variable("ProgramFiles"); if !program_files program_files = ort_environment_variable("ProgramFiles");
if !program_files return; if !program_files return;
versions := ocr_subfolders_newest_first(tprint("%\\NVIDIA\\CUDNN", program_files), "v9."); versions := ort_subfolders_newest_first(tprint("%\\NVIDIA\\CUDNN", program_files), "v9.");
for version: versions { for version: versions {
for cuda: ocr_subfolders_newest_first(tprint("%\\bin", version), "13.") { for cuda: ort_subfolders_newest_first(tprint("%\\bin", version), "13.") {
for folder: string.[tprint("%\\x64", cuda), cuda] { for folder: string.[tprint("%\\x64", cuda), cuda] {
if !file_exists(tprint("%\\cudnn64_9.dll", folder)) continue; if !file_exists(tprint("%\\cudnn64_9.dll", folder)) continue;
found := replace(folder, "/", "\\",, temp); found := replace(folder, "/", "\\",, temp);
path := ocr_environment_variable("PATH"); path := ort_environment_variable("PATH");
OcrSetEnvironmentVariableW(utf8_to_wide("PATH",, temp), utf8_to_wide(tprint("%;%", found, path),, temp)); OrtSetEnvironmentVariableW(utf8_to_wide("PATH",, temp), utf8_to_wide(tprint("%;%", found, path),, temp));
log("[OCR] cuDNN is not on PATH; using the one in %.", found); log("[onnxruntime] cuDNN is not on PATH; using the one in %.", found);
return; return;
} }
} }
@ -148,7 +147,7 @@ ocr_library_loads :: (name: string) -> bool {
// The folders in parent whose names start with prefix, the highest version first ("v9.26" above // The folders in parent whose names start with prefix, the highest version first ("v9.26" above
// "v9.3"). Temp. // "v9.3"). Temp.
ocr_subfolders_newest_first :: (parent: string, prefix: string) -> [..] string { ort_subfolders_newest_first :: (parent: string, prefix: string) -> [..] string {
Found :: struct { names: [..] string; prefix: string; } Found :: struct { names: [..] string; prefix: string; }
found: Found; found: Found;
found.names.allocator = temp; found.names.allocator = temp;
@ -156,12 +155,12 @@ ocr_library_loads :: (name: string) -> bool {
visit_files(parent, recursive = false, *found, (info: *File_Visit_Info, found: *Found) { visit_files(parent, recursive = false, *found, (info: *File_Visit_Info, found: *Found) {
if begins_with(info.short_name, found.prefix) array_add(*found.names, copy_string(info.full_name,, temp)); if begins_with(info.short_name, found.prefix) array_add(*found.names, copy_string(info.full_name,, temp));
}, visit_files = false, visit_directories = true); }, visit_files = false, visit_directories = true);
quick_sort(found.names, (a: string, b: string) -> s64 { return ocr_compare_versions(path_filename(b), path_filename(a)); }); quick_sort(found.names, (a: string, b: string) -> s64 { return ort_compare_versions(path_filename(b), path_filename(a)); });
return found.names; return found.names;
} }
// Compares the numbers in two names one by one: "v9.26" > "v9.3". // Compares the numbers in two names one by one: "v9.26" > "v9.3".
ocr_compare_versions :: (a: string, b: string) -> s64 { ort_compare_versions :: (a: string, b: string) -> s64 {
next_number :: (s: *string) -> s64, found: bool { next_number :: (s: *string) -> s64, found: bool {
while s.count && (s.*[0] < #char "0" || s.*[0] > #char "9") advance(s); while s.count && (s.*[0] < #char "0" || s.*[0] > #char "9") advance(s);
if !s.count return 0, false; if !s.count return 0, false;
@ -180,16 +179,16 @@ ocr_library_loads :: (name: string) -> bool {
} }
// Temp; "" when it is not set. // Temp; "" when it is not set.
ocr_environment_variable :: (name: string) -> string { ort_environment_variable :: (name: string) -> string {
wide_name := utf8_to_wide(name,, temp); wide_name := utf8_to_wide(name,, temp);
size := OcrGetEnvironmentVariableW(wide_name, null, 0); size := OrtGetEnvironmentVariableW(wide_name, null, 0);
if !size return ""; if !size return "";
buffer := NewArray(size, u16,, temp); buffer := NewArray(size, u16,, temp);
written := OcrGetEnvironmentVariableW(wide_name, buffer.data, size); written := OrtGetEnvironmentVariableW(wide_name, buffer.data, size);
if !written || written >= size return ""; if !written || written >= size return "";
return wide_to_utf8(buffer.data, cast(s32) written,, temp); return wide_to_utf8(buffer.data, cast(s32) written,, temp);
} }
OcrGetEnvironmentVariableW :: (name: *u16, buffer: *u16, size: u32) -> u32 #foreign kernel32 "GetEnvironmentVariableW"; OrtGetEnvironmentVariableW :: (name: *u16, buffer: *u16, size: u32) -> u32 #foreign kernel32 "GetEnvironmentVariableW";
OcrSetEnvironmentVariableW :: (name: *u16, value: *u16) -> s32 #foreign kernel32 "SetEnvironmentVariableW"; OrtSetEnvironmentVariableW :: (name: *u16, value: *u16) -> s32 #foreign kernel32 "SetEnvironmentVariableW";
} }

View File

@ -65,6 +65,8 @@ ort_path :: (path: string) -> *ORTCHAR_T {
} }
#load "bindings.jai"; #load "bindings.jai";
#load "session.jai";
#load "cuda.jai";
#scope_module #scope_module
@ -106,3 +108,5 @@ Get_Api_Base :: #type () -> *OrtApiBase #c_call;
#import "File"; #import "File";
#import "File_Utilities"; #import "File_Utilities";
#import "System"; #import "System";
#import "Sort";
#import "Atomics";

View File

@ -0,0 +1,157 @@
// Sessions: one model file each, run on an NVIDIA GPU through CUDA or on the CPU, and running them on
// a tensor. Shared by the modules that run models (OCR, Labels).
// CUDA sessions are made, first run and released one at a time, process-wide: hold ort_gpu_lock
// around it. Done on two threads at once (onnxruntime 1.30.0, CUDA 13, cuBLASLt 13.8, RTX 5080
// laptop), it crashed with a DEP access violation in cuBLASLt or near it, every time in a debug build;
// one at a time, never. Running sessions on many threads at once is fine. A spin lock, needing no
// setup, as engines can be made before anything else runs; it is held for a second or so at a time.
ort_gpu_lock :: () {
while atomic_swap(*ort_gpu_held, 1) != 0 sleep_milliseconds(2);
}
ort_gpu_unlock :: () {
atomic_swap(*ort_gpu_held, 0);
}
#scope_file
ort_gpu_held: s32;
#scope_export
Ort_Session :: struct {
session: *OrtSession;
input_name, output_name: *u8; // heap C strings
input_shape: [4] s64; // as the model declares it: -1 for a size it leaves open
input_rank: s64;
}
// How a CUDA session uses GPU memory. The defaults came from measuring OCR on the sample captures
// (RTX 5080 laptop): with nearly every input a new size, onnxruntime's exact-size arena filled GPU
// memory, and cuDNN's largest workspaces made it worse.
Ort_Gpu_Settings :: struct {
arena_extend_strategy := "kNextPowerOfTwo"; // reuses freed blocks for other sizes; "kSameAsRequested" grows by exactly what is asked
cudnn_max_workspace := false; // let cuDNN take as much working memory as it finds best (onnxruntime's own default)
}
// A session for the model file at path; with CUDA when gpu is set. output names the output that
// ort_run returns; "" for the first. missing_hint is added when the file is not there, to say how
// to get it. On failure, the reason.
ort_create_session :: (env: *OrtEnv, path: string, gpu: bool, intra_op_threads: s32, settings := Ort_Gpu_Settings.{}, output := "", missing_hint := "") -> Ort_Session, ok: bool, error: string {
result: Ort_Session;
api := ort.api;
options: *OrtSessionOptions;
ok, error := ort_check(api.CreateSessionOptions(*options));
if !ok return result, false, error;
defer api.ReleaseSessionOptions(options);
ort_check(api.SetSessionGraphOptimizationLevel(options, .ORT_ENABLE_ALL));
ort_check(api.SetIntraOpNumThreads(options, intra_op_threads));
ort_check(api.SetInterOpNumThreads(options, 1));
if gpu {
cuda: *OrtCUDAProviderOptionsV2;
ok, error = ort_check(api.CreateCUDAProviderOptions(*cuda));
if !ok return result, false, error;
defer api.ReleaseCUDAProviderOptions(cuda);
// Inputs come in many sizes: an exhaustive cuDNN search would be redone for each new shape.
keys := (*u8).["device_id", "cudnn_conv_algo_search", "arena_extend_strategy", "cudnn_conv_use_max_workspace"];
values := (*u8).["0", "HEURISTIC", temp_c_string(settings.arena_extend_strategy), temp_c_string(ifx settings.cudnn_max_workspace then "1" else "0")];
ok, error = ort_check(api.UpdateCUDAProviderOptions(cuda, keys.data, values.data, xx keys.count));
if !ok return result, false, error;
ok, error = ort_check(api.SessionOptionsAppendExecutionProvider_CUDA_V2(options, cuda));
if !ok return result, false, error;
}
if !file_exists(path) return result, false, tprint("% is not there%.", path, ifx missing_hint then tprint("; %", missing_hint) else "");
ok, error = ort_check(api.CreateSession(env, ort_path(path), options, *result.session));
if !ok return result, false, error;
allocator: *OrtAllocator;
ort_check(api.GetAllocatorWithDefaultOptions(*allocator));
name: *u8;
ort_check(api.SessionGetInputName(result.session, 0, allocator, *name));
result.input_name = to_c_string(to_string(name),, context.default_allocator);
ort_check(api.AllocatorFree(allocator, name));
// The output: the one named, else the first.
output_count: u64;
ort_check(api.SessionGetOutputCount(result.session, *output_count));
names: [..] string;
names.allocator = temp;
for i: 0..cast(s64) output_count - 1 {
ort_check(api.SessionGetOutputName(result.session, xx i, allocator, *name));
array_add(*names, copy_string(to_string(name),, temp));
ort_check(api.AllocatorFree(allocator, name));
}
chosen := ifx names.count then names[0] else "";
if output {
found := array_find(names, output);
if !found { ort_release_session(*result); return result, false, tprint("% has no output %; it has %.", path, output, names); }
chosen = output;
}
result.output_name = to_c_string(chosen,, context.default_allocator);
input_type: *OrtTypeInfo;
if ort_check(api.SessionGetInputTypeInfo(result.session, 0, *input_type)) {
tensor_info: *OrtTensorTypeAndShapeInfo; // belongs to input_type
if ort_check(api.CastTypeInfoToTensorInfo(input_type, *tensor_info)) && tensor_info {
rank: u64;
ort_check(api.GetDimensionsCount(tensor_info, *rank));
result.input_rank = min(cast(s64) rank, result.input_shape.count);
ort_check(api.GetDimensions(tensor_info, result.input_shape.data, xx result.input_rank));
}
api.ReleaseTypeInfo(input_type);
}
return result, true, "";
}
ort_release_session :: (s: *Ort_Session) {
if s.session ort.api.ReleaseSession(s.session);
free(s.input_name,, context.default_allocator);
free(s.output_name,, context.default_allocator);
s.* = .{};
}
// A run's output: its values stay valid until ort_release_output.
Ort_Output :: struct {
value: *OrtValue;
data: *float; // the values, for a float output (every model here has one)
dims: [4] s64;
dim_count: s64;
}
// Runs the session on one input tensor of shape, bytes long at data, with element type element.
// A session may be run from several threads at once.
ort_run :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: *void, bytes: s64, element: ONNXTensorElementDataType, shape: [] s64) -> Ort_Output, ok: bool, error: string {
out: Ort_Output;
api := ort.api;
input: *OrtValue;
ok, error := ort_check(api.CreateTensorWithDataAsOrtValue(memory_info, data, xx bytes, shape.data, xx shape.count, element, *input));
if !ok return out, false, error;
defer api.ReleaseValue(input);
ok, error = ort_check(api.Run(s.session, null, *s.input_name, *input, 1, *s.output_name, 1, *out.value));
if !ok return out, false, error;
raw: *void;
ort_check(api.GetTensorMutableData(out.value, *raw));
out.data = cast(*float) raw;
info: *OrtTensorTypeAndShapeInfo;
ort_check(api.GetTensorTypeAndShape(out.value, *info));
count: u64;
ort_check(api.GetDimensionsCount(info, *count));
out.dim_count = min(cast(s64) count, out.dims.count);
ort_check(api.GetDimensions(info, out.dims.data, xx out.dim_count));
api.ReleaseTensorTypeAndShapeInfo(info);
return out, true, "";
}
ort_run_float :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: [] float, shape: [] s64) -> Ort_Output, ok: bool, error: string {
out, ok, error := ort_run(s, memory_info, data.data, data.count * size_of(float), .ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, shape);
return out, ok, error;
}
ort_release_output :: (out: *Ort_Output) {
if out.value ort.api.ReleaseValue(out.value);
out.* = .{};
}

View File

@ -11,6 +11,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#load "test/logger_tests.jai"; #load "test/logger_tests.jai";
#load "test/catalog_tests.jai"; #load "test/catalog_tests.jai";
#load "test/ocr_tests.jai"; #load "test/ocr_tests.jai";
#load "test/labels_tests.jai";
} }
#load "app/main.jai"; // entry point #load "app/main.jai"; // entry point
@ -20,6 +21,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#load "app/catalog.jai"; #load "app/catalog.jai";
#load "app/stats_ui.jai"; #load "app/stats_ui.jai";
#load "app/background.jai"; #load "app/background.jai";
#load "app/labels_ui.jai";
#load "app/exit_shutdown.jai"; #load "app/exit_shutdown.jai";
#load "base/circle_buffer.jai"; #load "base/circle_buffer.jai";
@ -46,16 +48,19 @@ USING_WINDOWS :: OS == .WINDOWS;
#import "Unicode"; #import "Unicode";
#import "File"; #import "File";
#import "Random"; // the noise viewer_overwrite_and_delete writes #import "Random"; // the noise viewer_overwrite_and_delete writes
#import "Float16"; // label vectors are stored as 16-bit floats
#import "File_Utilities"; #import "File_Utilities";
#import "Math"; #import "Math";
#import "System"; #import "System";
#import "Thread"; #import "Thread";
#import "Hash_Table"; #import "Hash_Table";
#import "Hash"; // fnv1a_hash, for the label list's hash
Shared_Channel :: #import "Shared_Memory_Channel"; Shared_Channel :: #import "Shared_Memory_Channel";
#import "stb_image"; #import "stb_image";
#import "stb_image_resize"; #import "stb_image_resize";
#import "Capture_File"; // the .mcap format, copied from D:\SnagitIndex\SnagitConverter #import "Capture_File"; // the .mcap format, copied from D:\SnagitIndex\SnagitConverter
#import "OCR"; #import "OCR";
#import "Labels";
#import "Capture_Text"; #import "Capture_Text";
#import "Base64"; #import "Base64";
#import "onnxruntime"; #import "onnxruntime";
@ -178,6 +183,7 @@ App :: struct {
library_folder: string; // -library FOLDER: the .mcap folder the viewer shows, in place of VIEWER_FOLDER library_folder: string; // -library FOLDER: the .mcap folder the viewer shows, in place of VIEWER_FOLDER
scan_and_exit: bool; // -scan: scan the library with the chosen OCR model once it has loaded, then exit (0, or 1 if any capture failed) scan_and_exit: bool; // -scan: scan the library with the chosen OCR model once it has loaded, then exit (0, or 1 if any capture failed)
stats_and_exit: bool; // -stats: log the Statistics window's numbers once the catalog has loaded, then exit stats_and_exit: bool; // -stats: log the Statistics window's numbers once the catalog has loaded, then exit
start_search: string; // -search QUERY: the search box's text at startup
last_window_restore_time: Apollo_Time; last_window_restore_time: Apollo_Time;
imgui_context: *ImGui.ImGuiContext; imgui_context: *ImGui.ImGuiContext;

View File

@ -8,6 +8,7 @@ APP_MAIN_WINDOW_LABEL :: "Main";
app_ui_startup :: () { app_ui_startup :: () {
viewer_startup(); viewer_startup();
ocr_ui_startup(); ocr_ui_startup();
labels_ui_startup();
} }
// Every frame, before input is handed to ImGui. Poll background work here. // Every frame, before input is handed to ImGui. Poll background work here.
@ -19,6 +20,7 @@ app_ui_frame_start :: () {
app_ui_shutdown :: () { app_ui_shutdown :: () {
viewer_shutdown(); viewer_shutdown();
ocr_ui_shutdown(); ocr_ui_shutdown();
labels_ui_shutdown(); // after the OCR thread and the background workers have stopped
stats_ui_shutdown(); stats_ui_shutdown();
} }
@ -26,5 +28,6 @@ app_main_window_ui :: () {
push_imgui_window(APP_MAIN_WINDOW_LABEL); push_imgui_window(APP_MAIN_WINDOW_LABEL);
viewer_ui(); viewer_ui();
ocr_ui_frame(); ocr_ui_frame();
labels_ui_frame();
stats_ui_frame(); stats_ui_frame();
} }

View File

@ -7,6 +7,10 @@
// UI thread, and reads nothing else: never viewer.captures, which a refresh can grow. Catalog // UI thread, and reads nothing else: never viewer.captures, which a refresh can grow. Catalog
// entries replaced while jobs are out are freed once none are (background_retire). A newer search // entries replaced while jobs are out are freed once none are (background_retire). A newer search
// makes the older ones stop early, and their results are dropped. // makes the older ones stop early, and their results are dropped.
//
// A search with like: terms first turns their phrases into vectors with the label model's text
// encoder (a job of its own), then ranks the captures matching the rest by how near their image
// vectors are, best first.
BACKGROUND_SEARCH_CHUNK :: 256; // captures matched per job BACKGROUND_SEARCH_CHUNK :: 256; // captures matched per job
@ -14,6 +18,7 @@ Search_Item :: struct {
index: s32; index: s32;
entry: *Catalog_Entry; // null while not loaded entry: *Catalog_Entry; // null while not loaded
name: string; // the capture's; never freed name: string; // the capture's; never freed
score: float; // for like: terms, the mean cosine of its image vector with theirs
} }
background: struct { background: struct {
@ -55,7 +60,7 @@ background_snapshot_visible :: () -> [] Search_Item {
items := NewArray(viewer.visible.count, Search_Item,, context.default_allocator); items := NewArray(viewer.visible.count, Search_Item,, context.default_allocator);
for viewer.visible { for viewer.visible {
capture := *viewer.captures[it]; capture := *viewer.captures[it];
items[it_index] = .{it, capture.catalog, capture.name}; items[it_index] = .{index = it, entry = capture.catalog, name = capture.name};
} }
return items; return items;
} }
@ -68,6 +73,10 @@ background_frame :: () {
job := cast(*Background_Job) work; job := cast(*Background_Job) work;
background.jobs_out -= 1; background.jobs_out -= 1;
if job.kind == { if job.kind == {
case .SEARCH_EMBED;
request := job.request;
if request.generation == background.search_generation search_dispatch_chunks(request);
else search_request_free(request);
case .SEARCH_CHUNK; case .SEARCH_CHUNK;
request := job.request; request := job.request;
request.chunk_matches[job.chunk] = job.matches; request.chunk_matches[job.chunk] = job.matches;
@ -111,8 +120,10 @@ background_frame :: () {
// Matches the search against every capture not removed, in chunks on the workers; the results // Matches the search against every capture not removed, in chunks on the workers; the results
// replace viewer.visible when they are all back. // replace viewer.visible when they are all back.
background_search_start :: () { background_search_start :: () {
// While the catalog loads under a search, matches arrive every frame: a few searches a second do. // Under a search, catalog entries arriving (as the catalog loads, or a scan stores readings) would
if viewer.search_terms.count && !viewer.sorted_by_date && to_float64_seconds(current_time_monotonic() - viewer.visible_rebuilt_at) < 0.25 return; // start one every frame: a few searches a second do. Typing, a refresh and a delete reset
// visible_rebuilt_at, so they are searched at once.
if viewer.search_terms.count && to_float64_seconds(current_time_monotonic() - viewer.visible_rebuilt_at) < 0.25 return;
viewer.visible_dirty = false; viewer.visible_dirty = false;
viewer.visible_rebuilt_at = current_time_monotonic(); viewer.visible_rebuilt_at = current_time_monotonic();
@ -128,16 +139,35 @@ background_search_start :: () {
for viewer.search_terms request.terms[it_index] = .{it.field, copy_string(it.text)}; for viewer.search_terms request.terms[it_index] = .{it.field, copy_string(it.text)};
items: [..] Search_Item; items: [..] Search_Item;
array_reserve(*items, viewer_capture_count()); array_reserve(*items, viewer_capture_count());
for * viewer.captures if !it.removed array_add(*items, .{cast(s32) it_index, it.catalog, it.name}); for * viewer.captures if !it.removed array_add(*items, .{index = cast(s32) it_index, entry = it.catalog, name = it.name});
request.items = items; request.items = items;
chunks := max((items.count + BACKGROUND_SEARCH_CHUNK - 1) / BACKGROUND_SEARCH_CHUNK, 1);
request.chunk_matches = NewArray(chunks, [..] Search_Item);
// like: phrases need their vectors first, from the label model's text encoder.
likes: [..] string;
likes.allocator = temp;
for request.terms if it.field == .LIKE array_add(*likes, it.text);
if likes.count && labels_ui.engine {
request.like_texts = NewArray(likes.count, string);
for likes request.like_texts[it_index] = copy_string(it);
request.labels = labels_ui.engine;
job := New(Background_Job);
job.* = .{kind = .SEARCH_EMBED, generation = generation, request = request};
background_add(job);
return;
}
search_dispatch_chunks(request);
}
// The chunks of a search whose like: vectors (if any) are made.
search_dispatch_chunks :: (request: *Search_Request) {
push_allocator(context.default_allocator);
chunks := max((request.items.count + BACKGROUND_SEARCH_CHUNK - 1) / BACKGROUND_SEARCH_CHUNK, 1);
request.chunk_matches = NewArray(chunks, [..] Search_Item);
for chunk: 0..chunks - 1 { for chunk: 0..chunks - 1 {
job := New(Background_Job); job := New(Background_Job);
first := chunk * BACKGROUND_SEARCH_CHUNK; first := chunk * BACKGROUND_SEARCH_CHUNK;
job.* = .{kind = .SEARCH_CHUNK, generation = generation, request = request, chunk = chunk, job.* = .{kind = .SEARCH_CHUNK, generation = request.generation, request = request, chunk = chunk,
first = first, count = clamp(items.count - first, 0, BACKGROUND_SEARCH_CHUNK)}; first = first, count = clamp(request.items.count - first, 0, BACKGROUND_SEARCH_CHUNK)};
background_add(job); background_add(job);
} }
} }
@ -153,7 +183,7 @@ background_stats_start :: (items: [] Search_Item, model: string) {
#scope_file #scope_file
Background_Kind :: enum u8 { SEARCH_CHUNK; SEARCH_MERGE; STATS; } Background_Kind :: enum u8 { SEARCH_EMBED; SEARCH_CHUNK; SEARCH_MERGE; STATS; }
Background_Job :: struct { Background_Job :: struct {
kind: Background_Kind; kind: Background_Kind;
@ -177,6 +207,9 @@ Search_Request :: struct {
terms: [] Search_Term; terms: [] Search_Term;
items: [] Search_Item; items: [] Search_Item;
by_date: bool; // newest first; else in the captures' own order by_date: bool; // newest first; else in the captures' own order
like_texts: [] string; // the like: phrases; the order is then best first
like_vectors: [] float; // their vectors, made by the SEARCH_EMBED job
labels: *Labels_Engine;
chunk_matches: [] [..] Search_Item; // each chunk's, as they come back chunk_matches: [] [..] Search_Item; // each chunk's, as they come back
chunks_back: s64; chunks_back: s64;
} }
@ -190,6 +223,9 @@ search_request_free :: (request: *Search_Request) {
push_allocator(context.default_allocator); push_allocator(context.default_allocator);
for request.terms free(it.text); for request.terms free(it.text);
array_free(request.terms); array_free(request.terms);
for request.like_texts free(it);
array_free(request.like_texts);
array_free(request.like_vectors);
array_free(request.items); array_free(request.items);
for request.chunk_matches array_free(it); for request.chunk_matches array_free(it);
array_free(request.chunk_matches); array_free(request.chunk_matches);
@ -201,6 +237,11 @@ background_proc :: (group: *Thread_Group, thread: *Thread, work: *void) -> Threa
context.allocator = context.default_allocator; context.allocator = context.default_allocator;
job := cast(*Background_Job) work; job := cast(*Background_Job) work;
if job.kind == { if job.kind == {
case .SEARCH_EMBED;
// A newer search may have started; the vectors are still made, as the run cannot be stopped.
vectors, error := labels_embed_texts(job.request.labels, job.request.like_texts,, context.default_allocator);
if error log_error("[search] like: could not be worked out: %", error);
job.request.like_vectors = vectors;
case .SEARCH_CHUNK; search_chunk(job); case .SEARCH_CHUNK; search_chunk(job);
case .SEARCH_MERGE; search_merge(job); case .SEARCH_MERGE; search_merge(job);
case .STATS; job.stats = stats_compute(job.items, job.model); case .STATS; job.stats = stats_compute(job.items, job.model);
@ -214,9 +255,35 @@ search_chunk :: (job: *Background_Job) {
for i: job.first..job.first + job.count - 1 { for i: job.first..job.first + job.count - 1 {
if (i & 63) == 0 && atomic_read(*background.search_generation) != job.generation return; // a newer search: these are wasted if (i & 63) == 0 && atomic_read(*background.search_generation) != job.generation return; // a newer search: these are wasted
item := request.items[i]; item := request.items[i];
if search_matches(item.entry, request.terms) array_add(*job.matches, item); if !search_matches(item.entry, request.terms) continue;
if request.like_vectors.count {
item.score = search_like_score(request, item.entry);
} }
if request.by_date quick_sort(job.matches, search_order_by_date); array_add(*job.matches, item);
}
if request.like_vectors.count quick_sort(job.matches, search_order_by_score);
else if request.by_date quick_sort(job.matches, search_order_by_date);
}
// The mean cosine of the entry's image vector with the like: phrases' vectors (all unit vectors).
search_like_score :: (request: *Search_Request, entry: *Catalog_Entry) -> float {
half := entry.embedding;
dims := half.count;
phrases := request.like_vectors.count / max(dims, 1);
if !dims || phrases * dims != request.like_vectors.count return -1;
image := NewArray(dims, float,, temp);
for half image[it_index] = float16_to_float32(cast(Float16) it);
total: float;
for p: 0..phrases - 1 {
phrase := request.like_vectors.data + p * dims;
for 0..dims - 1 total += image[it] * phrase[it];
}
return total / phrases;
}
search_order_by_score :: (a: Search_Item, b: Search_Item) -> s64 {
if a.score != b.score return ifx a.score > b.score then -1 else 1;
return search_order_by_date(a, b);
} }
// Every chunk's matches as one list. In date order, each chunk is sorted already, so they are merged // Every chunk's matches as one list. In date order, each chunk is sorted already, so they are merged
@ -226,12 +293,12 @@ search_merge :: (job: *Background_Job) {
runs: [..] [] Search_Item; runs: [..] [] Search_Item;
runs.allocator = temp; runs.allocator = temp;
for request.chunk_matches array_add(*runs, it); for request.chunk_matches array_add(*runs, it);
if request.by_date { if request.by_date || request.like_vectors.count {
while runs.count > 1 { while runs.count > 1 {
next: [..] [] Search_Item; next: [..] [] Search_Item;
next.allocator = temp; next.allocator = temp;
for i: 0..(runs.count + 1) / 2 - 1 { for i: 0..(runs.count + 1) / 2 - 1 {
if 2 * i + 1 < runs.count array_add(*next, merge_two(runs[2 * i], runs[2 * i + 1])); if 2 * i + 1 < runs.count array_add(*next, merge_two(runs[2 * i], runs[2 * i + 1], request.like_vectors.count > 0));
else array_add(*next, runs[2 * i]); else array_add(*next, runs[2 * i]);
} }
runs = next; runs = next;
@ -243,12 +310,16 @@ search_merge :: (job: *Background_Job) {
for run: runs for run array_add(*job.visible, it.index); for run: runs for run array_add(*job.visible, it.index);
} }
// Two lists in date order as one. Temp. // Two lists in order (by score, or by date) as one. Temp.
merge_two :: (a: [] Search_Item, b: [] Search_Item) -> [] Search_Item { merge_two :: (a: [] Search_Item, b: [] Search_Item, by_score: bool) -> [] Search_Item {
result := NewArray(a.count + b.count, Search_Item,, temp); result := NewArray(a.count + b.count, Search_Item,, temp);
i, j := 0, 0; i, j := 0, 0;
for * result { for * result {
if j >= b.count || (i < a.count && search_order_by_date(a[i], b[j]) <= 0) { it.* = a[i]; i += 1; } take_a: bool;
if j >= b.count take_a = true;
else if i >= a.count take_a = false;
else take_a = (ifx by_score then search_order_by_score(a[i], b[j]) else search_order_by_date(a[i], b[j])) <= 0;
if take_a { it.* = a[i]; i += 1; }
else { it.* = b[j]; j += 1; } else { it.* = b[j]; j += 1; }
} }
return result; return result;

View File

@ -9,9 +9,12 @@ Catalog_Entry :: struct {
title, url, app: string; // WindowName, WebURL, AppName title, url, app: string; // WindowName, WebURL, AppName
notes: string; // the annotations' text, one per line notes: string; // the annotations' text, one per line
ocr: [..] Mcap_Ocr_Section; // one per model that read it ocr: [..] Mcap_Ocr_Section; // one per model that read it
labels: [..] Mcap_Label; // the label model's (LABELS_MODEL) best labels, best first
label_list: string; // the hash of the label list they were scored against
embedding: [] u16; // the label model's image vector, 16-bit floats; empty when not labelled
// Lowercased copies, for matching without case. // Lowercased copies, for matching without case.
find_title, find_url, find_app, find_notes, find_ocr: string; find_title, find_url, find_app, find_notes, find_ocr, find_labels: string;
} }
// Everything the entry holds is from the default allocator. // Everything the entry holds is from the default allocator.
@ -25,7 +28,11 @@ catalog_entry_free :: (entry: *Catalog_Entry) {
array_free(it.lines); array_free(it.lines);
} }
array_free(entry.ocr); array_free(entry.ocr);
free(entry.find_title); free(entry.find_url); free(entry.find_app); free(entry.find_notes); free(entry.find_ocr); for entry.labels free(it.name);
array_free(entry.labels);
free(entry.label_list);
array_free(entry.embedding);
free(entry.find_title); free(entry.find_url); free(entry.find_app); free(entry.find_notes); free(entry.find_ocr); free(entry.find_labels);
free(entry); free(entry);
} }
@ -77,6 +84,22 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
sections, ocr_ok := mcap_ocr_parse(front.ocr); sections, ocr_ok := mcap_ocr_parse(front.ocr);
if ocr_ok entry.ocr = sections; if ocr_ok entry.ocr = sections;
// The label model's chunks (app/labels_ui.jai).
labels_chunk := mcap_find_chunk(front.front_chunks, mcap_tag(MCAP_LABELS_TAG), LABELS_MODEL);
if labels_chunk {
labels, labels_ok := mcap_labels_parse(cast(string) labels_chunk.data);
if labels_ok {
entry.labels = labels.labels;
entry.label_list = labels.list;
free(labels.engine); free(labels.date);
}
}
embedding_chunk := mcap_find_chunk(front.front_chunks, mcap_tag(MCAP_EMBEDDING_TAG), LABELS_MODEL);
if embedding_chunk {
half, half_ok := mcap_embedding_parse(embedding_chunk.data);
if half_ok entry.embedding = half;
}
entry.find_title = to_lower_copy(entry.title); entry.find_title = to_lower_copy(entry.title);
entry.find_url = to_lower_copy(entry.url); entry.find_url = to_lower_copy(entry.url);
entry.find_app = to_lower_copy(entry.app); entry.find_app = to_lower_copy(entry.app);
@ -85,6 +108,15 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
all_ocr.allocator = temp; all_ocr.allocator = temp;
for entry.ocr for line: it.lines { append(*all_ocr, line.text); append(*all_ocr, "\n"); } for entry.ocr for line: it.lines { append(*all_ocr, line.text); append(*all_ocr, "\n"); }
entry.find_ocr = to_lower_copy(builder_to_string(*all_ocr,, temp)); entry.find_ocr = to_lower_copy(builder_to_string(*all_ocr,, temp));
all_labels: String_Builder;
all_labels.allocator = temp;
// The labels search matches: the best, and any scoring at least half as much (a close second).
for entry.labels {
if it_index > 0 && it.score < entry.labels[0].score * 0.5 continue;
append(*all_labels, it.name);
append(*all_labels, "\n");
}
entry.find_labels = to_lower_copy(builder_to_string(*all_labels,, temp));
return entry, ""; return entry, "";
} }
@ -96,14 +128,16 @@ catalog_section :: (entry: *Catalog_Entry, model: string) -> *Mcap_Ocr_Section {
// ---------------------------------------------------------------- search // ---------------------------------------------------------------- search
Search_Field :: enum u8 { ANY; OCR; TITLE; URL; APP; NOTE; } // LIKE is by meaning: the captures whose image is nearest the phrase, ranked by the label model
// (app/background.jai does the ranking); here it only asks for a labelled capture.
Search_Field :: enum u8 { ANY; OCR; TITLE; URL; APP; NOTE; LABEL; LIKE; }
Search_Term :: struct { Search_Term :: struct {
field: Search_Field; field: Search_Field;
text: string; // lowercased text: string; // lowercased
} }
// Words, "quoted phrases", and either with a field prefix: ocr: title: url: app: note:. Temp. // Words, "quoted phrases", and either with a field prefix: ocr: title: url: app: note: label: like:. Temp.
search_parse :: (query: string) -> [..] Search_Term { search_parse :: (query: string) -> [..] Search_Term {
terms: [..] Search_Term; terms: [..] Search_Term;
terms.allocator = temp; terms.allocator = temp;
@ -121,6 +155,8 @@ search_parse :: (query: string) -> [..] Search_Term {
case "url"; field = .URL; case "url"; field = .URL;
case "app"; field = .APP; case "app"; field = .APP;
case "note"; field = .NOTE; case "note"; field = .NOTE;
case "label"; field = .LABEL;
case "like"; field = .LIKE;
} }
if field != .ANY { term.field = field; rest = advance(rest, colon + 1); } // "http:..." and the like stay words if field != .ANY { term.field = field; rest = advance(rest, colon + 1); } // "http:..." and the like stay words
} }
@ -148,12 +184,14 @@ search_matches :: (entry: *Catalog_Entry, terms: [] Search_Term) -> bool {
found: bool; found: bool;
if it.field == { if it.field == {
case .ANY; found = contains(entry.find_ocr, it.text) || contains(entry.find_title, it.text) || contains(entry.find_url, it.text) || case .ANY; found = contains(entry.find_ocr, it.text) || contains(entry.find_title, it.text) || contains(entry.find_url, it.text) ||
contains(entry.find_app, it.text) || contains(entry.find_notes, it.text); contains(entry.find_app, it.text) || contains(entry.find_notes, it.text) || contains(entry.find_labels, it.text);
case .OCR; found = contains(entry.find_ocr, it.text); case .OCR; found = contains(entry.find_ocr, it.text);
case .TITLE; found = contains(entry.find_title, it.text); case .TITLE; found = contains(entry.find_title, it.text);
case .URL; found = contains(entry.find_url, it.text); case .URL; found = contains(entry.find_url, it.text);
case .APP; found = contains(entry.find_app, it.text); case .APP; found = contains(entry.find_app, it.text);
case .NOTE; found = contains(entry.find_notes, it.text); case .NOTE; found = contains(entry.find_notes, it.text);
case .LABEL; found = contains(entry.find_labels, it.text);
case .LIKE; found = entry.embedding.count > 0;
} }
if !found return false; if !found return false;
} }

371
src/app/labels_ui.jai Normal file
View File

@ -0,0 +1,371 @@
// Labels in the app (modules/Labels): the label list, the label engine, the Labels window, and the
// labels row of the Capture window.
//
// The label list is labels.config in the app's data folder: a name and a prompt per label, the
// prompt being what the model scores (a fuller description scores better than a word). It is read
// again when the file changes. Each version of it becomes a Label_Set: its names, its prompts'
// vectors and a hash of it, made on the OCR thread (app/ocr_ui.jai) and never changed after, so a
// scan can hold the set it started with while the list is edited.
//
// The engine is made on the OCR thread too, once, after the OCR engine; it is destroyed only at
// shutdown, after the background workers (app/background.jai), which use its text model for like:
// searches, have stopped.
LABELS_WINDOW :: "Labels";
LABELS_MODEL :: "siglip2-base-patch16-384"; // the one label model; its chunks are keyed by this
LABELS_TOP :: 5; // labels stored per capture, best first
LABELS_LIST_FILE :: "labels.config";
Label_Entry :: struct { name, prompt: string; }
// A version of the label list with its prompts' vectors. Heap; never changed once made.
Label_Set :: struct {
hash: string;
names: [] string;
vectors: [] float; // one unit vector per label, engine.dimensions long, one after another
}
labels_ui: struct {
// Owned by the OCR thread while ocr_ui.thread_busy, like ocr_ui.engine.
engine: *Labels_Engine;
engine_error: string;
engine_tried: bool;
set: *Label_Set; // the newest list's
retired_sets: [..] *Label_Set; // replaced while a scan might hold them; freed when the OCR thread is idle
// Main thread.
list: [..] Label_Entry; // as last read; heap
list_hash: string; // heap
list_error: string;
list_modtime: Apollo_Time;
list_checked_at: Apollo_Time;
window_open: bool;
scan_labels := true; // the library scan labels too
search_ready: bool; // the engine was up when the shown captures were last worked out
// The Labels window's counts, worked out again only when the catalog or the list changes.
counted_generation: s64 = -1;
counted_hash: string; // heap
labelled_count, current_count: s64;
};
labels_models_folder :: () -> string {
return tprint("%label-models", path_strip_filename(get_path_of_running_executable()));
}
labels_list_path :: () -> string {
return tprint("%/%", app_program_data_dir(), LABELS_LIST_FILE);
}
// ---------------------------------------------------------------- startup and shutdown
labels_ui_startup :: () {
labels_ui.list.allocator = context.default_allocator;
labels_ui.retired_sets.allocator = context.default_allocator;
path := labels_list_path();
if !file_exists(path) {
if write_entire_file(path, LABELS_STARTER_LIST) log("[Labels] Wrote a starter label list to %.", path);
else log_error("[Labels] Could not write %.", path);
}
labels_read_list();
}
// After ocr_ui_shutdown has stopped the OCR thread, and viewer_shutdown the background workers.
labels_ui_shutdown :: () {
labels_destroy(labels_ui.engine);
labels_ui.engine = null;
free(labels_ui.counted_hash,, context.default_allocator);
}
// Whether a scan can start with labels: the engine has been tried and, when it is up, the list's
// set is made.
labels_ui_settled :: () -> bool {
if ocr_ui.thread_busy || !labels_ui.engine_tried return false;
return !labels_ui.engine || (labels_ui.set && labels_ui.set.hash == labels_ui.list_hash);
}
// ---------------------------------------------------------------- the label list
LABELS_STARTER_LIST :: #string END
@version 1
# The labels each capture is scored against. One per line: its name, then the prompt the model scores,
# both quoted. The model compares an image with the prompt, and fuller descriptions score better than
# single words. Saved changes are picked up while the app runs; captures labelled with an earlier list
# are labelled again from their stored vectors by the next scan, without reading their images again.
label: "Code editor" "a screenshot of a code editor showing source code"
label: "Terminal" "a screenshot of a terminal window with command line text"
label: "Web page" "a screenshot of a web page in a browser"
label: "Search results" "a screenshot of search engine results"
label: "Documentation" "a screenshot of technical documentation with headings and paragraphs"
label: "Forum or social" "a screenshot of a forum thread or social media posts"
label: "Chat" "a screenshot of a chat conversation with messages"
label: "Email" "a screenshot of an email"
label: "Video call" "a screenshot of a video call with people's faces"
label: "Video" "a screenshot of a video player"
label: "Schematic" "an electronic circuit schematic diagram"
label: "PCB layout" "a printed circuit board layout in a PCB design tool"
label: "CAD model" "a 3D CAD model of a mechanical part"
label: "Plot" "a plot of a signal or data on axes"
label: "Chart" "a bar chart or pie chart"
label: "Dashboard" "a dashboard with graphs and numbers"
label: "Spreadsheet" "a spreadsheet with rows and columns of numbers"
label: "Table" "a table of data"
label: "Diagram" "a block diagram or flowchart"
label: "Map" "a map"
label: "Settings" "a settings dialog with options and checkboxes"
label: "File browser" "a file explorer window listing files and folders"
label: "System monitor" "a system monitor showing CPU temperatures and usage"
label: "Error message" "an error message dialog"
label: "Installer" "a software installer window"
label: "Shopping" "an online store page with products and prices"
label: "Datasheet" "a page of an electronic component datasheet"
label: "Paper" "a page of a scientific paper"
label: "Slide" "a presentation slide"
label: "Photo" "a photograph of a real scene"
label: "Product photo" "a photo of an electronic device or circuit board"
label: "Person" "a photo of a person"
label: "Game" "a screenshot of a video game"
label: "Code review" "a screenshot of a code diff or pull request"
END
// Reads labels.config into labels_ui.list, its hash into list_hash. A line that cannot be read is
// logged and skipped, as in the Ex4 formats.
labels_read_list :: () {
push_allocator(context.default_allocator);
path := labels_list_path();
modtime, _, found := file_modtime_and_size(path);
labels_ui.list_modtime = modtime;
for labels_ui.list { free(it.name); free(it.prompt); }
array_reset_keeping_memory(*labels_ui.list);
free(labels_ui.list_error);
labels_ui.list_error = "";
text, ok := read_entire_file(path, log_errors = false,, temp);
if !ok { labels_ui.list_error = sprint("Cannot read %.", path); labels_set_list_hash(); return; }
line_number := 0;
for raw: split(text, "\n",, temp) {
line_number += 1;
line := trim(raw);
if !line.count || line[0] == #char "#" || begins_with(line, "@version") continue;
if !begins_with(line, "label:") { log_error("[Labels] %:%: expected label: \"name\" \"prompt\". Ignored.", path, line_number); continue; }
rest := trim(advance(line, 6));
name, after, name_ok := labels_quoted(rest);
prompt, end, prompt_ok := labels_quoted(trim_left(after));
if !name_ok || !prompt_ok || trim(end).count || !name.count || !prompt.count {
log_error("[Labels] %:%: expected label: \"name\" \"prompt\". Ignored.", path, line_number);
continue;
}
array_add(*labels_ui.list, .{copy_string(name), copy_string(prompt)});
}
labels_set_list_hash();
log("[Labels] % labels from % (list %).", labels_ui.list.count, path, labels_ui.list_hash);
}
// Each frame: reads the list again when its file has changed (looked at every 2 s).
labels_check_list :: () {
now := current_time_monotonic();
if to_float64_seconds(now - labels_ui.list_checked_at) < 2 return;
labels_ui.list_checked_at = now;
modtime, _, found := file_modtime_and_size(labels_list_path());
if found && modtime != labels_ui.list_modtime labels_read_list();
}
// A Label_Set for the list given, with engine: its prompts' vectors. Heap. On the OCR thread.
labels_make_set :: (engine: *Labels_Engine, names: [] string, prompts: [] string, hash: string) -> *Label_Set, error: string {
push_allocator(context.default_allocator);
vectors, error := labels_embed_texts(engine, prompts);
if error return null, error;
set := New(Label_Set);
set.hash = copy_string(hash);
set.names = NewArray(names.count, string);
for names set.names[it_index] = copy_string(it);
set.vectors = vectors;
return set, "";
}
label_set_free :: (set: *Label_Set) {
if !set return;
push_allocator(context.default_allocator);
free(set.hash);
for set.names free(it);
array_free(set.names);
array_free(set.vectors);
free(set);
}
// The LABELS_TOP best labels of set for an image vector, best first. Temp.
labels_for_vector :: (engine: *Labels_Engine, set: *Label_Set, vector: *float) -> [..] Mcap_Label {
scored: [..] Mcap_Label;
scored.allocator = temp;
dims := engine.dimensions;
for set.names array_add(*scored, .{labels_score(engine, vector, set.vectors.data + it_index * dims), it});
quick_sort(scored, (a: Mcap_Label, b: Mcap_Label) -> s64 { return ifx a.score > b.score then -1 else ifx a.score < b.score then 1 else 0; });
if scored.count > LABELS_TOP scored.count = LABELS_TOP;
return scored;
}
// ---------------------------------------------------------------- each frame
// The engine and the list's set, made on the OCR thread when it is free; the list file watched.
labels_ui_frame :: () { using ImGui;
labels_check_list();
// like: searches need the engine: work them out again once it is up.
if labels_ui.engine && !labels_ui.search_ready && !ocr_ui.thread_busy {
labels_ui.search_ready = true;
for viewer.search_terms if it.field == .LIKE viewer.visible_dirty = true;
}
if !ocr_ui.thread_busy {
for labels_ui.retired_sets label_set_free(it);
array_reset_keeping_memory(*labels_ui.retired_sets);
if !labels_ui.engine_tried && ocr_ui.engine_tried { // after the OCR engine
ocr_ui_start(.{kind = .LABELS_CREATE});
} else if labels_ui.engine && (!labels_ui.set || labels_ui.set.hash != labels_ui.list_hash) && labels_ui.list.count {
ocr_ui_start(.{kind = .LABEL_SET});
}
}
if !labels_ui.window_open return;
SetNextWindowSize(.{640, 560}, .FirstUseEver);
if Begin(LABELS_WINDOW, *labels_ui.window_open) {
labels_window_contents();
}
End();
}
// On the OCR thread: makes the engine (LABELS_CREATE) or the newest list's set (LABEL_SET), with
// the list the task was given.
labels_ui_task :: (task: Ocr_Task) {
if task.kind == .LABELS_CREATE {
engine, ok, error := labels_create(labels_models_folder(), LABELS_MODEL);
free(labels_ui.engine_error);
labels_ui.engine_error = ifx ok then "" else copy_string(error);
if ok log("[Labels] % on %.%", LABELS_MODEL, engine.provider, ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
else log_error("[Labels] Could not start %: %", LABELS_MODEL, error);
labels_ui.engine = engine;
labels_ui.engine_tried = true;
}
if labels_ui.engine && task.label_names.count {
start := current_time_monotonic();
set, error := labels_make_set(labels_ui.engine, task.label_names, task.label_prompts, task.label_hash);
if !set { log_error("[Labels] The label list's prompts could not be scored: %", error); return; }
if labels_ui.set array_add(*labels_ui.retired_sets, labels_ui.set);
labels_ui.set = set;
log("[Labels] % labels ready (list %) in % ms.", set.names.count, set.hash, FF(to_float64_seconds(current_time_monotonic() - start) * 1000, 0));
}
}
// ---------------------------------------------------------------- the Labels window
#scope_file
labels_window_contents :: () { using ImGui;
busy := ocr_ui.thread_busy;
if busy && ocr_ui.task.kind == .LABELS_CREATE {
TextColored(app_theme.palette.dim, "Starting % ...", LABELS_MODEL);
} else if labels_ui.engine_error {
TextColored(app_theme.palette.warning, "Labelling could not start:");
TextWrapped("%", labels_ui.engine_error);
TextWrapped("Run `jai fetch_models.jai` in modules/Labels, then rebuild.");
} else if labels_ui.engine {
in_use, explanation := labels_gpu_status(labels_ui.engine);
Text("%: images on %, text on the CPU", labels_ui.engine.model.title, ifx in_use then "the GPU (CUDA)" else "the CPU");
if explanation text_with_links(explanation);
} else if !labels_ui.engine_tried {
TextColored(app_theme.palette.dim, "Starts after OCR.");
}
// How much of the library is labelled, and with which list: counted again only when the catalog
// or the list has changed, not every frame.
if labels_ui.counted_generation != viewer.catalog_generation || labels_ui.counted_hash != labels_ui.list_hash {
labels_ui.labelled_count, labels_ui.current_count = 0, 0;
for viewer.captures {
if it.removed || !it.catalog || !it.catalog.embedding.count continue;
labels_ui.labelled_count += 1;
if it.catalog.label_list == labels_ui.list_hash labels_ui.current_count += 1;
}
labels_ui.counted_generation = viewer.catalog_generation;
free(labels_ui.counted_hash,, context.default_allocator);
labels_ui.counted_hash = copy_string(labels_ui.list_hash,, context.default_allocator);
}
SeparatorText("The library");
Text("% of % captures labelled; % with this list.", labels_ui.labelled_count, viewer_capture_count(), labels_ui.current_count);
TextColored(app_theme.palette.dim, "The OCR window's scan labels the rest, and labels again from their vectors those scored with an earlier list.");
if SmallButton("OCR window") ocr_ui.window_open = true;
SeparatorText("The label list");
TextUnformatted(labels_list_path());
SameLine();
if SmallButton("Open") platform_open_file_in_default_program(labels_list_path(), app_program_data_dir());
if labels_ui.list_error TextColored(app_theme.palette.warning, "%", labels_ui.list_error);
ready := labels_ui.set && labels_ui.set.hash == labels_ui.list_hash;
TextColored(app_theme.palette.dim, "% labels, list %%", labels_ui.list.count, labels_ui.list_hash, ifx ready || !labels_ui.engine then "" else "; scoring its prompts...");
if BeginTable("##list", 2, .RowBg | .SizingStretchProp | .ScrollY, .{0, -FLOAT32_MIN}) {
TableSetupColumn("Name", .WidthFixed);
TableSetupColumn("Prompt");
TableSetupScrollFreeze(0, 1);
TableHeadersRow();
for labels_ui.list {
TableNextRow();
TableNextColumn(); TextUnformatted(it.name);
TableNextColumn(); TextUnformatted(it.prompt);
}
EndTable();
}
}
labels_set_list_hash :: () {
// FNV-1a (the Hash module's, 64-bit) over every name and prompt, each ended by a 0xFF byte, which
// UTF-8 text never has.
hash := FNV_64_OFFSET_BIAS;
for labels_ui.list {
hash = fnv1a_hash(it.name.data, it.name.count, hash);
hash = fnv1a_hash(0xFF, hash);
hash = fnv1a_hash(it.prompt.data, it.prompt.count, hash);
hash = fnv1a_hash(0xFF, hash);
}
free(labels_ui.list_hash,, context.default_allocator);
labels_ui.list_hash = sprint("%", formatInt(hash, base = 16, minimum_digits = 16),, context.default_allocator);
}
// A quoted string at the start of s, "" for a quote: its text (temp) and what follows.
labels_quoted :: (s: string) -> text: string, rest: string, ok: bool {
if !s.count || s[0] != #char "\"" return "", s, false;
builder: String_Builder;
builder.allocator = temp;
i := 1;
while i < s.count {
if s[i] == #char "\"" {
if i + 1 < s.count && s[i + 1] == #char "\"" { append(*builder, #char "\""); i += 2; continue; }
return builder_to_string(*builder,, temp), advance(s, i + 1), true;
}
append(*builder, s[i]);
i += 1;
}
return "", s, false;
}
#scope_export
// ---------------------------------------------------------------- the Capture window
// The capture's labels, under its OCR row: dim when they come from an earlier list.
labels_ui_capture_line :: (index: s32) { using ImGui;
entry := viewer.captures[index].catalog;
if !entry || !entry.labels.count {
if entry && entry.embedding.count TextColored(app_theme.palette.dim, "Labels: none yet with this list (the next scan adds them)");
return;
}
current := entry.label_list == labels_ui.list_hash;
builder: String_Builder;
builder.allocator = temp;
append(*builder, "Labels: ");
for entry.labels {
if it_index append(*builder, " ");
print_to_builder(*builder, "% %", it.name, FF(it.score, 3));
}
if !current append(*builder, " (from an earlier list)");
if current TextUnformatted(builder_to_string(*builder,, temp));
else TextColored(app_theme.palette.dim, "%", builder_to_string(*builder,, temp));
if IsItemHovered() SetTooltip("Scores are the model's own (0 to 1): small, but their order is what counts. Click a label in Stats, or search label:NAME.");
}

View File

@ -20,6 +20,7 @@ main :: () {
for app.args if it == "-library" && it_index + 1 < app.args.count app.library_folder = app.args[it_index + 1]; for app.args if it == "-library" && it_index + 1 < app.args.count app.library_folder = app.args[it_index + 1];
app.scan_and_exit = array_find(app.args, "-scan"); app.scan_and_exit = array_find(app.args, "-scan");
app.stats_and_exit = array_find(app.args, "-stats"); app.stats_and_exit = array_find(app.args, "-stats");
for app.args if it == "-search" && it_index + 1 < app.args.count app.start_search = app.args[it_index + 1];
defer report_memory_leaks(); defer report_memory_leaks();

View File

@ -3,11 +3,13 @@
// and in the Capture window the OCR button, the lines read drawn over the image, and the search's // and in the Capture window the OCR button, the lines read drawn over the image, and the search's
// matches among them. // matches among them.
// //
// Every reading is stored in its .mcap (mcap_store_ocr: one section per model) and its catalog entry // Every reading is stored in its .mcap (one OCR section per model) and its catalog entry is loaded
// is loaded again, so the overlay, the text shown and search all come from what is stored. // again, so the overlay, the text shown and search all come from what is stored. A capture read for
// labels too (app/labels_ui.jai) gets its labels and image vector stored in the same rewrite.
// //
// Making an engine, reading one capture and the scan all run on one background thread, one at a time; // Making the engines (OCR, then labels), the label list's vectors, reading one capture and the scan
// the UI only looks at what it has finished, and at the scan's progress counters. // all run on one background thread, one at a time; the UI only looks at what it has finished, and
// at the scan's progress counters.
OCR_WINDOW :: "OCR"; OCR_WINDOW :: "OCR";
OCR_DEFAULT_MODEL :: "ppocr-v5-mobile"; OCR_DEFAULT_MODEL :: "ppocr-v5-mobile";
@ -22,6 +24,7 @@ ocr_ui: struct {
// Owned by the main thread while thread_busy is false; by the background thread while it is true. // Owned by the main thread while thread_busy is false; by the background thread while it is true.
engine: *Ocr_Engine; engine: *Ocr_Engine;
engine_error: string; // why the last engine could not be made engine_error: string; // why the last engine could not be made
engine_tried: bool; // the first engine has been made, or failed: labels start after it
thread: Thread; thread: Thread;
thread_started: bool; thread_started: bool;
thread_busy: bool; // set by the main thread, cleared by the background one thread_busy: bool; // set by the main thread, cleared by the background one
@ -45,13 +48,16 @@ ocr_ui: struct {
toast_shown := false; toast_shown := false;
} }
Ocr_Task_Kind :: enum { NONE; CREATE; READ; SCAN; } Ocr_Task_Kind :: enum { NONE; CREATE; READ; SCAN; LABELS_CREATE; LABEL_SET; }
Ocr_Task :: struct { Ocr_Task :: struct {
kind: Ocr_Task_Kind; kind: Ocr_Task_Kind;
model: string; // CREATE model: string; // CREATE
path: string; // READ path: string; // READ
index: s32; // READ index: s32; // READ
// LABELS_CREATE and LABEL_SET: the label list as it was when the task started (heap).
label_names, label_prompts: [] string;
label_hash: string;
} }
// The library scan. The counters and flags are shared with the background thread. // The library scan. The counters and flags are shared with the background thread.
@ -68,9 +74,17 @@ Ocr_Scan :: struct {
paused_seconds: float64; // time spent paused, left out of the rate paused_seconds: float64; // time spent paused, left out of the rate
pause_start: Apollo_Time; pause_start: Apollo_Time;
elapsed_seconds: float64; // once it has ended: the time spent scanning, pauses left out elapsed_seconds: float64; // once it has ended: the time spent scanning, pauses left out
label_set: *Label_Set; // labels scored against this; null when the scan does not label
} }
Scan_Item :: struct { index: s32; path: string; } // A capture to read, and what it needs.
Scan_Item :: struct {
index: s32;
path: string;
ocr: bool; // read with the OCR engine
embed: bool; // its image vector made, and its labels
relabel: bool; // only its labels, from its stored vector (labelled with an earlier list)
}
ocr_models_folder :: () -> string { ocr_models_folder :: () -> string {
return tprint("%ocr-models", path_strip_filename(get_path_of_running_executable())); return tprint("%ocr-models", path_strip_filename(get_path_of_running_executable()));
@ -132,6 +146,15 @@ ocr_ui_start :: (task: Ocr_Task) -> bool {
ocr_ui.task = task; ocr_ui.task = task;
ocr_ui.task.model = copy_string(task.model); ocr_ui.task.model = copy_string(task.model);
ocr_ui.task.path = copy_string(task.path); ocr_ui.task.path = copy_string(task.path);
if task.kind == .LABELS_CREATE || task.kind == .LABEL_SET {
ocr_ui.task.label_names = NewArray(labels_ui.list.count, string);
ocr_ui.task.label_prompts = NewArray(labels_ui.list.count, string);
for labels_ui.list {
ocr_ui.task.label_names[it_index] = copy_string(it.name);
ocr_ui.task.label_prompts[it_index] = copy_string(it.prompt);
}
ocr_ui.task.label_hash = copy_string(labels_ui.list_hash);
}
ocr_ui.thread_busy = true; ocr_ui.thread_busy = true;
if ocr_ui.thread_started thread_deinit(*ocr_ui.thread); if ocr_ui.thread_started thread_deinit(*ocr_ui.thread);
ocr_ui.thread_started = thread_init(*ocr_ui.thread, ocr_ui_thread_proc, temporary_storage_size = 4 * 1024 * 1024, name = "ocr"); ocr_ui.thread_started = thread_init(*ocr_ui.thread, ocr_ui_thread_proc, temporary_storage_size = 4 * 1024 * 1024, name = "ocr");
@ -155,11 +178,17 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
ocr_ui.engine_error = ifx ok then "" else copy_string(error); ocr_ui.engine_error = ifx ok then "" else copy_string(error);
if ok log("[OCR] % on %.%", task.model, engine.provider, ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else ""); if ok log("[OCR] % on %.%", task.model, engine.provider, ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
else log_error("[OCR] Could not start %: %", task.model, error); else log_error("[OCR] Could not start %: %", task.model, error);
ocr_ui.engine_tried = true;
case .LABELS_CREATE; #through;
case .LABEL_SET;
labels_ui_task(task);
case .READ; case .READ;
start := current_time_monotonic(); start := current_time_monotonic();
items := Scan_Item.[.{task.index, task.path}]; set := ifx labels_ui.engine then labels_ui.set else null;
errors := ocr_read_and_store(items); items := Scan_Item.[.{index = task.index, path = task.path, ocr = true, embed = set != null}];
errors := ocr_read_and_store(items, set);
free(ocr_ui.read_error); free(ocr_ui.read_error);
ocr_ui.read_error = ifx errors[0] then errors[0] else ""; ocr_ui.read_error = ifx errors[0] then errors[0] else "";
ocr_ui.read_index = task.index; ocr_ui.read_index = task.index;
@ -172,59 +201,110 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
while scan.paused && !scan.cancel sleep_milliseconds(100); while scan.paused && !scan.cancel sleep_milliseconds(100);
if scan.cancel break; if scan.cancel break;
count := min(OCR_SCAN_BATCH, scan.items.count - position); count := min(OCR_SCAN_BATCH, scan.items.count - position);
errors := ocr_read_and_store(array_view(scan.items, position, count)); errors := ocr_read_and_store(array_view(scan.items, position, count), scan.label_set);
for errors if it { atomic_add(*scan.failed, 1); log_error("[OCR scan] % %", scan.items[position + it_index].path, it); free(it); } for errors if it { atomic_add(*scan.failed, 1); log_error("[OCR scan] % %", scan.items[position + it_index].path, it); free(it); }
atomic_add(*scan.done, count); atomic_add(*scan.done, count);
position += count; position += count;
reset_temporary_storage(); reset_temporary_storage();
} }
scan.elapsed_seconds = to_float64_seconds(current_time_monotonic() - scan.start) - scan.paused_seconds; scan.elapsed_seconds = to_float64_seconds(current_time_monotonic() - scan.start) - scan.paused_seconds;
log("[OCR scan] % of % captures read with % in % (% failed)%.", scan.done, scan.total, scan.model, ocr_duration_text(scan.elapsed_seconds), scan.failed, ifx scan.cancel then ", cancelled" else ""); log("[OCR scan] % of % captures read with %% in % (% failed)%.", scan.done, scan.total, scan.model, ifx scan.label_set then tprint(" and labelled with %", LABELS_MODEL) else "",
ocr_duration_text(scan.elapsed_seconds), scan.failed, ifx scan.cancel then ", cancelled" else "");
atomic_swap(*scan.running, false); atomic_swap(*scan.running, false);
} }
free(task.model); free(task.model);
free(task.path); free(task.path);
for task.label_names free(it);
for task.label_prompts free(it);
array_free(task.label_names);
array_free(task.label_prompts);
free(task.label_hash);
atomic_swap(*ocr_ui.thread_busy, false); atomic_swap(*ocr_ui.thread_busy, false);
return 0; return 0;
} }
// Reads items with the engine, stores each reading in its file, and hands each new catalog entry to // Where value is in list, or -1.
// the viewer. An error message (heap) for each item that failed, "" for the others. Background thread. scan_index_of :: (list: [] s64, value: s64) -> s64 {
ocr_read_and_store :: (items: [] Scan_Item) -> [] string { found, index := array_find(list, value);
return ifx found then index else -1;
}
// Reads items, as each asks: with the OCR engine; with the label engine (labels scored against set);
// or only labels again, from the vector stored in the file. Stores what each got in its file in one
// rewrite, and hands each new catalog entry to the viewer. An error message (heap) for each item that
// failed, "" for the others. Background thread.
ocr_read_and_store :: (items: [] Scan_Item, set: *Label_Set) -> [] string {
errors := NewArray(items.count, string); errors := NewArray(items.count, string);
if !ocr_ui.engine { for * errors it.* = copy_string("No OCR engine is running."); return errors; } if !ocr_ui.engine { for * errors it.* = copy_string("No OCR engine is running."); return errors; }
labels := labels_ui.engine;
// The image the Capture window shows: the rendered one, annotations and all, or the original. // The image the Capture window shows: the rendered one, annotations and all, or the original.
Prepared :: struct { pixels: *u8; width, height: s32; image: string; } Prepared :: struct { pixels: *u8; width, height: s32; image: string; stored_vector: [] u16; }
prepared := NewArray(items.count, Prepared,, temp); prepared := NewArray(items.count, Prepared,, temp);
images: [..] Ocr_Image; ocr_images: [..] Ocr_Image;
images.allocator = temp; ocr_images.allocator = temp;
image_of: [..] s64; // which item each image is ocr_of: [..] s64; // which item each OCR image is
image_of.allocator = temp; ocr_of.allocator = temp;
label_images: [..] Labels_Image;
label_images.allocator = temp;
label_of: [..] s64; // which item each image to label is
label_of.allocator = temp;
for item, n: items { for item, n: items {
data, read_ok := read_entire_file(item.path, log_errors = false,, temp); data, read_ok := read_entire_file(item.path, log_errors = false,, temp);
if !read_ok { errors[n] = copy_string("cannot be read"); continue; } if !read_ok { errors[n] = copy_string("cannot be read"); continue; }
m, ok, error := mcap_parse(cast([] u8) data); m, ok, error := mcap_parse(cast([] u8) data);
if !ok { errors[n] = copy_string(error); continue; } if !ok { errors[n] = copy_string(error); continue; }
if item.relabel {
chunk := mcap_find_chunk(m.front_chunks, mcap_tag(MCAP_EMBEDDING_TAG), LABELS_MODEL);
if chunk prepared[n].stored_vector = mcap_embedding_parse(chunk.data,, temp);
}
if !item.ocr && !item.embed continue;
png := mcap_rendered(m); png := mcap_rendered(m);
kind := "rendered"; kind := "rendered";
if !png.count { png = m.original; kind = "original"; } if !png.count { png = m.original; kind = "original"; }
w, h, channels: s32; w, h, channels: s32;
pixels := stbi_load_from_memory(png.data, cast(s32) png.count, *w, *h, *channels, 4); pixels := stbi_load_from_memory(png.data, cast(s32) png.count, *w, *h, *channels, 4);
if !pixels { errors[n] = copy_string("its image cannot be decoded"); continue; } if !pixels { errors[n] = copy_string("its image cannot be decoded"); continue; }
prepared[n] = .{pixels, w, h, kind}; prepared[n] = .{pixels, w, h, kind, prepared[n].stored_vector};
array_add(*images, .{pixels, w, h, w * 4}); if item.ocr { array_add(*ocr_images, .{pixels, w, h, w * 4}); array_add(*ocr_of, n); }
array_add(*image_of, n); if item.embed && labels && set { array_add(*label_images, .{pixels, w, h, w * 4}); array_add(*label_of, n); }
} }
defer for prepared if it.pixels stbi_image_free(it.pixels); defer for prepared if it.pixels stbi_image_free(it.pixels);
results := ocr_process(ocr_ui.engine, images,, temp); // Labels: vectors for the images to label, then each item's labels.
engine_text := tprint("onnxruntime %, %", ort.version, ifx ocr_ui.engine.provider == .CUDA then "CUDA" else "CPU"); Labelled :: struct { done: bool; labels: Mcap_Labels; vector: [] u16; }
for result, r: results { labelled := NewArray(items.count, Labelled,, temp);
n := image_of[r]; if labels && set {
if result.failed { errors[n] = copy_string(result.error); continue; } vectors, label_error := labels_embed_images(labels, label_images,, temp);
if label_error for label_of if !errors[it] errors[it] = sprint("labelling failed: %", label_error);
dims := labels.dimensions;
label_engine := tprint("onnxruntime %, %", ort.version, ifx labels.provider == .CUDA then "CUDA" else "CPU");
for n: 0..items.count - 1 {
if errors[n] continue;
vector: [] float;
at := scan_index_of(label_of, n);
if at >= 0 vector = array_view(vectors, at * dims, dims);
else if prepared[n].stored_vector.count == dims vector = labels_from_half(prepared[n].stored_vector,, temp);
if !vector.count continue;
l := *labelled[n];
l.done = true;
l.labels = .{engine = label_engine, date = mcap_ocr_now(), list = set.hash};
l.labels.labels = labels_for_vector(labels, set, vector.data);
if at >= 0 l.vector = labels_to_half(vector);
}
}
ocr_results := ocr_process(ocr_ui.engine, ocr_images,, temp);
engine_text := tprint("onnxruntime %, %", ort.version, ifx ocr_ui.engine.provider == .CUDA then "CUDA" else "CPU");
for n: 0..items.count - 1 {
if errors[n] continue;
section: Mcap_Ocr_Section; section: Mcap_Ocr_Section;
has_section := false;
r := scan_index_of(ocr_of, n);
if r >= 0 {
result := ocr_results[r];
if result.failed { errors[n] = copy_string(result.error); continue; }
has_section = true;
section.model = ocr_ui.engine.model.name; section.model = ocr_ui.engine.model.name;
section.engine = engine_text; section.engine = engine_text;
section.image = prepared[n].image; section.image = prepared[n].image;
@ -239,11 +319,20 @@ ocr_read_and_store :: (items: [] Scan_Item) -> [] string {
line.corners[it_index * 2 + 1] = cast(s32) round(corner.y); line.corners[it_index * 2 + 1] = cast(s32) round(corner.y);
} }
} }
}
chunks: [..] Mcap_Chunk;
chunks.allocator = temp;
if labelled[n].done {
array_add(*chunks, mcap_labels_chunk(LABELS_MODEL, labelled[n].labels));
if labelled[n].vector.count array_add(*chunks, mcap_embedding_chunk(LABELS_MODEL, labelled[n].vector));
}
if !has_section && !chunks.count { errors[n] = copy_string("nothing to store (the label list is not ready)"); continue; }
stored: bool; stored: bool;
error: string; error: string;
for 1..OCR_STORE_ATTEMPTS { for 1..OCR_STORE_ATTEMPTS {
stored, error = mcap_store_ocr(items[n].path, section); stored, error = mcap_store(items[n].path, ifx has_section then *section else null, chunks);
if stored break; if stored break;
sleep_milliseconds(250); sleep_milliseconds(250);
} }
@ -272,7 +361,7 @@ ocr_ui_scan_and_exit :: (busy: bool) {
if ocr_ui.models_error { finish(1, "[OCR scan] %", ocr_ui.models_error); return; } if ocr_ui.models_error { finish(1, "[OCR scan] %", ocr_ui.models_error); return; }
if viewer.scan_error { finish(1, "[OCR scan] %", viewer.scan_error); return; } if viewer.scan_error { finish(1, "[OCR scan] %", viewer.scan_error); return; }
if !ocr_ui.scan_requested { if !ocr_ui.scan_requested {
if busy || !ocr_ui.engine || viewer.catalog_loaded < viewer.captures.count return; if busy || !ocr_ui.engine || viewer.catalog_loaded < viewer.captures.count || !labels_ui_settled() return;
ocr_ui.scan_requested = true; ocr_ui.scan_requested = true;
ocr_ui.window_open = true; // its progress bar ocr_ui.window_open = true; // its progress bar
ocr_scan_start(); ocr_scan_start();
@ -300,15 +389,24 @@ ocr_scan_start :: () {
scan.* = .{}; scan.* = .{};
scan.items.allocator = context.default_allocator; scan.items.allocator = context.default_allocator;
scan.model = copy_string(ocr_ui.engine.model.name,, context.default_allocator); scan.model = copy_string(ocr_ui.engine.model.name,, context.default_allocator);
// Every capture without a reading by this model, in the order the grid shows them. if labels_ui.scan_labels && labels_ui.engine && labels_ui.set && labels_ui.set.hash == labels_ui.list_hash scan.label_set = labels_ui.set;
// Every capture this model has not read, or not labelled with this list, in the order the grid shows them.
for viewer.visible { for viewer.visible {
capture := *viewer.captures[it]; capture := *viewer.captures[it];
if capture.removed continue; if capture.removed continue;
if capture.catalog && catalog_section(capture.catalog, scan.model) continue; entry := capture.catalog;
array_add(*scan.items, .{it, copy_string(capture.path,, context.default_allocator)}); item := Scan_Item.{index = it};
item.ocr = !entry || !catalog_section(entry, scan.model);
if scan.label_set {
item.embed = !entry || !entry.embedding.count;
item.relabel = !item.embed && entry.label_list != scan.label_set.hash;
}
if !item.ocr && !item.embed && !item.relabel continue;
item.path = copy_string(capture.path,, context.default_allocator);
array_add(*scan.items, item);
} }
scan.total = scan.items.count; scan.total = scan.items.count;
if !scan.total { toast_show(.Success, "Every capture shown has been read with %", scan.model); return; } if !scan.total { toast_show(.Success, "Every capture shown has been read with %%", scan.model, ifx scan.label_set then " and labelled" else ""); return; }
scan.running = true; scan.running = true;
scan.start = current_time_monotonic(); scan.start = current_time_monotonic();
ocr_ui_start(.{kind = .SCAN}); ocr_ui_start(.{kind = .SCAN});
@ -326,8 +424,13 @@ ocr_scan_ui :: () { using ImGui;
if Button(temp_c_string(label)) ocr_scan_start(); if Button(temp_c_string(label)) ocr_scan_start();
if !can_scan EndDisabled(); if !can_scan EndDisabled();
SameLine(); SameLine();
labels_ready := labels_ui.engine && labels_ui.set && labels_ui.set.hash == labels_ui.list_hash;
if !labels_ready BeginDisabled();
Checkbox("Labels too", *labels_ui.scan_labels);
if !labels_ready EndDisabled();
if IsItemHovered(.AllowWhenDisabled) SetTooltip(ifx labels_ready then tprint("Also labels each capture with %, storing its labels and image vector", LABELS_MODEL) else "Labelling is not ready: see the Labels window");
if !catalog_ready TextColored(app_theme.palette.dim, "(once the catalog has loaded)"); if !catalog_ready TextColored(app_theme.palette.dim, "(once the catalog has loaded)");
else TextColored(app_theme.palette.dim, "Reads every capture without a reading by this model. Each is stored as it is read, so a scan can stop and start again."); else TextColored(app_theme.palette.dim, "Reads every capture this model has not read, and labels those not yet labelled with this list. Each is stored as it is read, so a scan can stop and start again.");
if scan.total && scan.done { if scan.total && scan.done {
TextColored(app_theme.palette.dim, "Last scan: % of % read with % in %, % failed.", scan.done, scan.total, scan.model, ocr_duration_text(scan.elapsed_seconds), scan.failed); TextColored(app_theme.palette.dim, "Last scan: % of % read with % in %, % failed.", scan.done, scan.total, scan.model, ocr_duration_text(scan.elapsed_seconds), scan.failed);
} }

View File

@ -28,6 +28,8 @@ Stats_Result :: struct {
years: [..] Stats_Year; // oldest first years: [..] Stats_Year; // oldest first
most_words: [..] Stats_Capture; // most first most_words: [..] Stats_Capture; // most first
common_words: [..] Stats_Word; // commonest first common_words: [..] Stats_Word; // commonest first
labelled: s64; // captures with labels (app/labels_ui.jai)
top_labels: [..] Stats_Word; // how many captures have each label as their best, most first
} }
stats_ui: struct { stats_ui: struct {
@ -60,6 +62,8 @@ stats_result_free :: (s: *Stats_Result) {
free(s.model); free(s.model);
for s.years free(it.year); for s.years free(it.year);
for s.common_words free(it.word); for s.common_words free(it.word);
for s.top_labels free(it.word);
array_free(s.top_labels);
array_free(s.years); array_free(s.years);
array_free(s.most_words); array_free(s.most_words);
array_free(s.common_words); array_free(s.common_words);
@ -74,8 +78,10 @@ stats_model :: () -> string {
stats_ui_frame :: () { using ImGui; stats_ui_frame :: () { using ImGui;
// -stats: once the catalog and the search are settled, at once, on this thread. // -stats: once the catalog and the search are settled, at once, on this thread.
waiting_for_labels := false;
for viewer.search_terms if it.field == .LIKE && !labels_ui.search_ready waiting_for_labels = true;
if app.stats_and_exit && viewer.captures.count && viewer.catalog_loaded >= viewer.captures.count && !ocr_ui.thread_busy if app.stats_and_exit && viewer.captures.count && viewer.catalog_loaded >= viewer.captures.count && !ocr_ui.thread_busy
&& !viewer.visible_dirty && !background.searching { && !viewer.visible_dirty && !background.searching && !waiting_for_labels {
items := background_snapshot_visible(); items := background_snapshot_visible();
stats_result_arrived(background.stats_generation, stats_compute(items, stats_model())); stats_result_arrived(background.stats_generation, stats_compute(items, stats_model()));
array_free(items,, context.default_allocator); array_free(items,, context.default_allocator);
@ -121,6 +127,21 @@ stats_log :: () {
builder.allocator = temp; builder.allocator = temp;
for s.common_words print_to_builder(*builder, "% %, ", it.word, it.count); for s.common_words print_to_builder(*builder, "% %, ", it.word, it.count);
log("[stats] commonest: %", builder_to_string(*builder,, temp)); log("[stats] commonest: %", builder_to_string(*builder,, temp));
labels: String_Builder;
labels.allocator = temp;
for s.top_labels print_to_builder(*labels, "% %, ", it.word, it.count);
log("[stats] % labelled; best labels: %", s.labelled, builder_to_string(*labels,, temp));
if viewer.search_terms.count {
shown: String_Builder;
shown.allocator = temp;
for viewer.visible {
if it_index >= 10 break;
capture := *viewer.captures[it];
print_to_builder(*shown, "%%", ifx it_index then ", " else "", capture.name);
if capture.catalog && capture.catalog.labels.count print_to_builder(*shown, " (%)", capture.catalog.labels[0].name);
}
log("[stats] search \"%\": % shown; first: %", to_string(viewer.search_buffer.data), viewer.visible.count, builder_to_string(*shown,, temp));
}
} }
stats_window_contents :: () { using ImGui; stats_window_contents :: () { using ImGui;
@ -197,6 +218,26 @@ stats_window_contents :: () { using ImGui;
if Selectable(temp_c_string(tprint("% words % %##most%", stats_number(it.words), date, capture.name, it_index))) viewer_request_full(it.index); if Selectable(temp_c_string(tprint("% words % %##most%", stats_number(it.words), date, capture.name, it_index))) viewer_request_full(it.index);
} }
SeparatorText("Top labels");
if !s.labelled {
TextColored(app_theme.palette.dim, "None of these captures is labelled yet: the OCR window's scan labels them (Labels too).");
} else {
TextColored(app_theme.palette.dim, "Each capture's best label, of % labelled. Click one to search for it.", stats_number(s.labelled));
if BeginTable("##labels", 3, .SizingFixedFit) {
TableSetupColumn("label");
TableSetupColumn("captures");
TableSetupColumn("bar", .WidthStretch);
for s.top_labels {
TableNextRow();
TableNextColumn();
if SmallButton(temp_c_string(tprint("%##label%", it.word, it_index))) viewer_set_search(tprint("label:\"%\"", it.word));
TableNextColumn(); TextUnformatted(stats_number(it.count));
TableNextColumn(); ProgressBar(cast(float) it.count / max(s.labelled, 1), .{-FLOAT32_MIN, 0}, temp_c_string(stats_percent(it.count, s.labelled)));
}
EndTable();
}
}
SeparatorText("Commonest words"); SeparatorText("Commonest words");
TextColored(app_theme.palette.dim, "Words of 3 letters or more, without the commonest English ones. Click one to search for it."); TextColored(app_theme.palette.dim, "Words of 3 letters or more, without the commonest English ones. Click one to search for it.");
right_edge := GetCursorScreenPos().x + GetContentRegionAvail().x; right_edge := GetCursorScreenPos().x + GetContentRegionAvail().x;
@ -224,6 +265,9 @@ stats_compute :: (items: [] Search_Item, model: string) -> *Stats_Result {
s.years.allocator = context.default_allocator; s.years.allocator = context.default_allocator;
s.most_words.allocator = context.default_allocator; s.most_words.allocator = context.default_allocator;
s.common_words.allocator = context.default_allocator; s.common_words.allocator = context.default_allocator;
s.top_labels.allocator = context.default_allocator;
top: Table(string, s64); // keys borrow the entries' label names
top.allocator = temp;
per_capture: [..] Stats_Capture; per_capture: [..] Stats_Capture;
per_capture.allocator = temp; per_capture.allocator = temp;
@ -240,6 +284,11 @@ stats_compute :: (items: [] Search_Item, model: string) -> *Stats_Result {
year := find_or_add(*years, year_text); year := find_or_add(*years, year_text);
year.year = year_text; year.year = year_text;
year.captures += 1; year.captures += 1;
if entry && entry.labels.count {
s.labelled += 1;
count := find_or_add(*top, entry.labels[0].name);
count.* += 1;
}
section := catalog_section(entry, model); section := catalog_section(entry, model);
if !section continue; if !section continue;
@ -286,6 +335,9 @@ stats_compute :: (items: [] Search_Item, model: string) -> *Stats_Result {
array_add(*s.common_words, .{copy_string(it.word,, context.default_allocator), it.count}); array_add(*s.common_words, .{copy_string(it.word,, context.default_allocator), it.count});
} }
for count, name: top array_add(*s.top_labels, .{copy_string(name,, context.default_allocator), count});
quick_sort(s.top_labels, (a: Stats_Word, b: Stats_Word) -> s64 { return ifx a.count != b.count then b.count - a.count else compare(a.word, b.word); });
s.compute_ms = to_float64_seconds(current_time_monotonic() - start) * 1000; s.compute_ms = to_float64_seconds(current_time_monotonic() - start) * 1000;
return s; return s;
} }

View File

@ -167,6 +167,7 @@ viewer_startup :: () {
viewer.workers_started += 1; viewer.workers_started += 1;
} }
background_startup(); background_startup();
if app.start_search viewer_set_search(app.start_search);
} }
// On the shutdown thread: stops the workers. The textures go with the device. // On the shutdown thread: stops the workers. The textures go with the device.
@ -398,18 +399,23 @@ viewer_apply_catalog_updates :: () {
more_to_load := viewer.catalog_next < viewer.captures.count; more_to_load := viewer.catalog_next < viewer.captures.count;
unlock(*viewer.mutex); unlock(*viewer.mutex);
// Whether the date order can change: a capture's first entry (one a refresh added), one lost, or a
// changed date. An entry read again after the scan stored a reading keeps its place, so a scan
// does not set off a search of the whole library for each capture it stores.
order_changes := false;
for updates { for updates {
capture := *viewer.captures[it.index]; capture := *viewer.captures[it.index];
if !capture.catalog viewer.catalog_loaded += 1; if !capture.catalog viewer.catalog_loaded += 1;
if !capture.catalog || !it.entry || it.entry.date != capture.catalog.date order_changes = true;
if it.entry || !capture.catalog { if it.entry || !capture.catalog {
background_retire(capture.catalog); // a search or Stats job may be reading it background_retire(capture.catalog); // a search or Stats job may be reading it
capture.catalog = it.entry; capture.catalog = it.entry;
} }
} }
if updates.count viewer.catalog_generation += 1; if updates.count viewer.catalog_generation += 1;
// Under a search, or once sorted by date, entries arriving can move captures (after a refresh, // Under a search, any entry can start or stop matching (a scan adds OCR text and labels);
// the new ones get their dates). // background_search_start keeps those searches to a few a second.
if updates.count && (viewer.search_terms.count || viewer.sorted_by_date) viewer.visible_dirty = true; if updates.count && (viewer.search_terms.count || (viewer.sorted_by_date && order_changes)) viewer.visible_dirty = true;
if !viewer.sorted_by_date && viewer.catalog_loaded >= viewer.captures.count { if !viewer.sorted_by_date && viewer.catalog_loaded >= viewer.captures.count {
viewer.sorted_by_date = true; viewer.sorted_by_date = true;
viewer.visible_dirty = true; viewer.visible_dirty = true;
@ -766,6 +772,8 @@ viewer_ui :: () { using ImGui;
SameLine(); SameLine();
if Button("Stats") stats_ui.window_open = !stats_ui.window_open; // app/stats_ui.jai if Button("Stats") stats_ui.window_open = !stats_ui.window_open; // app/stats_ui.jai
SameLine(); SameLine();
if Button("Labels") labels_ui.window_open = !labels_ui.window_open; // app/labels_ui.jai
SameLine();
busy := ocr_ui.thread_busy; // a scan or a reading refers to captures by index busy := ocr_ui.thread_busy; // a scan or a reading refers to captures by index
if busy BeginDisabled(); if busy BeginDisabled();
if Button("Refresh") viewer_refresh(); if Button("Refresh") viewer_refresh();
@ -935,6 +943,7 @@ viewer_full_window :: () { using ImGui;
SameLine(); SameLine();
if SmallButton("100%") { viewer.full_fit = .OFF; viewer.full_zoom = 100; } if SmallButton("100%") { viewer.full_fit = .OFF; viewer.full_zoom = 100; }
ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai
labels_ui_capture_line(viewer.full_index); // app/labels_ui.jai
// Left and Right step through the captures in grid order while this window has focus. // Left and Right step through the captures in grid order while this window has focus.
if IsWindowFocused(.RootAndChildWindows) { if IsWindowFocused(.RootAndChildWindows) {

View File

@ -58,6 +58,10 @@ query_tests :: () {
expect(terms[3].field == .NOTE && terms[3].text == "late", "note: %", terms[3]); expect(terms[3].field == .NOTE && terms[3].text == "late", "note: %", terms[3]);
expect(terms[4].field == .ANY && terms[4].text == "http://x.y", "an unknown prefix stays part of the word: %", terms[4]); expect(terms[4].field == .ANY && terms[4].text == "http://x.y", "an unknown prefix stays part of the word: %", terms[4]);
labels := search_parse("label:schematic like:\"a circuit board\"");
expect(labels.count == 2 && labels[0].field == .LABEL && labels[0].text == "schematic" && labels[1].field == .LIKE && labels[1].text == "a circuit board",
"label: and like: prefixes: %", labels);
phrase := search_parse("title:\"two words\""); phrase := search_parse("title:\"two words\"");
expect(phrase.count == 1 && phrase[0].field == .TITLE && phrase[0].text == "two words", "a prefixed phrase: %", phrase); expect(phrase.count == 1 && phrase[0].field == .TITLE && phrase[0].text == "two words", "a prefixed phrase: %", phrase);
expect(search_parse(" ").count == 0, "blank is no terms"); expect(search_parse(" ").count == 0, "blank is no terms");
@ -81,6 +85,16 @@ match_tests :: () {
expect(search_matches(*entry, search_parse("")), "no terms match everything"); expect(search_matches(*entry, search_parse("")), "no terms match everything");
expect(!search_matches(null, search_parse("x")), "an entry not loaded matches no search"); expect(!search_matches(null, search_parse("x")), "an entry not loaded matches no search");
entry.find_labels = "schematic\npcb layout\n";
expect(search_matches(*entry, search_parse("label:schematic")), "a label word");
expect(search_matches(*entry, search_parse("schematic")), "a plain word matches labels too");
expect(!search_matches(*entry, search_parse("ocr:schematic")), "an OCR word is not a label's");
expect(!search_matches(*entry, search_parse("like:anything")), "like: needs an image vector");
half := u16.[0x3C00];
entry.embedding = half;
expect(search_matches(*entry, search_parse("like:anything")), "like: takes a labelled capture (the ranking is the background's)");
entry.embedding = .[];
expect(search_line_matches("0.1uF : Capacitor_SMD", search_parse("capacitor")), "a line holding a word"); expect(search_line_matches("0.1uF : Capacitor_SMD", search_parse("capacitor")), "a line holding a word");
expect(!search_line_matches("0.1uF : Capacitor_SMD", search_parse("title:capacitor")), "a title word is not an OCR line's"); expect(!search_line_matches("0.1uF : Capacitor_SMD", search_parse("title:capacitor")), "a title word is not an OCR line's");
} }

133
src/test/labels_tests.jai Normal file
View File

@ -0,0 +1,133 @@
// Labels (modules/Labels): the tokenizer's ids for known phrases (from Hugging Face's tokenizers on
// the same tokenizer.json), unit vectors from both encoders, and the model ranking a drawn text image
// as text rather than a photo. Needs the label models beside the program (the build copies them);
// without them, the suite says so and passes.
#scope_file
checks_run := 0;
checks_failed := 0;
MAX_GROUPS :: 8;
group_names : [MAX_GROUPS] string;
group_checks : [MAX_GROUPS] int;
group_fails : [MAX_GROUPS] int;
group_count := 0;
output_builder: *String_Builder;
emit :: (format_string: string, args: ..Any) {
if output_builder print_to_builder(output_builder, format_string, ..args);
else print(format_string, ..args);
} @PrintLike
group :: (name: string) {
assert(group_count < MAX_GROUPS, "raise MAX_GROUPS");
group_names[group_count] = name;
group_count += 1;
emit("\n-- % --\n", name);
}
expect :: (condition: bool, format: string, args: ..Any, loc := #caller_location) {
checks_run += 1;
if group_count group_checks[group_count-1] += 1;
if condition return;
checks_failed += 1;
if group_count group_fails[group_count-1] += 1;
emit(" FAIL [%:%] %\n", group_names[max(group_count-1, 0)], loc.line_number, tprint(format, ..args));
}
report :: () -> bool {
emit("\n");
for 0..group_count-1 {
if group_fails[it] emit(" % -- % checks, % FAILED\n", group_names[it], group_checks[it], group_fails[it]);
else emit(" % -- % checks\n", group_names[it], group_checks[it]);
}
if checks_failed emit("\n% OF % LABELS CHECKS FAILED\n", checks_failed, checks_run);
else emit("\nALL LABELS TESTS PASSED (% checks over % groups)\n", checks_run, group_count);
return checks_failed == 0;
}
// From Hugging Face's tokenizers 0.23.2 on the same tokenizer.json, lowercased, cut and padded to 64.
Known_Tokens :: struct { text: string; ids: [] s64; }
KNOWN :: Known_Tokens.[
.{"code editor", .[2564, 10476, 1]},
.{"A screenshot of a terminal", .[235250, 13703, 576, 476, 17145, 1]},
.{"", .[1]},
];
// labels_float_to_half is a copy of the Float16 module's float_to_half_fast3 marked #no_aoc: the
// same bits for every value (here in a debug build, where the module's own is not stopped).
half_tests :: () {
group("half floats");
values: [..] float;
values.allocator = temp;
array_add(*values, ..float.[0, -0.0, 1, -1, 0.5, 65504, 65520, 1000000, -1000000, cast(float) 0.00006103515625, cast(float) 0.0000001, FLOAT32_INFINITY, -FLOAT32_INFINITY]);
x: float = 0.00001;
for 1..10_000 { array_add(*values, x, -x * 0.37); x *= 1.0027; }
differ := 0;
for values if labels_float_to_half(it) != cast(u16) float32_to_float16(it) differ += 1;
expect(differ == 0, "% of % values convert to different bits", differ, values.count);
}
labels_tests :: () {
half_tests();
folder := labels_models_folder();
if !file_exists(tprint("%/models.config", folder)) {
emit(" (skipped: no label models in %)\n", folder);
return;
}
group("engine");
engine, created, error := labels_create(folder, LABELS_MODEL);
expect(created, "the label engine starts: %", error);
if !created return;
defer labels_destroy(engine);
emit(" images on %; % dimensions\n", ifx engine.provider == .CUDA then "CUDA" else "the CPU", engine.dimensions);
group("tokenizer");
for KNOWN {
ids := tokenizer_encode(engine.tokenizer, it.text, 64);
same := ids.count == 64;
for known, i: it.ids if !same || ids[i] != known same = false;
for it.ids.count..63 if same && ids[it] != 0 same = false; // <pad>
expect(same, "\"%\" tokenizes as Hugging Face's does: % (got %)", it.text, it.ids, array_view(ids, 0, it.ids.count + 1));
}
group("vectors");
prompts := string.["a screenshot of text on a white background", "a photo of a cat", "a photo of a mountain landscape"];
texts, text_error := labels_embed_texts(engine, prompts,, temp);
expect(!text_error && texts.count == prompts.count * engine.dimensions, "the prompts' vectors are made (%)", text_error);
if text_error || texts.count != prompts.count * engine.dimensions return;
for 0..prompts.count - 1 expect(abs(vector_length(array_view(texts, it * engine.dimensions, engine.dimensions)) - 1) < 0.001, "the vector of \"%\" is a unit vector", prompts[it]);
drawn, drawn_ok := test_drawn_text();
if !drawn_ok {
emit(" (the text image is drawn with GDI, on Windows only: skipped)\n");
return;
}
image := Labels_Image.{drawn.pixels, drawn.width, drawn.height, drawn.stride};
vector, image_error := labels_embed_images(engine, .[image],, temp);
expect(!image_error && vector.count == engine.dimensions, "the image's vector is made (%)", image_error);
if image_error || vector.count != engine.dimensions return;
expect(abs(vector_length(vector) - 1) < 0.001, "the image's vector is a unit vector");
scores: [3] float;
for 0..2 scores[it] = labels_score(engine, vector.data, texts.data + it * engine.dimensions);
expect(scores[0] > scores[1] && scores[0] > scores[2], "drawn text scores as text (%) above a cat (%) and a landscape (%)", scores[0], scores[1], scores[2]);
}
vector_length :: (v: [] float) -> float {
sum: float;
for v sum += it * it;
return sqrt(sum);
}
#scope_export
run_labels_tests :: (builder: *String_Builder = null) -> bool {
output_builder = builder;
emit("=== labels test suite ===\n");
push_allocator(context.default_allocator);
labels_tests();
return report();
}

View File

@ -204,6 +204,16 @@ turn_tests :: () {
#scope_export #scope_export
// LINES drawn black on white, as these tests read them; for other suites (labels_tests.jai). Temp.
test_drawn_text :: () -> Ocr_Image, ok: bool {
#if OS == .WINDOWS {
image, ok := drawn_lines();
return image, ok;
} else {
return .{}, false;
}
}
run_ocr_tests :: (builder: *String_Builder = null) -> bool { run_ocr_tests :: (builder: *String_Builder = null) -> bool {
output_builder = builder; output_builder = builder;
emit("=== OCR test suite ===\n"); emit("=== OCR test suite ===\n");

View File

@ -40,6 +40,7 @@ Test_Type :: enum s32 {
logger_suite; logger_suite;
catalog_suite; catalog_suite;
ocr_suite; ocr_suite;
labels_suite;
} }
testing_started :: () -> bool { testing_started :: () -> bool {
@ -82,6 +83,7 @@ run_all_tests :: () {
add_suite_test(.logger_suite, "logger"); add_suite_test(.logger_suite, "logger");
add_suite_test(.catalog_suite, "catalog"); add_suite_test(.catalog_suite, "catalog");
add_suite_test(.ocr_suite, "ocr"); add_suite_test(.ocr_suite, "ocr");
add_suite_test(.labels_suite, "labels");
} }
// One work item per suite. Each suite keeps its tallies in file-scope globals, so the whole // One work item per suite. Each suite keeps its tallies in file-scope globals, so the whole
@ -190,6 +192,8 @@ test_thread_group_proc :: (group: *Thread_Group, thread: *Thread, work: *void) -
task.pass = run_catalog_tests(task.output); task.pass = run_catalog_tests(task.output);
case .ocr_suite; case .ocr_suite;
task.pass = run_ocr_tests(task.output); task.pass = run_ocr_tests(task.output);
case .labels_suite;
task.pass = run_labels_tests(task.output);
} }
return .CONTINUE; return .CONTINUE;