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:
@@ -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
|
||||
+142
-189
@@ -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();
|
||||
|
||||
+235
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user