diff --git a/CMakeLists.txt b/CMakeLists.txt index 73b50b6..9eff942 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ set(RCLI_SOURCES src/commands/cmd_tool.cpp src/commands/cmd_image.cpp src/commands/cmd_segment.cpp + src/commands/cmd_ocr.cpp src/commands/cmd_serve.cpp src/commands/cmd_show.cpp src/commands/cmd_stt.cpp diff --git a/cmake/sdk-pin.cmake b/cmake/sdk-pin.cmake index a57202d..a0e6327 100644 --- a/cmake/sdk-pin.cmake +++ b/cmake/sdk-pin.cmake @@ -12,10 +12,10 @@ # older lock and pinning 1.1.1 would have failed fetch-kit.sh against the very # kit it validates. 0.20.33's kits were built after that bump, so these values # are read from the shipped SCHEMA_LOCK rather than carried forward. -set(RCLI_PINNED_SDK_VERSION "0.20.34") -set(RCLI_PINNED_IDL_VERSION "1.1.1") -set(RCLI_PINNED_IDL_SCHEMA_SHA256 "ec50b7ca4beff9fa065c5ae1f12dbbc0c996d8b43e64c44bf350ef86a1fa41aa") +set(RCLI_PINNED_SDK_VERSION "0.20.36") +set(RCLI_PINNED_IDL_VERSION "1.2.0") +set(RCLI_PINNED_IDL_SCHEMA_SHA256 "571199c430eead0638472fc55355513831aeb2139e6fee5d5cd841f1b01d5a43") set(RCLI_PINNED_IDL_PROTOC_VERSION "35.1") -set(RCLI_PINNED_KIT_SHA256_MACOS_ARM64 "8bf2019b27f10001b33d78338b5dcf78a14977c70fcded3f8a3e0647ca8da188") -set(RCLI_PINNED_KIT_SHA256_WINDOWS_X64 "d472ada77c67bdf4445cd0ba12cb029e9f9cc3ff09937b18fe8025d5e28e5c6f") -set(RCLI_PINNED_KIT_SHA256_WINDOWS_ARM64 "4d1984ec8c3867a2fe23ed660e586b5ff8515b31211acf8eff7144dfee9cea22") +set(RCLI_PINNED_KIT_SHA256_MACOS_ARM64 "ed2ddeb64c22936a8c3769ba15734455e4a5aa1a719a33ca97b5e765a5848da6") +set(RCLI_PINNED_KIT_SHA256_WINDOWS_X64 "9014f0c76fb4320a7cee8658b9309aeb9f173baec7b9907510a38930d5693fcb") +set(RCLI_PINNED_KIT_SHA256_WINDOWS_ARM64 "4a877ab7a326fdf11fa8593f25170dd15b94949c15aa04a774547eeaea12de75") diff --git a/src/app.cpp b/src/app.cpp index 9e2ccf1..8bcad67 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -56,6 +56,7 @@ void configure_app(CLI::App& app, GlobalOptions& options) { commands::register_image(app, options); commands::register_diarize(app, options); commands::register_segment(app, options); + commands::register_ocr(app, options); commands::register_voice(app, options); commands::register_rag(app, options); commands::register_models(app, options); diff --git a/src/catalog/catalog.cpp b/src/catalog/catalog.cpp index 11664d7..499b9e6 100644 --- a/src/catalog/catalog.cpp +++ b/src/catalog/catalog.cpp @@ -1749,6 +1749,31 @@ constexpr CatalogEntry kCatalog[] = { // 4225 ms of audio (32.8x realtime), 24 kHz, no NaNs. Gate A mel distance 0.2832-0.3159, where // upstream Kokoro's own real-op CustomSTFT scores 0.289 against its complex path and two // DIFFERENT utterances score 2.63-2.71 -- i.e. at the vocoder's floor, not conversion loss. + // The first ANE OCR row, and NeuRT's ninth served primitive (ABI v11). A detector + + // recognizer PAIR: the recognizer's input is a grid-sampled crop of the DETECTOR's + // 128-channel feature map, so neither half is usable alone -- which is why the three + // previously published nemotron-ocr bundles, recognizer-only, could never run. This bundle + // carries both, and the runtime now REFUSES a recognizer-only one by name. + // + // Gate A on an M4 Max: confidence 0.99995617, rbox 0.99993401, feature map 0.99999610 + // (min 0.99993401 against a 0.999 bar). Detector latency 89.55 ms cpuAndNeuralEngine vs + // 186.65 ms cpuOnly -- 2.08x faster with the ANE admitted. + // + // DECODE QUALITY, stated rather than implied: greedy CTC, so reads are word-fragments. + // Upstream uses beam search plus a Kneser-Ney LM that lives in a torch CUDAExtension and + // cannot exist on Apple hardware. The pipeline itself is proven correct -- identical + // line-for-line to a pure-Python reference built from the torch detector, geometry_ref.py + // and the verbatim torch recognizer. + // + // The .zip, NOT the repo root: a bare huggingface.co// URL makes `rcli pull` + // fetch the repo's HTML page and report `done 100%`. + {"nemotron_ocr_v1_full_ane", "ocr-ane", + "Nemotron-OCR v1 detector+recognizer (Apple Neural Engine)", + v1::MODEL_CATEGORY_OCR, v1::INFERENCE_FRAMEWORK_COREML, + v1::MODEL_FORMAT_MLPACKAGE, + "https://huggingface.co/runanywhere/nemotron-ocr-v1-full_ANE/resolve/main/" + "nemotron-ocr-v1-full_ANE.zip", + nullptr, 0, 93253733LL, 0, false}, {"kokoro_82m_ane", "kokoro-ane", "Kokoro 82M (Apple Neural Engine)", v1::MODEL_CATEGORY_SPEECH_SYNTHESIS, v1::INFERENCE_FRAMEWORK_COREML, diff --git a/src/commands/cmd_ocr.cpp b/src/commands/cmd_ocr.cpp new file mode 100644 index 0000000..3aa7753 --- /dev/null +++ b/src/commands/cmd_ocr.cpp @@ -0,0 +1,171 @@ +/** + * @file cmd_ocr.cpp + * @brief `rcli ocr --model ` — read the text on a page. + * + * Mirrors cmd_segment's shape (bootstrap → resolve model → one commons path → render), against + * the OCR service promoted to `ocr_ops` in ABI v11: + * + * rac_ocr_create(model) → rac_ocr_initialize(path) → rac_ocr_read_page(image, &result) + * → rac_ocr_result_free / _cleanup / _destroy + * + * `read_page` and NOT `recognize`. Full-page OCR is two models, and for the family this serves + * the recognizer's input is a grid-sampled crop of the DETECTOR's feature map rather than a line + * image — so `recognize` returns NOT_SUPPORTED and there is no line-level verb to expose here. + * + * PPM (P6) for the same reason cmd_segment uses it: the dependency-free image format the CLI can + * decode without linking a codec. + */ +#include +#include +#include +#include + +#include "commands/commands.h" +#include "commands/model_setup.h" +#include "io/image_io.h" +#include "io/output.h" +#include "rac/features/ocr/rac_ocr_service.h" + +namespace rcli::commands { + +namespace { + +int resolve_model_path(const GlobalOptions& options, const std::string& ref, + std::string* out_path) { + ResolvedModelPaths model; + const int setup = ensure_model_ready(options, ref, &model); + if (setup != 0) { + return setup; + } + *out_path = model.primary_path; + return 0; +} + +void print_result(const GlobalOptions& options, const std::string& model_ref, + const rac_ocr_result_t& result) { + if (options.json) { + out::JsonWriter json; + json.begin_object() + .field("model", model_ref) + .field("region_count", static_cast(result.num_regions)) + .field("processing_time_ms", static_cast(result.processing_time_ms)); + json.begin_array("regions"); + for (size_t i = 0; i < result.num_regions; ++i) { + const rac_ocr_region_t& r = result.regions[i]; + json.begin_array_object() + .field("text", r.text ? r.text : "") + .field("confidence", static_cast(r.confidence)); + // The quad, not just the text. Geometry is half of what OCR + // produces -- dropping it here would make the JSON strictly less + // useful than the C API it wraps, and it is what a caller needs to + // crop, sort into reading order, or draw a box. + json.begin_array("quad"); + for (int q = 0; q < 8; ++q) { + json.value(static_cast(r.quad[q])); + } + json.end_array().end_object(); + } + json.end_array().end_object(); + out::result_line(json.str()); + return; + } + + if (result.num_regions == 0) { + out::result_line("(no text found)"); + } else { + std::vector> rows; + rows.reserve(result.num_regions); + for (size_t i = 0; i < result.num_regions; ++i) { + const rac_ocr_region_t& r = result.regions[i]; + char conf[16]; + std::snprintf(conf, sizeof(conf), "%.2f", static_cast(r.confidence)); + rows.push_back({conf, r.text ? r.text : ""}); + } + out::table({"conf", "text"}, rows); + } + if (options.verbose) { + out::status_line("(" + std::to_string(result.processing_time_ms) + " ms)"); + } +} + +int run_ocr(const GlobalOptions& options, const std::string& image_path, + const std::string& model_ref) { + Bootstrapped env; + if (bootstrap(options, &env) != RAC_SUCCESS) { + return 1; + } + if (model_ref.empty()) { + out::error_line("--model is required (an OCR model id or on-disk path)"); + return 2; + } + + std::string model_path; + const int resolve = resolve_model_path(options, model_ref, &model_path); + if (resolve != 0) { + return resolve; + } + + image::RgbImage src; + std::string err; + if (!image::read_ppm(image_path, &src, &err)) { + out::error_line(err); + return 1; + } + + rac_handle_t handle = nullptr; + rac_result_t rc = rac_ocr_create(model_path.c_str(), &handle); + if (rc != RAC_SUCCESS) { + out::error_line("could not create the OCR service: " + out::describe_result(rc)); + return 1; + } + rc = rac_ocr_initialize(handle, model_path.c_str()); + if (rc != RAC_SUCCESS) { + // The manifest refuses a recognizer-only bundle BY NAME, which is what every + // nemotron-ocr bundle published before nemotron-ocr-v1-full_ANE is. + out::error_line("could not initialize the OCR model: " + out::describe_result(rc)); + rac_ocr_destroy(handle); + return 1; + } + + rac_ocr_image_t image = {}; + image.format = RAC_OCR_FORMAT_RGB8; + image.pixels = src.rgb.data(); + image.width = src.width; + image.height = src.height; + + rac_ocr_result_t result = {}; + rc = rac_ocr_read_page(handle, &image, &result); + if (rc != RAC_SUCCESS) { + out::error_line("read_page failed: " + out::describe_result(rc)); + rac_ocr_result_free(&result); + rac_ocr_cleanup(handle); + rac_ocr_destroy(handle); + return 1; + } + + print_result(options, model_ref, result); + rac_ocr_result_free(&result); + rac_ocr_cleanup(handle); + rac_ocr_destroy(handle); + return 0; +} + +} // namespace + +void register_ocr(CLI::App& app, GlobalOptions& options) { + CLI::App* cmd = app.add_subcommand("ocr", "Read the text on a page image"); + auto image_path = std::make_shared(); + auto model = std::make_shared(); + cmd->add_option("image", *image_path, "Input image (binary PPM / P6)") + ->required() + ->check(CLI::ExistingFile); + cmd->add_option("--model,-m", *model, "OCR model id or on-disk path")->required(); + cmd->callback([&options, image_path, model]() { + const int exit_code = run_ocr(options, *image_path, *model); + if (exit_code != 0) { + throw CLI::RuntimeError(exit_code); + } + }); +} + +} // namespace rcli::commands diff --git a/src/commands/commands.h b/src/commands/commands.h index 5a1549a..d8bea1a 100644 --- a/src/commands/commands.h +++ b/src/commands/commands.h @@ -42,6 +42,7 @@ void register_rerank(CLI::App& app, GlobalOptions& options); void register_image(CLI::App& app, GlobalOptions& options); void register_diarize(CLI::App& app, GlobalOptions& options); void register_segment(CLI::App& app, GlobalOptions& options); +void register_ocr(CLI::App& app, GlobalOptions& options); void register_voice(CLI::App& app, GlobalOptions& options); void register_rag(CLI::App& app, GlobalOptions& options); void register_models(CLI::App& app, GlobalOptions& options);