diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/model/ModelStatusResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/model/ModelStatusResponse.java index 85655ec9cb..eb1b1e72f3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/model/ModelStatusResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/model/ModelStatusResponse.java @@ -40,4 +40,7 @@ public class ModelStatusResponse { /** Where detection runs: auto | browser | server. */ private String executionMode; + + /** True if the server-side ONNX engine is bundled in this build (else only browser works). */ + private boolean serverEngineAvailable; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/service/FormDetectionModelManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/service/FormDetectionModelManager.java index ae283c130f..d7927474ad 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/service/FormDetectionModelManager.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/formdetection/service/FormDetectionModelManager.java @@ -59,6 +59,25 @@ public class FormDetectionModelManager { private static final Pattern SAFE_ID = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}"); private static final Pattern SHA256_HEX = Pattern.compile("[0-9a-f]{64}"); + /** + * Whether the server-side ONNX engine is bundled in this build (the onnxruntime jar is only + * included via {@code -PbundleOnnxRuntime=true}, e.g. the Docker server image). The frontend + * uses this to disable the "Server" execution mode when it isn't available. + */ + private static final boolean SERVER_ENGINE_AVAILABLE = isOnnxRuntimePresent(); + + private static boolean isOnnxRuntimePresent() { + try { + Class.forName( + "ai.onnxruntime.OrtEnvironment", + false, + FormDetectionModelManager.class.getClassLoader()); + return true; + } catch (Throwable t) { + return false; + } + } + private final RuntimePathConfig runtimePathConfig; private final ModelCatalogService catalog; private final ApplicationProperties applicationProperties; @@ -363,7 +382,8 @@ public class FormDetectionModelManager { isWritable(dir), catalog.getAll(), isFeatureEnabled(), - applicationProperties.getFormDetection().getExecutionMode()); + applicationProperties.getFormDetection().getExecutionMode(), + SERVER_ENGINE_AVAILABLE); } public Optional getActiveModelFile() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/formdetection/controller/FormDetectionModelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/formdetection/controller/FormDetectionModelControllerTest.java index 1088e4a4a1..58f9ef855f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/formdetection/controller/FormDetectionModelControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/formdetection/controller/FormDetectionModelControllerTest.java @@ -21,7 +21,7 @@ class FormDetectionModelControllerTest { private ModelStatusResponse notInstalled() { return new ModelStatusResponse( - "not_installed", 0, "", List.of(), null, true, List.of(), true, "auto"); + "not_installed", 0, "", List.of(), null, true, List.of(), true, "auto", true); } private MockMvc mvc(FormDetectionModelManager manager) { diff --git a/frontend/editor/src/core/hooks/useFormDetectionModelStatus.ts b/frontend/editor/src/core/hooks/useFormDetectionModelStatus.ts index 54862fc090..b3ac5badfd 100644 --- a/frontend/editor/src/core/hooks/useFormDetectionModelStatus.ts +++ b/frontend/editor/src/core/hooks/useFormDetectionModelStatus.ts @@ -45,6 +45,7 @@ export interface FormDetectionModelStatus { catalog: FormDetectionCatalogEntry[]; enabled: boolean; executionMode: FormDetectionExecutionMode; + serverEngineAvailable: boolean; } const STATUS_URL = "/api/v1/ai/form-detection-model/status"; diff --git a/frontend/editor/src/core/services/formDetection/decode.test.ts b/frontend/editor/src/core/services/formDetection/decode.test.ts new file mode 100644 index 0000000000..9190fe62f2 --- /dev/null +++ b/frontend/editor/src/core/services/formDetection/decode.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; + +import { decode } from "@app/services/formDetection/decode"; +import { + ModelPipelineSpec, + Preprocessed, + RawOutput, +} from "@app/services/formDetection/types"; + +// Golden fixture shared VERBATIM with the backend YoloTest.java +// (decodeThresholdsAndSuppressesOverlaps). Asserting the same inputs -> same outputs in both +// suites proves the in-browser decode matches the server decode 1:1, so the two execution paths +// produce identical detections. + +const spec: ModelPipelineSpec = { + inputSize: 10, + resizeMode: "letterbox", + padColor: [114, 114, 114], + channelOrder: "rgb", + normMean: [0, 0, 0], + normStd: [1, 1, 1], + outputLayout: "nc_first", + hasObjectness: false, + classNames: ["text", "choice"], + classFieldTypes: ["text", "checkbox"], + scoreThreshold: 0.5, + nms: "perClass", + iou: 0.5, +}; + +// identity transform (scale 1, no pad), 10x10 source +const pre: Preprocessed = { + chw: new Float32Array(0), + inputSize: 10, + scaleX: 1, + scaleY: 1, + padX: 0, + padY: 0, + srcW: 10, + srcH: 10, +}; + +// nc_first layout [channels=6][anchors=3], data[c*anchors + a] +// box A (cx5,cy5,w4,h4) twice (overlapping) + box B (cx8,cy8,w2,h2) +const data = [ + 5, 5, 8, // cx + 5, 5, 8, // cy + 4, 4, 2, // w + 4, 4, 2, // h + 0.9, 0.8, 0.7, // text score + 0.1, 0.1, 0.1, // choice score +]; +const out: RawOutput = { data, d1: 6, d2: 3 }; + +describe("formDetection decode (parity with backend Yolo.decode)", () => { + it("thresholds and suppresses overlaps identically to the Java golden fixture", () => { + const dets = decode(out, spec, pre, 0.5); + // a0 (box A, 0.9) kept; a1 (box A', 0.8) suppressed by NMS; a2 (box B, 0.7) kept + expect(dets).toHaveLength(2); + + expect(dets[0].classId).toBe(0); + expect(dets[0].score).toBeCloseTo(0.9, 5); + expect(dets[0].x).toBeCloseTo(3, 4); + expect(dets[0].y).toBeCloseTo(3, 4); + expect(dets[0].w).toBeCloseTo(4, 4); + expect(dets[0].h).toBeCloseTo(4, 4); + + expect(dets[1].score).toBeCloseTo(0.7, 5); + expect(dets[1].x).toBeCloseTo(7, 4); + expect(dets[1].y).toBeCloseTo(7, 4); + expect(dets[1].w).toBeCloseTo(2, 4); + expect(dets[1].h).toBeCloseTo(2, 4); + }); + + it("drops everything when the threshold exceeds all scores", () => { + expect(decode(out, spec, pre, 0.95)).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFormDetectionSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFormDetectionSection.tsx index 815cfc2cfc..994c56edff 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFormDetectionSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFormDetectionSection.tsx @@ -55,6 +55,7 @@ export default function AdminFormDetectionSection() { const enabled = status?.enabled ?? true; const executionMode: FormDetectionExecutionMode = status?.executionMode ?? "auto"; + const serverEngineAvailable = status?.serverEngineAvailable ?? true; const catalog = status?.catalog ?? []; const selectData = useMemo( @@ -196,9 +197,18 @@ export default function AdminFormDetectionSection() { { label: t("admin.formDetection.engine.server", "Server"), value: "server", + disabled: !serverEngineAvailable, }, ]} /> + {!serverEngineAvailable ? ( + + {t( + "admin.formDetection.engine.serverUnavailable", + "The server engine is not bundled in this build, so detection runs in the browser. Use Auto or Browser.", + )} + + ) : null} {inFlight ? (