Add decode parity test and gate Server engine option on availability
This commit is contained in:
+3
@@ -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;
|
||||
}
|
||||
|
||||
+21
-1
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+10
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user