Add decode parity test and gate Server engine option on availability

This commit is contained in:
Anthony Stirling
2026-06-15 10:07:59 +01:00
parent b0109e174b
commit 313f614ec2
6 changed files with 114 additions and 2 deletions
@@ -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;
}
@@ -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<Path> getActiveModelFile() {
@@ -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) {
@@ -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";
@@ -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);
});
});
@@ -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 ? (
<Text size="xs" c="dimmed" mt={6}>
{t(
"admin.formDetection.engine.serverUnavailable",
"The server engine is not bundled in this build, so detection runs in the browser. Use Auto or Browser.",
)}
</Text>
) : null}
</div>
{inFlight ? (