fix(vlm): improve ImageTiler OCR accuracy with white padding and bicubic interpolation

Changes to ImageTiler.java:
- Change padToSize() from black to white padding for better text recovery
- Switch resizeImage() from bilinear to bicubic interpolation for sharper edges
- Remove misleading KEY_ANTIALIASING hint (no-op for raster image scaling)
- Add optional sharpening controlled by nd4j.vlm.image.sharpen property
- Refactor splitImageForVLM to use resize-to-fit + pad instead of squishing

Add TestGenerationPipelineAccuracy:
- GenerationPipeline-only test for page-10 OCR without StaticKvCacheDecodeLoop
- Configurable DPI via vlm.test.pdf.dpi system property

The white padding change recovers more text content from the mythic PDF.
The bicubic interpolation preserves thin character strokes better than bilinear.


Former-commit-id: 0ec63de6f4c6167f4282634bf1461cbcd7b38b60
This commit is contained in:
agibsonccc
2026-04-23 11:22:56 +09:00
parent 8975a3212d
commit 768c008f6d
5 changed files with 4785 additions and 189 deletions
+200
View File
@@ -0,0 +1,200 @@
/* ******************************************************************************
*
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0.
*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* SPDX-License-Identifier: Apache-2.0
******************************************************************************/
#include <graph/FrozenPlan.h>
#include <graph/NativeDynamicShapePlan.h>
#include <graph/DspDiagnostics.h>
namespace sd {
namespace graph {
FrozenPlan::FrozenPlan() : plan_(nullptr), buildPassCount_(0) {}
FrozenPlan::~FrozenPlan() {
// Plan is owned by us (when not in a cache) or by the cache.
// In the cache path, the cache deletes the FrozenPlan which deletes the plan.
// In the standalone path (compileDynamicShapePlan), we own it.
delete plan_;
plan_ = nullptr;
}
FrozenPlan* FrozenPlan::create(const void* serializedBytes, LongType numBytes,
int graphExecutionMode) {
if (serializedBytes == nullptr || numBytes <= 0) {
return nullptr;
}
auto mode = static_cast<GraphExecutionMode>(graphExecutionMode);
auto* nativePlan = NativeDynamicShapePlan::fromSerializedPlan(serializedBytes, numBytes, mode);
if (nativePlan == nullptr) {
return nullptr;
}
auto* frozen = new FrozenPlan();
frozen->plan_ = nativePlan;
frozen->buildPassCount_ = 0;
DSP_DIAG(LIFECYCLE, "created: %d slots, %d inputs, %d outputs, mode=%d",
nativePlan->getNumSlots(), nativePlan->getNumExternalInputs(),
nativePlan->getNumRequestedOutputs(), graphExecutionMode);
return frozen;
}
int FrozenPlan::execute(Context* context, void* stream) {
if (plan_ == nullptr) return 1;
if (context == nullptr) return 1;
int numInputs = static_cast<int>(context->width());
int numOutputs = static_cast<int>(context->outputWidth());
// Validate counts
if (numInputs != plan_->getNumExternalInputs()) return 2;
if (numOutputs != plan_->getNumRequestedOutputs()) return 3;
// Extract input/output NDArrays from context
std::vector<NDArray*> inputPtrs(numInputs);
for (int i = 0; i < numInputs; i++) {
inputPtrs[i] = context->array(i);
if (inputPtrs[i] == nullptr) return 4;
}
std::vector<NDArray*> outputPtrs(numOutputs);
for (int i = 0; i < numOutputs; i++) {
outputPtrs[i] = context->outputArray(i);
// Output arrays may be null (plan allocates them)
}
// Delegate to NativeDynamicShapePlan::execute()
auto status = plan_->execute(
inputPtrs.data(), numInputs,
outputPtrs.data(), numOutputs,
stream);
if (status == Status::OK) {
// Track build pass for diagnostics
buildPassCount_++;
// Write outputs back to context
for (int i = 0; i < numOutputs; i++) {
if (outputPtrs[i] != nullptr && outputPtrs[i] != context->outputArray(i)) {
context->setOutputArray(i, outputPtrs[i]);
}
}
return 0;
}
return static_cast<int>(status);
}
int FrozenPlan::releaseGpuIntermediates() {
if (plan_ == nullptr) return 0;
return plan_->releaseGpuIntermediates();
}
bool FrozenPlan::isSealed() const {
if (plan_ == nullptr) return false;
return plan_->getPlanPhaseCode() >= 3; // REPLAYING
}
void FrozenPlan::emitStateReport() const {
if (plan_ == nullptr) return;
auto& diag = DspDiagnostics::getInstance();
if (!diag.isEnabled(DSP_DIAG_LIFECYCLE)) return;
const auto& segments = plan_->getSegments();
int numSegs = static_cast<int>(segments.size());
int numSlots = plan_->getNumSlots();
int phaseCode = plan_->getPlanPhaseCode();
bool sealed = isSealed();
diag.recordEvent(DSP_DIAG_LIFECYCLE, -1, -1, -1, "FrozenPlan", 0,
"[DSP_PLAN_STATE] buildPassCount=%d sealed=%s phase=%d segments=%d slots=%d",
buildPassCount_, sealed ? "true" : "false", phaseCode, numSegs, numSlots);
// Per-segment state dump
for (int i = 0; i < numSegs; i++) {
const auto& seg = segments[i];
int segPhase = seg.exec.getExecutionPhaseCode();
int segStart = seg.def.startSlot;
int segEnd = seg.def.endSlot;
diag.recordEvent(DSP_DIAG_LIFECYCLE, -1, i, -1, "FrozenPlan", 0,
" seg[%d: %d-%d] phase=%d backend=%s",
i, segStart, segEnd, segPhase,
seg.exec.compiledByBackend.empty() ? "none" : seg.exec.compiledByBackend.c_str());
}
}
void FrozenPlan::emitResourceReport() const {
if (plan_ == nullptr) return;
auto& diag = DspDiagnostics::getInstance();
if (!diag.isEnabled(DSP_DIAG_MEMORY)) return;
int numSlots = plan_->getNumSlots();
int numExternalInputs = plan_->getNumExternalInputs();
int numOutputs = plan_->getNumRequestedOutputs();
diag.recordEvent(DSP_DIAG_MEMORY, -1, -1, -1, "FrozenPlan", 0,
"[DSP_RESOURCES] slots=%d extInputs=%d outputs=%d",
numSlots, numExternalInputs, numOutputs);
}
uint64_t FrozenPlan::identityFingerprint() const {
if (plan_ == nullptr) return 0;
return plan_->identityFingerprint();
}
int FrozenPlan::getPlanPhaseCode() const {
if (plan_ == nullptr) return -1;
return plan_->getPlanPhaseCode();
}
void FrozenPlan::setShapesFrozen(bool frozen) {
if (plan_ == nullptr) return;
plan_->setShapesFrozen(frozen);
}
void FrozenPlan::clearShapeCaches() {
if (plan_ == nullptr) return;
plan_->clearShapeCaches();
}
void FrozenPlan::clearAllShapeCachesForce() {
if (plan_ == nullptr) return;
plan_->clearAllShapeCachesForce();
}
unsigned long long FrozenPlan::getReplaySignatureHash(int segIdx) const {
if (plan_ == nullptr) return 0;
const auto& segments = plan_->getSegments();
if (segIdx < 0 || segIdx >= static_cast<int>(segments.size())) return 0;
return segments[segIdx].exec.replaySignatureHash;
}
int FrozenPlan::getReplayUnitCount(int segIdx) const {
if (plan_ == nullptr) return 0;
const auto& segments = plan_->getSegments();
if (segIdx < 0 || segIdx >= static_cast<int>(segments.size())) return 0;
return segments[segIdx].exec.replayUnitCount;
}
} // namespace graph
} // namespace sd
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
/* ******************************************************************************
*
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0.
*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* SPDX-License-Identifier: Apache-2.0
******************************************************************************/
/**
* NativeDynamicShapePlan — Slot Execution CUDA Support
*
* Contains CUDA-specific implementations for slot execution:
* - platformPrezeroSegmentOutputs: batched cudaMemsetAsync for output zeroing
* - platformReconcileOutputActuality: device sync for control arrays post-exec
* - platformValidateSlotInputBuffer: null GPU buffer detection
* - platformSetLtEpilogue / platformClearLtEpilogue: cublasLt epilogue wiring
* - platformLogSlotOutput: triton verify kernel logging
*
* CPU stubs for these methods are in NativeDynamicShapePlan_cuda_stubs.cpp.
*/
#ifdef SD_CUDA
#include <graph/NativeDynamicShapePlan.h>
#include <graph/DspDiagnostics.h>
#include <graph/DspVerifyUtils.h>
#include <helpers/MmulHelper.h>
#include <system/Environment.h>
#include <cuda_runtime.h>
namespace sd {
namespace graph {
// ── Platform prezero: batched cudaMemsetAsync ─────────────────────────────────
void NativeDynamicShapePlan::platformPrezeroSegmentOutputs(const GraphSegment& seg, void* stream) {
auto cudaStr = (stream != nullptr) ? *static_cast<cudaStream_t*>(stream) : nullptr;
// Collect qualifying buffers first, then batch-launch a single memset kernel
// instead of issuing N individual cudaMemsetAsync driver calls.
struct PrezeroTarget { void* buf; size_t bytes; int slotIdx; };
constexpr int kStackCapacity = 128;
PrezeroTarget stackBuf[kStackCapacity];
std::vector<PrezeroTarget> heapBuf;
PrezeroTarget* targets = stackBuf;
int targetCount = 0;
bool useHeap = false;
for (int s = seg.def.startSlot; s <= seg.def.endSlot; s++) {
if (s < 0 || s >= numSlots_) continue;
NativeSlot& slot = slots_[s];
if (slot.frozenConstantSlot()) continue;
if (!slot.flags.needsZeroedOutput) continue;
if (slot.flags.isViewCapableOp) continue;
if (slot.flags.isIdentityOp) continue;
if (slot.flags.inPlaceFused) continue;
if (slot.fusedChain.isFusedChainTail) continue;
if (slot.state_ >= NativeSlot::SlotState::FROZEN && slot.flags.isFullyWriting) continue;
for (int o = 0; o < slot.wiring.numOutputs; o++) {
int outIdx = slot.wiring.outputSlotIndices[o];
if (outIdx < 0 || outIdx >= totalOutputSlots_) continue;
if (slotIsViewProducer_ != nullptr && slotIsViewProducer_[outIdx]) continue;
NDArray* arr = outputSlots_[outIdx];
if (arr == nullptr) continue;
if (arr->isView()) continue;
auto* db = arr->dataBuffer();
if (db == nullptr) continue;
size_t bytes = db->getLenInBytes();
if (bytes == 0) continue;
void* buf = arr->specialBuffer();
if (buf == nullptr) continue;
DSP_DIAG_SEG(MEMORY, s, "prezeroSegmentOutputs: seg[%d-%d] slot=%d outIdx=%d op=%s bytes=%lld stream=%p",
seg.def.startSlot, seg.def.endSlot, s, outIdx,
slot.ident.opName.c_str(), (long long)bytes, (void*)cudaStr);
DSP_DIAG_SLOT_ZERO(outIdx, "prezero", cudaStr, "segment-prezero");
if (targetCount >= kStackCapacity && !useHeap) {
heapBuf.assign(stackBuf, stackBuf + targetCount);
useHeap = true;
targets = nullptr;
}
PrezeroTarget t{buf, bytes, s};
if (useHeap) {
heapBuf.push_back(t);
} else {
stackBuf[targetCount] = t;
}
targetCount++;
}
}
if (useHeap) targets = heapBuf.data();
// Dispatch: single buffer → direct memset, multiple → batched kernel
if (targetCount == 1) {
cudaMemsetAsync(targets[0].buf, 0, targets[0].bytes, cudaStr);
} else if (targetCount > 1) {
std::vector<void*> dstPtrs(targetCount);
std::vector<size_t> sizes(targetCount);
for (int i = 0; i < targetCount; i++) {
dstPtrs[i] = targets[i].buf;
sizes[i] = targets[i].bytes;
}
launchBatchMemset(cudaStr, dstPtrs.data(), sizes.data(), targetCount);
DSP_DIAG(MEMORY, "prezeroSegmentOutputs: batched %d buffers into 1 kernel launch", targetCount);
}
// Bump generation for all slots that were zeroed
if (targetCount > 0) {
int prevSlot = -1;
for (int i = 0; i < targetCount; i++) {
int s = targets[i].slotIdx;
if (s != prevSlot) {
slots_[s].bumpGeneration();
prevSlot = s;
}
}
}
}
// ── Platform reconcile output actuality ───────────────────────────────────────
void NativeDynamicShapePlan::platformReconcileOutputActuality(
const char* stage, int stepIdx, const NativeSlot& slot, NDArray* output) {
if (output == nullptr) return;
auto* db = output->dataBuffer();
if (db == nullptr || db->isClosed()) return;
const bool primaryActual = db->isPrimaryActual();
const bool specialActual = db->isSpecialActual();
const bool needsDeviceVisibleControl =
slot.flags.isDataDependent || slot.flags.outputShapeDependsOnInputValues ||
(output->dataType() == INT32 || output->dataType() == INT64 || output->dataType() == BOOL) &&
output->lengthOf() > 0 && output->lengthOf() <= 32;
if (primaryActual && !specialActual) {
if (needsDeviceVisibleControl) {
output->syncToDevice();
DSP_DIAG(SHAPE,
"CONTROL_OUTPUT_SYNC: stage=%s slot=%d (%s) "
"synced host-current output to device after native execution",
stage, stepIdx, slot.ident.opName.c_str());
}
}
}
// ── Platform validate GPU buffer ──────────────────────────────────────────────
bool NativeDynamicShapePlan::platformValidateSlotInputBuffer(
int stepIdx, const NativeSlot& slot, int inputIdx, NDArray* input) {
if (input == nullptr || input->isEmpty()) return true;
auto* db = input->dataBuffer();
if (db == nullptr) return true;
if (db->special() == nullptr) {
DSP_DIAG_SLOT(EXECUTE, stepIdx,
"NULL GPU buffer for slot %d (%s) input %d, srcIdx=%d "
"len=%lld isClosed=%d isConst=%d exec=%d",
stepIdx, slot.ident.opName.c_str(), inputIdx,
slot.wiring.inputSourceIndices[inputIdx],
(long long)input->lengthOf(), db->isClosed() ? 1 : 0,
db->isConstant ? 1 : 0, executeCount_);
return false;
}
return true;
}
// ── Platform reusable slot array validation (GPU-specific check) ──────────────
bool NativeDynamicShapePlan::platformValidateReusableSlotBuffer(NDArray* cached) {
if (cached == nullptr) return true;
auto* db = cached->dataBuffer();
if (db == nullptr) return true;
// On CUDA, a non-empty array with null special (device) buffer is invalid
if (db->special() == nullptr && !cached->isEmpty()) {
return false;
}
return true;
}
// ── Platform set/clear cublasLt epilogue ──────────────────────────────────────
void NativeDynamicShapePlan::platformSetLtEpilogue(const NativeSlot& slot, NDArray* biasArray) {
if (biasArray == nullptr) return;
biasArray->syncToDevice();
MmulHelper::setLtEpilogue(slot.flags.ltEpilogueType, biasArray->specialBuffer(),
biasArray->lengthOf() * biasArray->sizeOfT());
}
void NativeDynamicShapePlan::platformClearLtEpilogue() {
MmulHelper::clearLtEpilogue();
}
// ── Platform log slot output (triton verify) ──────────────────────────────────
void NativeDynamicShapePlan::platformLogSlotOutput(
int stepIdx, const char* opName, const char* tag,
const int* outputSlotIndices, int numOutputs) {
if (!Environment::getInstance().tritonVerifyKernels()) return;
dspLogSlotOutput(stepIdx, opName, tag,
outputSlots_, outputSlotIndices, numOutputs, totalOutputSlots_);
}
} // namespace graph
} // namespace sd
#endif // SD_CUDA
@@ -28,6 +28,8 @@ import org.nd4j.linalg.indexing.NDArrayIndex;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.ConvolveOp;
import java.awt.image.Kernel;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
@@ -37,10 +39,13 @@ import java.util.concurrent.Future;
/**
* Image tiling utilities for Vision-Language Models.
*
* Implements the split_image logic from HuggingFace Idefics3ImageProcessor:
* 1. resize_for_vision_encoder: Round dimensions UP to nearest multiples of maxSize
* 2. Split into tiles (each exactly maxSize x maxSize)
* 3. Add global image squished to maxSize x maxSize
* Splits an image into a grid of tiles plus a global frame for VLM processing.
*
* <p>Each cropped tile is resized to fit within {@code maxSize x maxSize} while preserving
* aspect ratio, then padded out to the model's square input size. The returned
* {@link ContentRegion} values capture the unpadded content size for each frame so downstream
* pixel-attention masks reflect the true occupied area instead of treating every frame as a
* fully populated square.</p>
*/
@Slf4j
public class ImageTiler {
@@ -92,10 +97,11 @@ public class ImageTiler {
/**
* Split an image into tiles for VLM processing (e.g. SmolDocling/Idefics3).
*
* The algorithm (matching HuggingFace Idefics3ImageProcessor):
* 1. resize_for_vision_encoder: Round both dimensions UP to nearest multiples of maxSize
* 2. Split into tiles (each exactly maxSize x maxSize)
* 3. Add global image squished to maxSize x maxSize
* The algorithm:
* 1. Choose a tile grid from the source image dimensions, respecting {@code maxTiles}
* 2. Crop each tile from the original aspect-ratio image
* 3. Resize each tile to fit within {@code maxSize x maxSize}, then pad to square
* 4. Add a global image frame using the same resize-to-fit + pad behavior
*
* @param image The input image
* @param maxSize The maximum tile size (e.g. 512 for SmolDocling)
@@ -114,94 +120,18 @@ public class ImageTiler {
* @return SplitImageResult containing the frames and metadata
*/
public static SplitImageResult splitImageForVLM(BufferedImage image, int maxSize, int maxTiles) {
// Step 1: resize_for_vision_encoder - round dimensions to multiples of maxSize
int width = image.getWidth();
int height = image.getHeight();
if (height > maxSize || width > maxSize) {
double aspectRatio = (double) width / height;
int newWidth, newHeight;
if (width >= height) {
newWidth = (int) Math.ceil((double) width / maxSize) * maxSize;
newHeight = (int) (newWidth / aspectRatio);
newHeight = (int) Math.ceil((double) newHeight / maxSize) * maxSize;
} else {
newHeight = (int) Math.ceil((double) height / maxSize) * maxSize;
newWidth = (int) (newHeight * aspectRatio);
newWidth = (int) Math.ceil((double) newWidth / maxSize) * maxSize;
}
log.info("resize_for_vision_encoder: {}x{} -> {}x{} (multiples of {})",
width, height, newWidth, newHeight, maxSize);
image = resizeImage(image, newWidth, newHeight);
width = newWidth;
height = newHeight;
}
log.info("Splitting image {}x{} into {}x{} tiles (maxTiles={})", width, height, maxSize, maxSize,
log.info("Splitting image {}x{} into aspect-preserving {}x{} tiles (maxTiles={})", width, height, maxSize, maxSize,
maxTiles > 0 ? maxTiles : "unlimited");
List<BufferedImage> frames = new ArrayList<>();
List<ContentRegion> contentRegions = new ArrayList<>();
int numSplitsH = 0;
int numSplitsW = 0;
if (maxTiles == 1) {
log.info("maxTiles=1: skipping tiling, using single global image only");
} else if (height > maxSize || width > maxSize) {
numSplitsH = (int) Math.ceil((double) height / maxSize);
numSplitsW = (int) Math.ceil((double) width / maxSize);
if (maxTiles > 0) {
int maxTilesForGrid = maxTiles;
int totalTiles = numSplitsH * numSplitsW;
if (totalTiles > maxTilesForGrid) {
double imageAspect = (double) height / width;
int bestH = 1, bestW = 1;
int bestCount = 1;
double bestAspectMatch = Double.MAX_VALUE;
for (int h = 1; h <= Math.min(numSplitsH, maxTilesForGrid); h++) {
int maxW = maxTilesForGrid / h;
for (int w = 1; w <= Math.min(numSplitsW, maxW); w++) {
int count = h * w;
if (count <= maxTilesForGrid) {
double gridAspect = (double) h / w;
double aspectMatch = Math.abs(Math.log(gridAspect) - Math.log(imageAspect));
if (count > bestCount ||
(count == bestCount && aspectMatch < bestAspectMatch)) {
bestH = h;
bestW = w;
bestCount = count;
bestAspectMatch = aspectMatch;
}
}
}
}
numSplitsH = bestH;
numSplitsW = bestW;
log.info("Reduced grid from {}x{} to {}x{} ({} tiles) to fit maxTiles={}, imageAspect={}",
(int) Math.ceil((double) height / maxSize),
(int) Math.ceil((double) width / maxSize),
numSplitsH, numSplitsW, numSplitsH * numSplitsW, maxTiles, imageAspect);
}
}
// Idefics3 row/col tokens are defined for up to 6x6 grid
int maxGrid = 6;
if (numSplitsH > maxGrid || numSplitsW > maxGrid) {
double scale = Math.min((double) maxGrid / numSplitsH, (double) maxGrid / numSplitsW);
int newH = Math.max(1, (int) Math.floor(numSplitsH * scale));
int newW = Math.max(1, (int) Math.floor(numSplitsW * scale));
newH = Math.min(maxGrid, newH);
newW = Math.min(maxGrid, newW);
log.info("Reducing grid {}x{} to {}x{} to fit row/col token limits",
numSplitsH, numSplitsW, newH, newW);
numSplitsH = newH;
numSplitsW = newW;
}
int[] grid = chooseGrid(height, width, maxSize, maxTiles);
int numSplitsH = grid[0];
int numSplitsW = grid[1];
if (numSplitsH > 0 && numSplitsW > 0) {
int optimalHeight = (int) Math.ceil((double) height / numSplitsH);
int optimalWidth = (int) Math.ceil((double) width / numSplitsW);
@@ -218,25 +148,22 @@ public class ImageTiler {
int tileWidth = endX - startX;
int tileHeight = endY - startY;
BufferedImage tile = image.getSubimage(startX, startY, tileWidth, tileHeight);
if (tileWidth != maxSize || tileHeight != maxSize) {
tile = resizeImage(tile, maxSize, maxSize);
}
frames.add(tile);
contentRegions.add(new ContentRegion(maxSize, maxSize));
PreparedFrame prepared = prepareFrame(
image.getSubimage(startX, startY, tileWidth, tileHeight), maxSize);
frames.add(prepared.image);
contentRegions.add(prepared.contentRegion);
log.debug(" Tile [{},{}]: crop ({},{}) to ({},{}), tile {}x{} -> {}x{}",
r, c, startX, startY, endX, endY, tileWidth, tileHeight, maxSize, maxSize);
r, c, startX, startY, endX, endY, tileWidth, tileHeight,
prepared.contentRegion.width, prepared.contentRegion.height);
}
}
}
// Always add the global image at the end, squished to maxSize x maxSize
BufferedImage globalImage = resizeImage(image, maxSize, maxSize);
frames.add(globalImage);
contentRegions.add(new ContentRegion(maxSize, maxSize));
log.debug(" Added global resized image ({}x{}, squished)", maxSize, maxSize);
PreparedFrame globalFrame = prepareFrame(image, maxSize);
frames.add(globalFrame.image);
contentRegions.add(globalFrame.contentRegion);
log.debug(" Added global padded image with content {}x{}",
globalFrame.contentRegion.width, globalFrame.contentRegion.height);
log.info("Total frames: {} ({} tiles + 1 global)", frames.size(), frames.size() - 1);
@@ -254,69 +181,11 @@ public class ImageTiler {
* @return SplitImageResult containing the frames and metadata
*/
public static SplitImageResult splitImageForVLMParallel(BufferedImage image, int maxSize, int maxTiles, int numThreads) {
// Step 1: resize_for_vision_encoder - round dimensions to multiples of maxSize
int width = image.getWidth();
int height = image.getHeight();
if (height > maxSize || width > maxSize) {
double aspectRatio = (double) width / height;
int newWidth, newHeight;
if (width >= height) {
newWidth = (int) Math.ceil((double) width / maxSize) * maxSize;
newHeight = (int) (newWidth / aspectRatio);
newHeight = (int) Math.ceil((double) newHeight / maxSize) * maxSize;
} else {
newHeight = (int) Math.ceil((double) height / maxSize) * maxSize;
newWidth = (int) (newHeight * aspectRatio);
newWidth = (int) Math.ceil((double) newWidth / maxSize) * maxSize;
}
log.info("resize_for_vision_encoder: {}x{} -> {}x{} (multiples of {})",
width, height, newWidth, newHeight, maxSize);
image = resizeImage(image, newWidth, newHeight);
width = newWidth;
height = newHeight;
}
int numSplitsH = 0;
int numSplitsW = 0;
if (maxTiles == 1 || (height <= maxSize && width <= maxSize)) {
// No tiling needed
} else {
numSplitsH = (int) Math.ceil((double) height / maxSize);
numSplitsW = (int) Math.ceil((double) width / maxSize);
if (maxTiles > 0 && numSplitsH * numSplitsW > maxTiles) {
double imageAspect = (double) height / width;
int bestH = 1, bestW = 1;
int bestCount = 1;
double bestAspectMatch = Double.MAX_VALUE;
for (int h = 1; h <= Math.min(numSplitsH, maxTiles); h++) {
int maxW = maxTiles / h;
for (int w = 1; w <= Math.min(numSplitsW, maxW); w++) {
int count = h * w;
if (count <= maxTiles) {
double gridAspect = (double) h / w;
double aspectMatch = Math.abs(Math.log(gridAspect) - Math.log(imageAspect));
if (count > bestCount || (count == bestCount && aspectMatch < bestAspectMatch)) {
bestH = h;
bestW = w;
bestCount = count;
bestAspectMatch = aspectMatch;
}
}
}
}
numSplitsH = bestH;
numSplitsW = bestW;
}
int maxGrid = 6;
if (numSplitsH > maxGrid || numSplitsW > maxGrid) {
double scale = Math.min((double) maxGrid / numSplitsH, (double) maxGrid / numSplitsW);
numSplitsH = Math.min(maxGrid, Math.max(1, (int) Math.floor(numSplitsH * scale)));
numSplitsW = Math.min(maxGrid, Math.max(1, (int) Math.floor(numSplitsW * scale)));
}
}
int[] grid = chooseGrid(height, width, maxSize, maxTiles);
int numSplitsH = grid[0];
int numSplitsW = grid[1];
int totalTiles = numSplitsH * numSplitsW;
int totalFrames = totalTiles + 1; // tiles + global
@@ -325,10 +194,9 @@ public class ImageTiler {
List<ContentRegion> contentRegions = new ArrayList<>(totalFrames);
if (totalTiles == 0) {
// No tiles, just global
BufferedImage globalImage = resizeImage(image, maxSize, maxSize);
frames.add(globalImage);
contentRegions.add(new ContentRegion(maxSize, maxSize));
PreparedFrame globalFrame = prepareFrame(image, maxSize);
frames.add(globalFrame.image);
contentRegions.add(globalFrame.contentRegion);
return new SplitImageResult(frames, contentRegions, 0, 0);
}
@@ -357,16 +225,15 @@ public class ImageTiler {
int startY = r * optimalHeight;
int endX = Math.min(startX + optimalWidth, srcWidth);
int endY = Math.min(startY + optimalHeight, srcHeight);
BufferedImage tile = sourceImage.getSubimage(startX, startY, endX - startX, endY - startY);
if ((endX - startX) != maxSize || (endY - startY) != maxSize) {
tile = resizeImage(tile, maxSize, maxSize);
}
frames.set(idx, tile);
contentRegions.set(idx, new ContentRegion(maxSize, maxSize));
PreparedFrame prepared = prepareFrame(
sourceImage.getSubimage(startX, startY, endX - startX, endY - startY), maxSize);
frames.set(idx, prepared.image);
contentRegions.set(idx, prepared.contentRegion);
}
}
frames.set(totalTiles, resizeImage(sourceImage, maxSize, maxSize));
contentRegions.set(totalTiles, new ContentRegion(maxSize, maxSize));
PreparedFrame globalFrame = prepareFrame(sourceImage, maxSize);
frames.set(totalTiles, globalFrame.image);
contentRegions.set(totalTiles, globalFrame.contentRegion);
} else {
ExecutorService pool = Executors.newFixedThreadPool(effectiveThreads, r -> {
Thread t = new Thread(r, "ImageTiler");
@@ -384,19 +251,18 @@ public class ImageTiler {
int startY = row * optimalHeight;
int endX = Math.min(startX + optimalWidth, srcWidth);
int endY = Math.min(startY + optimalHeight, srcHeight);
BufferedImage tile = sourceImage.getSubimage(startX, startY, endX - startX, endY - startY);
if ((endX - startX) != maxSize || (endY - startY) != maxSize) {
tile = resizeImage(tile, maxSize, maxSize);
}
frames.set(idx, tile);
contentRegions.set(idx, new ContentRegion(maxSize, maxSize));
PreparedFrame prepared = prepareFrame(
sourceImage.getSubimage(startX, startY, endX - startX, endY - startY), maxSize);
frames.set(idx, prepared.image);
contentRegions.set(idx, prepared.contentRegion);
}));
}
}
// Global image as last frame
futures.add(pool.submit(() -> {
frames.set(totalTiles, resizeImage(sourceImage, maxSize, maxSize));
contentRegions.set(totalTiles, new ContentRegion(maxSize, maxSize));
PreparedFrame globalFrame = prepareFrame(sourceImage, maxSize);
frames.set(totalTiles, globalFrame.image);
contentRegions.set(totalTiles, globalFrame.contentRegion);
}));
for (Future<?> future : futures) {
future.get();
@@ -414,6 +280,80 @@ public class ImageTiler {
return new SplitImageResult(frames, contentRegions, numSplitsH, numSplitsW);
}
private static int[] chooseGrid(int height, int width, int maxSize, int maxTiles) {
int numSplitsH = 0;
int numSplitsW = 0;
if (maxTiles == 1 || (height <= maxSize && width <= maxSize)) {
return new int[]{0, 0};
}
numSplitsH = (int) Math.ceil((double) height / maxSize);
numSplitsW = (int) Math.ceil((double) width / maxSize);
if (maxTiles > 0 && numSplitsH * numSplitsW > maxTiles) {
double imageAspect = (double) height / width;
int bestH = 1;
int bestW = 1;
int bestCount = 1;
double bestAspectMatch = Double.MAX_VALUE;
for (int h = 1; h <= Math.min(numSplitsH, maxTiles); h++) {
int maxW = maxTiles / h;
for (int w = 1; w <= Math.min(numSplitsW, maxW); w++) {
int count = h * w;
if (count <= maxTiles) {
double gridAspect = (double) h / w;
double aspectMatch = Math.abs(Math.log(gridAspect) - Math.log(imageAspect));
if (count > bestCount || (count == bestCount && aspectMatch < bestAspectMatch)) {
bestH = h;
bestW = w;
bestCount = count;
bestAspectMatch = aspectMatch;
}
}
}
}
log.info("Reduced grid from {}x{} to {}x{} ({} tiles) to fit maxTiles={}, imageAspect={}",
numSplitsH, numSplitsW, bestH, bestW, bestH * bestW, maxTiles, imageAspect);
numSplitsH = bestH;
numSplitsW = bestW;
}
int maxGrid = 6;
if (numSplitsH > maxGrid || numSplitsW > maxGrid) {
double scale = Math.min((double) maxGrid / numSplitsH, (double) maxGrid / numSplitsW);
int newH = Math.max(1, (int) Math.floor(numSplitsH * scale));
int newW = Math.max(1, (int) Math.floor(numSplitsW * scale));
numSplitsH = Math.min(maxGrid, newH);
numSplitsW = Math.min(maxGrid, newW);
log.info("Reducing grid {}x{} to {}x{} to fit row/col token limits",
(int) Math.ceil((double) height / maxSize),
(int) Math.ceil((double) width / maxSize),
numSplitsH,
numSplitsW);
}
return new int[]{numSplitsH, numSplitsW};
}
private static PreparedFrame prepareFrame(BufferedImage source, int maxSize) {
ResizeResult resized = resizeToFit(source, maxSize, maxSize);
BufferedImage padded = padToSize(resized.image, maxSize, maxSize);
return new PreparedFrame(padded, new ContentRegion(resized.width, resized.height));
}
private static class PreparedFrame {
private final BufferedImage image;
private final ContentRegion contentRegion;
private PreparedFrame(BufferedImage image, ContentRegion contentRegion) {
this.image = image;
this.contentRegion = contentRegion;
}
}
/**
* Resize image so that the longest edge matches the target length.
*/
@@ -446,21 +386,34 @@ public class ImageTiler {
}
/**
* Resize an image to the specified dimensions using bilinear interpolation.
* Resize an image to the specified dimensions using bicubic interpolation.
*/
public static BufferedImage resizeImage(BufferedImage original, int targetWidth, int targetHeight) {
BufferedImage resized = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resized.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null);
g2d.dispose();
// Apply mild sharpening to preserve character edge clarity for OCR
String sharpen = System.getProperty("nd4j.vlm.image.sharpen", "false");
if ("true".equalsIgnoreCase(sharpen)) {
float[] sharpenKernel = {
0, -0.5f, 0,
-0.5f, 3.0f, -0.5f,
0, -0.5f, 0
};
Kernel kernel = new Kernel(3, 3, sharpenKernel);
ConvolveOp sharpenOp = new ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null);
resized = sharpenOp.filter(resized, null);
}
return resized;
}
/**
* Pad an image to the target size (top-left aligned, black padding).
* Pad an image to the target size (top-left aligned, white padding).
*/
public static BufferedImage padToSize(BufferedImage image, int targetWidth, int targetHeight) {
if (image.getWidth() == targetWidth && image.getHeight() == targetHeight) {
@@ -468,7 +421,7 @@ public class ImageTiler {
}
BufferedImage padded = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = padded.createGraphics();
g2d.setColor(Color.BLACK);
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, targetWidth, targetHeight);
g2d.drawImage(image, 0, 0, null);
g2d.dispose();
@@ -0,0 +1,235 @@
package org.eclipse.deeplearning4j.llm.generation;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.eclipse.deeplearning4j.llm.config.PreprocessorConfig;
import org.eclipse.deeplearning4j.llm.tokenizer.HuggingFaceTokenizer;
import org.eclipse.deeplearning4j.llm.tokenizer.Tokenizer;
import org.eclipse.deeplearning4j.model.benchmark.BenchmarkConfig;
import org.eclipse.deeplearning4j.model.benchmark.BenchmarkConfigApplier;
import org.eclipse.deeplearning4j.vlm.data.VLMModelDownloader;
import org.eclipse.deeplearning4j.vlm.model.EmbeddingMerger;
import org.eclipse.deeplearning4j.vlm.model.OnnxModelCache;
import org.eclipse.deeplearning4j.vlm.model.VisionEncoder;
import org.eclipse.deeplearning4j.vlm.model.VisionEncoderUtils;
import org.eclipse.deeplearning4j.vlm.preprocessing.ImagePromptBuilder;
import org.eclipse.deeplearning4j.vlm.preprocessing.ImageTiler;
import org.eclipse.deeplearning4j.vlm.preprocessing.VLMImagePreprocessor;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.nd4j.autodiff.samediff.SameDiff;
import org.nd4j.linalg.api.ndarray.INDArray;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
@Slf4j
public class TestGenerationPipelineAccuracy {
private static final int TARGET_SIZE = 512;
private static SameDiff decoder;
private static SameDiff embedTokens;
private static SameDiff visionEncoderSd;
private static Tokenizer tokenizer;
private static File pdfFile;
private static boolean loaded;
@BeforeAll
public static void setup() {
System.setProperty("nd4j.optimizer.enabled", "true");
System.setProperty("nd4j.optimizer.fp16", "true");
}
@Test
@DisplayName("GenerationPipeline reads page 10 OCR content at configured DPI")
public void testPage10OcrAccuracy() throws Exception {
ensureLoaded();
int dpi = Integer.getInteger("vlm.test.pdf.dpi", 150);
int maxTokens = Integer.getInteger("vlm.test.maxTokens", 100);
BenchmarkConfig config = BenchmarkConfig.optimal().maxTokens(maxTokens).minDiversityPct(0);
BufferedImage pdfImage = loadPageImage(pdfFile, 10, dpi);
log.info("Running OCR test at {} DPI: image={}x{}", dpi, pdfImage.getWidth(), pdfImage.getHeight());
BufferedImage resizedForTiling = ImageTiler.resizeLongestEdge(pdfImage, 2048);
ImageTiler.SplitImageResult splitResult = ImageTiler.splitImageForVLM(resizedForTiling, TARGET_SIZE, 9);
PreprocessorConfig ppConfig = buildPreprocessorConfig();
VLMImagePreprocessor preprocessor = VLMImagePreprocessor.fromConfig(ppConfig);
INDArray imageInput = VisionEncoderUtils.preprocessFrames(splitResult.frames, preprocessor, TARGET_SIZE);
preprocessor.shutdown();
VisionEncoder visionEncoder = VisionEncoder.builder()
.model(visionEncoderSd)
.targetSize(TARGET_SIZE)
.maxTiles(9)
.build();
INDArray visionEmbeddings;
try {
VisionEncoder.Result visionResult = visionEncoder.encode(
imageInput, splitResult.getTotalFrames(), splitResult);
visionEmbeddings = visionResult.getEmbeddings();
} finally {
imageInput.close();
visionEncoder.close();
}
int imageTokenId = ImagePromptBuilder.resolveImageTokenId(tokenizer);
int imageSeqLenPerFrame = (int) visionEmbeddings.size(1) / splitResult.getTotalFrames();
String imagePrompt = ImagePromptBuilder.buildImagePromptString(
splitResult.numRows, splitResult.numCols, imageSeqLenPerFrame);
String chatPrompt = buildChatPrompt(imagePrompt);
int[] promptTokenIds = tokenizer.encode(chatPrompt, false).getIds();
INDArray textEmbeddings = null;
GenerationPipeline embedPipeline = GenerationPipeline.create(
GenerationPipelineConfig.builder()
.decoder(decoder)
.embedTokens(embedTokens)
.tokenizer(tokenizer)
.samplingConfig(SamplingConfig.greedy())
.maxNewTokens(1)
.build());
try {
textEmbeddings = embedPipeline.embedTokens(promptTokenIds);
} finally {
embedPipeline.close();
}
INDArray merged = EmbeddingMerger.mergeEmbeddings(
textEmbeddings, visionEmbeddings, promptTokenIds, imageTokenId);
textEmbeddings.close();
compileFor(config);
ModelIOConfig ioConfig = ModelIOConfig.discover(decoder);
GenerationPipeline pipeline = GenerationPipeline.create(
GenerationPipelineConfig.builder()
.decoder(decoder)
.embedTokens(embedTokens)
.tokenizer(tokenizer)
.ioConfig(ioConfig)
.samplingConfig(SamplingConfig.greedy())
.maxNewTokens(maxTokens)
.hiddenSize(576L)
.build());
try {
GenerationResult result = pipeline.generate(merged, promptTokenIds, maxTokens);
log.info("DPI_{} text='{}'", dpi, safeSnippet(result.getText(), 300));
assertOcrOutput(result, maxTokens);
} finally {
pipeline.close();
merged.close();
}
}
private static synchronized void ensureLoaded() throws Exception {
if (loaded) {
return;
}
pdfFile = discoverPdfFile();
VLMModelDownloader.DownloadResult visionDl =
VLMModelDownloader.download(VLMModelDownloader.VLMModel.SMOLDOCLING_VISION_ENCODER);
VLMModelDownloader.DownloadResult decoderDl =
VLMModelDownloader.download(VLMModelDownloader.VLMModel.SMOLDOCLING_DECODER);
VLMModelDownloader.DownloadResult embedTokensDl =
VLMModelDownloader.download(VLMModelDownloader.VLMModel.SMOLDOCLING_EMBED_TOKENS);
VLMModelDownloader.DownloadResult tokenizerDl =
VLMModelDownloader.download(VLMModelDownloader.VLMModel.SMOLDOCLING_TOKENIZER);
VLMModelDownloader.download(VLMModelDownloader.VLMModel.SMOLDOCLING_TOKENIZER_CONFIG);
tokenizer = HuggingFaceTokenizer.fromFile(tokenizerDl.getModelFile());
SameDiff[] models = OnnxModelCache.importAllWithCache(
visionDl.getModelFile().getAbsolutePath(),
decoderDl.getModelFile().getAbsolutePath(),
embedTokensDl.getModelFile().getAbsolutePath()
);
visionEncoderSd = models[0];
decoder = models[1];
embedTokens = models[2];
loaded = true;
log.info("Models loaded. pdf={}", pdfFile.getAbsolutePath());
}
private static File discoverPdfFile() {
String configuredPath = System.getProperty("vlm.test.pdf.path");
if (configuredPath != null && !configuredPath.isBlank()) {
return new File(configuredPath);
}
File f = new File(System.getProperty("user.dir"), "pathfinder-mythic.pdf");
if (f.exists()) {
return f;
}
return new File("/home/agibsonccc/Documents/GitHub/deeplearning4j/platform-tests/pathfinder-mythic.pdf");
}
private static BufferedImage loadPageImage(File pdfFile, int page, int dpi) throws IOException {
assertTrue(pdfFile.exists(), "PDF must exist: " + pdfFile.getAbsolutePath());
try (PDDocument document = PDDocument.load(pdfFile)) {
PDFRenderer renderer = new PDFRenderer(document);
return renderer.renderImageWithDPI(page, dpi, ImageType.RGB);
}
}
private static PreprocessorConfig buildPreprocessorConfig() {
PreprocessorConfig config = new PreprocessorConfig();
config.setSize(new PreprocessorConfig.ImageSize(TARGET_SIZE, TARGET_SIZE));
config.setDoRescale(true);
config.setRescaleFactor(1.0 / 255.0);
config.setDoNormalize(true);
config.setImageMean(new double[]{0.5, 0.5, 0.5});
config.setImageStd(new double[]{0.5, 0.5, 0.5});
return config;
}
private static String buildChatPrompt(String imagePrompt) {
return "<|im_start|>User:" + imagePrompt + "Convert this page to docling.<end_of_utterance>\nAssistant:";
}
private static void compileFor(BenchmarkConfig config) {
BenchmarkConfigApplier.resetModelState(decoder);
BenchmarkConfigApplier.resetModelState(embedTokens);
BenchmarkConfigApplier.apply(config);
BenchmarkConfigApplier.compileModels(decoder, "decoder", embedTokens, "embed_tokens", config);
}
private static void assertOcrOutput(GenerationResult result, int maxTokens) {
String text = result.getText();
assertNotNull(text, "Generated text is null");
String normalized = text.trim().toLowerCase();
int minUsefulTokens = Math.min(maxTokens, 50);
assertTrue(result.getGeneratedTokenCount() >= minUsefulTokens,
"Generated too few tokens: " + result.getGeneratedTokenCount());
assertTrue(normalized.contains("<"),
"Expected structural tags in output. Text: " + safeSnippet(text, 220));
assertTrue(normalized.contains("<text>") || normalized.contains("<page>"),
"Expected text/page structural tags. Text: " + safeSnippet(text, 220));
assertFalse(normalized.startsWith("<doctag><picture>"),
"Collapsed to picture-only output. Text: " + safeSnippet(text, 220));
}
private static String safeSnippet(String text, int maxChars) {
if (text == null) {
return "<null>";
}
String normalized = text.replace("\n", " ").replace("\r", " ").trim();
if (normalized.length() <= maxChars) {
return normalized;
}
return normalized.substring(0, maxChars) + "...";
}
}