Pre-bundle FFDNet-S into the Docker image and seed it on startup

This commit is contained in:
Anthony Stirling
2026-06-15 10:46:47 +01:00
parent 313f614ec2
commit c168e5a658
6 changed files with 95 additions and 0 deletions
+7
View File
@@ -88,6 +88,13 @@ stirling/
customFiles/
configs/
# Local dev runtime dirs created by bootRun under the module dir (hold the locked H2 DB,
# downloaded models, logs); never build input. Root-level patterns above don't match these.
**/configs/
app/core/customFiles/
app/core/logs/
app/core/pipeline/
# Claude Code workspace
.claude/
@@ -319,6 +319,14 @@ public class ApplicationProperties {
/** Optional override dir; blank uses {@code <configs>/models/form-detection}. */
private String modelDir = "";
/**
* Read-only dir of models baked into the image (e.g. the Docker server image pre-downloads
* FFDNet-S here). On startup any {@code <catalogId>.onnx} found here is copied into the
* writable model dir if not already present, and activated if no model is active - so the
* feature works out-of-the-box. Blank (default) disables seeding.
*/
private String preinstalledModelDir = "";
}
/**
@@ -369,6 +369,7 @@ formDetection:
executionMode: auto # Where detection runs: 'auto' (browser first, server fallback), 'browser' (in-browser WASM only, PDF never leaves the device), or 'server' (backend inference)
activeModelId: "" # Id of the installed Auto Form Detection model (set automatically after an admin installs one)
modelDir: "" # Optional override directory for downloaded .onnx models; blank uses <configs>/models/form-detection
preinstalledModelDir: "" # Read-only dir of models baked into the image (Docker pre-downloads FFDNet-S here); seeded into the model dir on startup. Blank disables.
policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
@@ -92,6 +92,7 @@ public class FormDetectionModelManager {
@PostConstruct
void init() {
sweepTempFiles();
seedPreinstalledModels();
Optional<Path> active = getActiveModelFile();
if (active.isPresent()) {
activeSha = getActiveEntry().map(ModelCatalogEntry::getSha256).orElse(null);
@@ -417,6 +418,58 @@ public class FormDetectionModelManager {
return Paths.get(runtimePathConfig.getFormDetectionModelPath());
}
/**
* Copy any image-baked models (see {@code formDetection.preinstalledModelDir}) into the
* writable model dir if not already present, and activate one when nothing is active yet. Lets
* the Docker server image ship with FFDNet-S ready without an admin install. No-op when the dir
* is unset or missing (desktop/local).
*/
private void seedPreinstalledModels() {
String preDir = applicationProperties.getFormDetection().getPreinstalledModelDir();
if (StringUtils.isBlank(preDir)) {
return;
}
Path src = Paths.get(preDir);
if (!Files.isDirectory(src)) {
return;
}
Path dir = modelDir();
try {
Files.createDirectories(dir);
} catch (IOException e) {
log.warn("Cannot create model dir to seed pre-installed models: {}", e.getMessage());
return;
}
if (!isWritable(dir)) {
log.warn("Model dir {} not writable; skipping pre-installed model seeding", dir);
return;
}
try (DirectoryStream<Path> models = Files.newDirectoryStream(src, "*.onnx")) {
for (Path p : models) {
String fn = p.getFileName().toString();
String id = fn.substring(0, fn.length() - ".onnx".length());
if (!SAFE_ID.matcher(id).matches() || catalog.getById(id).isEmpty()) {
continue;
}
Path target = dir.resolve(id + ".onnx");
if (!Files.exists(target)) {
Files.copy(p, target, StandardCopyOption.COPY_ATTRIBUTES);
log.info("Seeded pre-installed Auto Form Detection model '{}'", id);
}
if (StringUtils.isBlank(activeModelId())) {
applicationProperties.getFormDetection().setActiveModelId(id);
try {
GeneralUtils.saveKeyToSettings("formDetection.activeModelId", id);
} catch (IOException e) {
log.warn("Could not persist seeded activeModelId: {}", e.getMessage());
}
}
}
} catch (IOException e) {
log.warn("Failed to seed pre-installed models from {}: {}", src, e.getMessage());
}
}
private void sweepTempFiles() {
Path dir = modelDir();
if (!Files.isDirectory(dir)) {
+13
View File
@@ -19,6 +19,15 @@ RUN apt-get update \
&& rm /tmp/task.deb \
&& rm -rf /var/lib/apt/lists/*
# Pre-download the default Auto Form Detection model (FFDNet-S, ~37MB) so the feature works
# out-of-the-box. Verified by checksum here; seeded into the writable configs model dir at startup
# by FormDetectionModelManager (FORMDETECTION_PREINSTALLEDMODELDIR below).
ARG FORM_DETECTION_MODEL_URL=https://huggingface.co/jbarrow/FFDNet-S-cpu/resolve/main/FFDNet-S.onnx
ARG FORM_DETECTION_MODEL_SHA256=93bccf47c048f9f947f9b1b52d002edf144a8a583dae39f164d9e5725321acc0
RUN mkdir -p /preinstalled-models \
&& curl -fSL "${FORM_DETECTION_MODEL_URL}" -o /preinstalled-models/ffdnet-s.onnx \
&& echo "${FORM_DETECTION_MODEL_SHA256} /preinstalled-models/ffdnet-s.onnx" | sha256sum -c -
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
@@ -84,6 +93,9 @@ COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --link --chown=1000:1000 scripts/ /scripts/
# Pre-bundled default detection model; FormDetectionModelManager seeds it into /configs on startup.
COPY --link --from=app-build --chown=1000:1000 /preinstalled-models/ /opt/stirling/preinstalled-models/
# Fonts go to system dir, root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
@@ -107,6 +119,7 @@ RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
# Environment variables
ENV VERSION_TAG=$VERSION_TAG \
FORMDETECTION_PREINSTALLEDMODELDIR="/opt/stirling/preinstalled-models" \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
+13
View File
@@ -20,6 +20,15 @@ RUN apt-get update \
&& rm /tmp/task.deb \
&& rm -rf /var/lib/apt/lists/*
# Pre-download the default Auto Form Detection model (FFDNet-S, ~37MB) so the feature works
# out-of-the-box - especially important for this air-gapped fat image (no runtime internet).
# Verified by checksum; seeded into the writable configs model dir at startup.
ARG FORM_DETECTION_MODEL_URL=https://huggingface.co/jbarrow/FFDNet-S-cpu/resolve/main/FFDNet-S.onnx
ARG FORM_DETECTION_MODEL_SHA256=93bccf47c048f9f947f9b1b52d002edf144a8a583dae39f164d9e5725321acc0
RUN mkdir -p /preinstalled-models \
&& curl -fSL "${FORM_DETECTION_MODEL_URL}" -o /preinstalled-models/ffdnet-s.onnx \
&& echo "${FORM_DETECTION_MODEL_SHA256} /preinstalled-models/ffdnet-s.onnx" | sha256sum -c -
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
@@ -79,6 +88,9 @@ COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --link --chown=1000:1000 scripts/ /scripts/
# Pre-bundled default detection model; FormDetectionModelManager seeds it into /configs on startup.
COPY --link --from=app-build --chown=1000:1000 /preinstalled-models/ /opt/stirling/preinstalled-models/
# Fonts go to system dir, root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
@@ -101,6 +113,7 @@ RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
# Environment variables
ENV VERSION_TAG=$VERSION_TAG \
FORMDETECTION_PREINSTALLEDMODELDIR="/opt/stirling/preinstalled-models" \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \