[draft]Optimize Nemotron OCR GPU execution for stream-aware serving
#8
by VibhuJawa - opened
- nemotron-ocr/cpp/better_grid_sample/gpu_indirect_grid_sample.cu +31 -21
- nemotron-ocr/cpp/cuda_intellisense.cuh +8 -2
- nemotron-ocr/cpp/local_ips/quad_all_2_all_dist_v2.cu +4 -1
- nemotron-ocr/cpp/non_maximal_suppression/cuda_non_maximal_suppression.cu +22 -15
- nemotron-ocr/src/nemotron_ocr/inference/encoders/relational_encoder.py +98 -12
- nemotron-ocr/src/nemotron_ocr/inference/models/detector/aspp.py +157 -5
- nemotron-ocr/src/nemotron_ocr/inference/models/detector/fast_batch_norm.py +516 -0
- nemotron-ocr/src/nemotron_ocr/inference/models/detector/fots_detector.py +193 -9
- nemotron-ocr/src/nemotron_ocr/inference/models/relational.py +293 -67
- nemotron-ocr/src/nemotron_ocr/inference/pipeline_v2.py +368 -99
- nemotron-ocr/tests/test_detector_exact_copy_helpers.py +172 -0
- nemotron-ocr/tests/test_fast_batch_norm.py +127 -0
- nemotron-ocr/tests/test_pipeline_v2_prefilter.py +93 -0
- nemotron-ocr/tests/test_relational_batched_geometry.py +162 -0
- nemotron-ocr/tests/test_relational_encoder_staging.py +152 -0
nemotron-ocr/cpp/better_grid_sample/gpu_indirect_grid_sample.cu
CHANGED
|
@@ -79,21 +79,18 @@ void single_ex_grid_sample_bilinear_kernel(const float *pInputImage,
|
|
| 79 |
}
|
| 80 |
}
|
| 81 |
|
| 82 |
-
template<typename
|
| 83 |
__global__
|
| 84 |
-
void indirect_grid_sample_forward_bilinear_kernel(torch::PackedTensorAccessor32<
|
| 85 |
-
torch::PackedTensorAccessor32<
|
| 86 |
torch::PackedTensorAccessor32<int64_t, 1> inputIndices,
|
| 87 |
-
torch::PackedTensorAccessor32<
|
| 88 |
{
|
| 89 |
-
static_assert(std::is_same<T, float>::value, "Currently only float32 is supported!");
|
| 90 |
-
//typedef typename fp_promote<T>::type accum_t;
|
| 91 |
typedef float accum_t;
|
| 92 |
-
constexpr
|
| 93 |
-
constexpr
|
| 94 |
-
constexpr
|
| 95 |
-
constexpr
|
| 96 |
-
constexpr T ZERO_PT_5 = 0.5;
|
| 97 |
typedef decltype(inputs.stride(0)) index_t;
|
| 98 |
|
| 99 |
const index_t n = blockDim.z * blockIdx.z + threadIdx.z;
|
|
@@ -148,12 +145,12 @@ void indirect_grid_sample_forward_bilinear_kernel(torch::PackedTensorAccessor32<
|
|
| 148 |
for (index_t row = 0; row < 2; ++row) {
|
| 149 |
#pragma unroll
|
| 150 |
for (index_t col = 0; col < 2; ++col) {
|
| 151 |
-
|
| 152 |
-
opVal += rs[row] * ps[col] * Convert<
|
| 153 |
}
|
| 154 |
}
|
| 155 |
|
| 156 |
-
outputs[n][c][outY][outX] =
|
| 157 |
}
|
| 158 |
|
| 159 |
template<typename T>
|
|
@@ -230,14 +227,20 @@ void indirect_grid_sample_backward_bilinear_kernel(torch::PackedTensorAccessor64
|
|
| 230 |
|
| 231 |
torch::Tensor gpu_indirect_grid_sample_forward(torch::Tensor input, torch::Tensor grid, torch::Tensor inputIndices, const std::string &method)
|
| 232 |
{
|
| 233 |
-
auto output = input.new_empty(
|
|
|
|
|
|
|
| 234 |
|
| 235 |
|
| 236 |
if (method != "bilinear"s) {
|
| 237 |
throw runtime_error("Only 'bilinear' sampling is currently supported!");
|
| 238 |
}
|
| 239 |
|
| 240 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
uint32_t gridNumCells = grid.size(1) * grid.size(2);
|
| 242 |
dim3 blockDim(32, 3, 1);
|
| 243 |
dim3 gridDim(div_up(gridNumCells, blockDim.x),
|
|
@@ -259,11 +262,18 @@ torch::Tensor gpu_indirect_grid_sample_forward(torch::Tensor input, torch::Tenso
|
|
| 259 |
dim3 gridDim(div_up(grid.size(1) * grid.size(2), blockDim.x),
|
| 260 |
div_up(input.size(1), blockDim.y),
|
| 261 |
div_up(inputIndices.size(0), blockDim.z));
|
| 262 |
-
|
| 263 |
-
input.
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
);
|
| 268 |
}
|
| 269 |
|
|
|
|
| 79 |
}
|
| 80 |
}
|
| 81 |
|
| 82 |
+
template<typename input_t>
|
| 83 |
__global__
|
| 84 |
+
void indirect_grid_sample_forward_bilinear_kernel(torch::PackedTensorAccessor32<input_t, 4> inputs,
|
| 85 |
+
torch::PackedTensorAccessor32<float, 4> grid,
|
| 86 |
torch::PackedTensorAccessor32<int64_t, 1> inputIndices,
|
| 87 |
+
torch::PackedTensorAccessor32<float, 4> outputs)
|
| 88 |
{
|
|
|
|
|
|
|
| 89 |
typedef float accum_t;
|
| 90 |
+
constexpr float NEG_ONE = -1;
|
| 91 |
+
constexpr float ONE = 1;
|
| 92 |
+
constexpr float ZERO = 0;
|
| 93 |
+
constexpr float ZERO_PT_5 = 0.5;
|
|
|
|
| 94 |
typedef decltype(inputs.stride(0)) index_t;
|
| 95 |
|
| 96 |
const index_t n = blockDim.z * blockIdx.z + threadIdx.z;
|
|
|
|
| 145 |
for (index_t row = 0; row < 2; ++row) {
|
| 146 |
#pragma unroll
|
| 147 |
for (index_t col = 0; col < 2; ++col) {
|
| 148 |
+
input_t inputPixel = my_get_pixel_clamped(localInputs, inXint + col, inYint + row);
|
| 149 |
+
opVal += rs[row] * ps[col] * Convert<input_t, accum_t>::LeftToRight(inputPixel);
|
| 150 |
}
|
| 151 |
}
|
| 152 |
|
| 153 |
+
outputs[n][c][outY][outX] = opVal;
|
| 154 |
}
|
| 155 |
|
| 156 |
template<typename T>
|
|
|
|
| 227 |
|
| 228 |
torch::Tensor gpu_indirect_grid_sample_forward(torch::Tensor input, torch::Tensor grid, torch::Tensor inputIndices, const std::string &method)
|
| 229 |
{
|
| 230 |
+
auto output = input.new_empty(
|
| 231 |
+
{ inputIndices.size(0), input.size(1), grid.size(1), grid.size(2) },
|
| 232 |
+
input.options().dtype(torch::kFloat32));
|
| 233 |
|
| 234 |
|
| 235 |
if (method != "bilinear"s) {
|
| 236 |
throw runtime_error("Only 'bilinear' sampling is currently supported!");
|
| 237 |
}
|
| 238 |
|
| 239 |
+
if (grid.scalar_type() != torch::kFloat32) {
|
| 240 |
+
throw std::runtime_error("The CUDA grid must have dtype float32!");
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
if (input.scalar_type() == torch::kFloat32 && input.size(0) == 1 && input.is_contiguous() && grid.is_contiguous()) {
|
| 244 |
uint32_t gridNumCells = grid.size(1) * grid.size(2);
|
| 245 |
dim3 blockDim(32, 3, 1);
|
| 246 |
dim3 gridDim(div_up(gridNumCells, blockDim.x),
|
|
|
|
| 262 |
dim3 gridDim(div_up(grid.size(1) * grid.size(2), blockDim.x),
|
| 263 |
div_up(input.size(1), blockDim.y),
|
| 264 |
div_up(inputIndices.size(0), blockDim.z));
|
| 265 |
+
AT_DISPATCH_FLOATING_TYPES_AND_HALF(
|
| 266 |
+
input.scalar_type(),
|
| 267 |
+
"gpu_indirect_grid_sample_forward",
|
| 268 |
+
([&] {
|
| 269 |
+
typedef typename remap_half<scalar_t>::type input_t;
|
| 270 |
+
indirect_grid_sample_forward_bilinear_kernel KERNEL_ARG2(gridDim, blockDim) (
|
| 271 |
+
input.packed_accessor32<input_t, 4>(),
|
| 272 |
+
grid.packed_accessor32<float, 4>(),
|
| 273 |
+
inputIndices.packed_accessor32<int64_t, 1>(),
|
| 274 |
+
output.packed_accessor32<float, 4>()
|
| 275 |
+
);
|
| 276 |
+
})
|
| 277 |
);
|
| 278 |
}
|
| 279 |
|
nemotron-ocr/cpp/cuda_intellisense.cuh
CHANGED
|
@@ -3,6 +3,10 @@
|
|
| 3 |
|
| 4 |
#pragma once
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
#if defined(__INTELLISENSE__) || !defined(__NVCC__)
|
| 7 |
#ifndef KERNEL_ARG2
|
| 8 |
#define KERNEL_ARG2(grid, block)
|
|
@@ -27,8 +31,10 @@ dim3 gridDim;
|
|
| 27 |
|
| 28 |
#else
|
| 29 |
#ifndef KERNEL_ARG2
|
| 30 |
-
#define KERNEL_ARG2(grid, block)
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
#define KERNEL_ARG4(grid, block, sh_mem, stream) <<< grid, block, sh_mem, stream >>>
|
| 33 |
#endif
|
| 34 |
#endif
|
|
|
|
| 3 |
|
| 4 |
#pragma once
|
| 5 |
|
| 6 |
+
#ifdef __NVCC__
|
| 7 |
+
#include <ATen/cuda/CUDAContext.h>
|
| 8 |
+
#endif
|
| 9 |
+
|
| 10 |
#if defined(__INTELLISENSE__) || !defined(__NVCC__)
|
| 11 |
#ifndef KERNEL_ARG2
|
| 12 |
#define KERNEL_ARG2(grid, block)
|
|
|
|
| 31 |
|
| 32 |
#else
|
| 33 |
#ifndef KERNEL_ARG2
|
| 34 |
+
#define KERNEL_ARG2(grid, block) \
|
| 35 |
+
<<< grid, block, 0, at::cuda::getCurrentCUDAStream().stream() >>>
|
| 36 |
+
#define KERNEL_ARG3(grid, block, sh_mem) \
|
| 37 |
+
<<< grid, block, sh_mem, at::cuda::getCurrentCUDAStream().stream() >>>
|
| 38 |
#define KERNEL_ARG4(grid, block, sh_mem, stream) <<< grid, block, sh_mem, stream >>>
|
| 39 |
#endif
|
| 40 |
#endif
|
nemotron-ocr/cpp/local_ips/quad_all_2_all_dist_v2.cu
CHANGED
|
@@ -143,7 +143,10 @@ torch::Tensor ragged_quad_all_2_all_distance_v2(torch::Tensor embedQuads, torch:
|
|
| 143 |
|
| 144 |
auto csWorkPerExample = torch::cumsum(workPerExample, 0);
|
| 145 |
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
dim3 blockSize(16, 2);
|
| 149 |
dim3 gridSize(div_up(totalWork, blockSize.y), 1);
|
|
|
|
| 143 |
|
| 144 |
auto csWorkPerExample = torch::cumsum(workPerExample, 0);
|
| 145 |
|
| 146 |
+
// Launch from tensor metadata so we do not synchronize to read a CUDA
|
| 147 |
+
// scalar. The kernel already bounds-checks against the exact cumulative
|
| 148 |
+
// work count.
|
| 149 |
+
int64_t totalWork = embedQuads.size(0) * embedQuads.size(1) * embedQuads.size(1);
|
| 150 |
|
| 151 |
dim3 blockSize(16, 2);
|
| 152 |
dim3 gridSize(div_up(totalWork, blockSize.y), 1);
|
nemotron-ocr/cpp/non_maximal_suppression/cuda_non_maximal_suppression.cu
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
|
| 4 |
#include "non_maximal_suppression.h"
|
| 5 |
|
|
|
|
| 6 |
#include <cooperative_groups.h>
|
| 7 |
#include <cooperative_groups/reduce.h>
|
| 8 |
|
|
@@ -1028,6 +1029,7 @@ struct CollapseRowsResult {
|
|
| 1028 |
torch::Tensor ExCounts;
|
| 1029 |
torch::Tensor StridedMergeQuads;
|
| 1030 |
int32_t TotalNumQuads;
|
|
|
|
| 1031 |
// NOTE: This will only be available in Debug builds
|
| 1032 |
torch::Tensor QuadIds;
|
| 1033 |
int32_t ImageWidth;
|
|
@@ -1096,16 +1098,18 @@ CollapseRowsResult collapse_rows(
|
|
| 1096 |
}
|
| 1097 |
#endif
|
| 1098 |
|
| 1099 |
-
//
|
| 1100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1101 |
|
| 1102 |
counts = counts.slice(/*dim=*/ 0, 0, counts.size(0) - 1);
|
| 1103 |
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
maxExCount =
|
| 1107 |
-
} else {
|
| 1108 |
-
maxExCount = totalQuads;
|
| 1109 |
}
|
| 1110 |
|
| 1111 |
static bool s_sortOrder = false;
|
|
@@ -1119,7 +1123,15 @@ CollapseRowsResult collapse_rows(
|
|
| 1119 |
rowMergeTensor = torch::gather(rowMergeTensor, /*dim=*/ 2, embOrder);
|
| 1120 |
idsTensor = torch::gather(idsTensor, /*dim=*/ 1, order);
|
| 1121 |
|
| 1122 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1123 |
}
|
| 1124 |
|
| 1125 |
|
|
@@ -1247,12 +1259,7 @@ AdjacencyResult compute_all_to_all_adjacency(
|
|
| 1247 |
{
|
| 1248 |
torch::Tensor counts = collapseResult.ExCounts;
|
| 1249 |
|
| 1250 |
-
int64_t maxExCount;
|
| 1251 |
-
if (counts.size(0) > 1) {
|
| 1252 |
-
maxExCount = counts.max().item<int32_t>();
|
| 1253 |
-
} else {
|
| 1254 |
-
maxExCount = collapseResult.TotalNumQuads;
|
| 1255 |
-
}
|
| 1256 |
|
| 1257 |
auto isStartTensor = torch::ones({ counts.size(0), maxExCount }, counts.options().dtype(torch::kBool));
|
| 1258 |
auto adjCountsTensor = torch::zeros({ counts.size(0), maxExCount }, counts.options().dtype(torch::kInt32));
|
|
@@ -1595,7 +1602,7 @@ nms_result_t cuda_quad_non_maximal_suppression_impl(
|
|
| 1595 |
torch::Tensor retQuads, retConf, regionCounts;
|
| 1596 |
|
| 1597 |
{
|
| 1598 |
-
CudaStoreTimer tTotal{msTotal, s_timerEnabled};
|
| 1599 |
{
|
| 1600 |
CudaStoreTimer t{msRowCollapse, s_timerEnabled && verbose && s_verboseLevel2};
|
| 1601 |
|
|
|
|
| 3 |
|
| 4 |
#include "non_maximal_suppression.h"
|
| 5 |
|
| 6 |
+
#include <algorithm>
|
| 7 |
#include <cooperative_groups.h>
|
| 8 |
#include <cooperative_groups/reduce.h>
|
| 9 |
|
|
|
|
| 1029 |
torch::Tensor ExCounts;
|
| 1030 |
torch::Tensor StridedMergeQuads;
|
| 1031 |
int32_t TotalNumQuads;
|
| 1032 |
+
int32_t MaxExCount;
|
| 1033 |
// NOTE: This will only be available in Debug builds
|
| 1034 |
torch::Tensor QuadIds;
|
| 1035 |
int32_t ImageWidth;
|
|
|
|
| 1098 |
}
|
| 1099 |
#endif
|
| 1100 |
|
| 1101 |
+
// Copy this tiny counter vector once. Reading the total and maximum via
|
| 1102 |
+
// separate CUDA scalar operations would introduce two stream barriers and
|
| 1103 |
+
// launch an unnecessary device reduction.
|
| 1104 |
+
auto countsCpu = counts.cpu();
|
| 1105 |
+
auto countsCpuAcc = countsCpu.accessor<int32_t, 1>();
|
| 1106 |
+
int32_t totalQuads = countsCpuAcc[countsCpuAcc.size(0) - 1];
|
| 1107 |
|
| 1108 |
counts = counts.slice(/*dim=*/ 0, 0, counts.size(0) - 1);
|
| 1109 |
|
| 1110 |
+
int32_t maxExCount = 0;
|
| 1111 |
+
for (int64_t i = 0; i < counts.size(0); ++i) {
|
| 1112 |
+
maxExCount = std::max(maxExCount, countsCpuAcc[i]);
|
|
|
|
|
|
|
| 1113 |
}
|
| 1114 |
|
| 1115 |
static bool s_sortOrder = false;
|
|
|
|
| 1123 |
rowMergeTensor = torch::gather(rowMergeTensor, /*dim=*/ 2, embOrder);
|
| 1124 |
idsTensor = torch::gather(idsTensor, /*dim=*/ 1, order);
|
| 1125 |
|
| 1126 |
+
return {
|
| 1127 |
+
counts,
|
| 1128 |
+
rowMergeTensor,
|
| 1129 |
+
totalQuads,
|
| 1130 |
+
maxExCount,
|
| 1131 |
+
idsTensor,
|
| 1132 |
+
imageWidth,
|
| 1133 |
+
imageHeight,
|
| 1134 |
+
};
|
| 1135 |
}
|
| 1136 |
|
| 1137 |
|
|
|
|
| 1259 |
{
|
| 1260 |
torch::Tensor counts = collapseResult.ExCounts;
|
| 1261 |
|
| 1262 |
+
const int64_t maxExCount = collapseResult.MaxExCount;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1263 |
|
| 1264 |
auto isStartTensor = torch::ones({ counts.size(0), maxExCount }, counts.options().dtype(torch::kBool));
|
| 1265 |
auto adjCountsTensor = torch::zeros({ counts.size(0), maxExCount }, counts.options().dtype(torch::kInt32));
|
|
|
|
| 1602 |
torch::Tensor retQuads, retConf, regionCounts;
|
| 1603 |
|
| 1604 |
{
|
| 1605 |
+
CudaStoreTimer tTotal{msTotal, s_timerEnabled && verbose};
|
| 1606 |
{
|
| 1607 |
CudaStoreTimer t{msRowCollapse, s_timerEnabled && verbose && s_verboseLevel2};
|
| 1608 |
|
nemotron-ocr/src/nemotron_ocr/inference/encoders/relational_encoder.py
CHANGED
|
@@ -20,6 +20,39 @@ from nemotron_ocr_cpp import dense_relations_to_graph as cpp_dense_relations_to_
|
|
| 20 |
logging.getLogger("shapely.geos").setLevel(logging.FATAL)
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
class RelationalTargetEncoder(TargetEncoderBase):
|
| 24 |
def __init__(self, input_size, amp_opt=0, is_train=True):
|
| 25 |
super().__init__(input_size, amp_opt, False)
|
|
@@ -38,13 +71,14 @@ class RelationalTargetEncoder(TargetEncoderBase):
|
|
| 38 |
if all_word_relations[0].dim() == 1:
|
| 39 |
all_word_relations = [sparse_to_dense(gt_rel) for gt_rel in all_word_relations]
|
| 40 |
|
| 41 |
-
all_word_relations_cpu =
|
| 42 |
|
| 43 |
region_counts = region_counts.cpu()
|
| 44 |
all_quads_cpu = all_quads.cpu()
|
| 45 |
cs_region_counts = torch.cumsum(region_counts, 0)
|
| 46 |
|
| 47 |
-
|
|
|
|
| 48 |
for i, word_relations_cpu in enumerate(all_word_relations_cpu):
|
| 49 |
start_offset = cs_region_counts[i - 1] if i > 0 else 0
|
| 50 |
end_offset = cs_region_counts[i]
|
|
@@ -55,14 +89,36 @@ class RelationalTargetEncoder(TargetEncoderBase):
|
|
| 55 |
regions = [tr.TextRegion(Quadrangle(q), "") for q in quads]
|
| 56 |
graph = None
|
| 57 |
if end_offset > start_offset:
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
)
|
| 61 |
else:
|
| 62 |
graph = tr.RelationGraph()
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
if limit_idxs is not None:
|
| 68 |
examples = [examples[idx] for idx in limit_idxs]
|
|
@@ -76,8 +132,31 @@ class RelationalTargetEncoder(TargetEncoderBase):
|
|
| 76 |
line_log_uncertainty: torch.Tensor = None,
|
| 77 |
is_gt=False,
|
| 78 |
):
|
| 79 |
-
lines =
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
dev = line_logits.device
|
| 82 |
line_logits = line_logits.float()
|
| 83 |
|
|
@@ -91,14 +170,14 @@ class RelationalTargetEncoder(TargetEncoderBase):
|
|
| 91 |
else:
|
| 92 |
inv_uncertainty = torch.ones_like(line_logits)
|
| 93 |
|
| 94 |
-
n_words = word_relations.shape[0]
|
| 95 |
-
|
| 96 |
line_lengths = torch.tensor([len(line) for line in lines], dtype=torch.int64, device=dev)
|
| 97 |
word_indices = torch.tensor(
|
| 98 |
[word for line in lines for word in line], dtype=torch.int64, device=dev
|
| 99 |
)
|
| 100 |
line_ids = torch.repeat_interleave(
|
| 101 |
-
torch.arange(len(lines), dtype=torch.int64, device=dev),
|
|
|
|
|
|
|
| 102 |
)
|
| 103 |
w_idx_to_line_map = torch.empty(n_words, dtype=torch.int64, device=dev)
|
| 104 |
w_idx_to_line_map[word_indices] = line_ids
|
|
@@ -156,7 +235,14 @@ class RelationalTargetEncoder(TargetEncoderBase):
|
|
| 156 |
)
|
| 157 |
line_to_line_probs = line_to_line_probs[:, 1:]
|
| 158 |
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
paragraphs = []
|
| 162 |
for rel_line in rel_lines:
|
|
|
|
| 20 |
logging.getLogger("shapely.geos").setLevel(logging.FATAL)
|
| 21 |
|
| 22 |
|
| 23 |
+
def _copy_tensors_to_cpu_batched(tensors):
|
| 24 |
+
"""Copy CUDA tensors to pinned host memory with one wait per stream.
|
| 25 |
+
|
| 26 |
+
Calling ``Tensor.cpu()`` for each relation matrix serializes every D2H
|
| 27 |
+
transfer with the host. Stage all copies first so CUDA can process them as
|
| 28 |
+
one queue, then wait once before the CPU graph code dereferences them.
|
| 29 |
+
"""
|
| 30 |
+
cpu_tensors = []
|
| 31 |
+
pending_streams = {}
|
| 32 |
+
|
| 33 |
+
for tensor in tensors:
|
| 34 |
+
if tensor is None or tensor.device.type == "cpu":
|
| 35 |
+
cpu_tensors.append(tensor)
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
if tensor.device.type != "cuda":
|
| 39 |
+
cpu_tensors.append(tensor.cpu())
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
with torch.cuda.device(tensor.device):
|
| 43 |
+
stream = torch.cuda.current_stream(tensor.device)
|
| 44 |
+
cpu_tensor = torch.empty_like(tensor, device="cpu", pin_memory=True)
|
| 45 |
+
cpu_tensor.copy_(tensor, non_blocking=True)
|
| 46 |
+
|
| 47 |
+
cpu_tensors.append(cpu_tensor)
|
| 48 |
+
pending_streams[(tensor.device.index, stream.cuda_stream)] = stream
|
| 49 |
+
|
| 50 |
+
for stream in pending_streams.values():
|
| 51 |
+
stream.synchronize()
|
| 52 |
+
|
| 53 |
+
return cpu_tensors
|
| 54 |
+
|
| 55 |
+
|
| 56 |
class RelationalTargetEncoder(TargetEncoderBase):
|
| 57 |
def __init__(self, input_size, amp_opt=0, is_train=True):
|
| 58 |
super().__init__(input_size, amp_opt, False)
|
|
|
|
| 71 |
if all_word_relations[0].dim() == 1:
|
| 72 |
all_word_relations = [sparse_to_dense(gt_rel) for gt_rel in all_word_relations]
|
| 73 |
|
| 74 |
+
all_word_relations_cpu = _copy_tensors_to_cpu_batched(all_word_relations)
|
| 75 |
|
| 76 |
region_counts = region_counts.cpu()
|
| 77 |
all_quads_cpu = all_quads.cpu()
|
| 78 |
cs_region_counts = torch.cumsum(region_counts, 0)
|
| 79 |
|
| 80 |
+
prepared_examples = []
|
| 81 |
+
pending_line_graphs = []
|
| 82 |
for i, word_relations_cpu in enumerate(all_word_relations_cpu):
|
| 83 |
start_offset = cs_region_counts[i - 1] if i > 0 else 0
|
| 84 |
end_offset = cs_region_counts[i]
|
|
|
|
| 89 |
regions = [tr.TextRegion(Quadrangle(q), "") for q in quads]
|
| 90 |
graph = None
|
| 91 |
if end_offset > start_offset:
|
| 92 |
+
lines = self._word_relations_to_lines(word_relations_cpu)
|
| 93 |
+
line_to_line_probs = self._build_line_relation_probs(
|
| 94 |
+
lines,
|
| 95 |
+
word_relations_cpu.shape[0],
|
| 96 |
+
line_relations,
|
| 97 |
+
line_unc,
|
| 98 |
+
is_gt,
|
| 99 |
+
)
|
| 100 |
+
pending_line_graphs.append(
|
| 101 |
+
(len(prepared_examples), lines, line_to_line_probs)
|
| 102 |
)
|
| 103 |
else:
|
| 104 |
graph = tr.RelationGraph()
|
| 105 |
|
| 106 |
+
prepared_examples.append([regions, graph])
|
| 107 |
+
|
| 108 |
+
line_probs_cpu = _copy_tensors_to_cpu_batched(
|
| 109 |
+
[line_probs for _, _, line_probs in pending_line_graphs]
|
| 110 |
+
)
|
| 111 |
+
for (example_idx, lines, _), line_probs in zip(
|
| 112 |
+
pending_line_graphs, line_probs_cpu
|
| 113 |
+
):
|
| 114 |
+
prepared_examples[example_idx][1] = self._line_probs_to_graph(
|
| 115 |
+
lines, line_probs
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
examples = [
|
| 119 |
+
tr.Example(regions, relation_graph=graph)
|
| 120 |
+
for regions, graph in prepared_examples
|
| 121 |
+
]
|
| 122 |
|
| 123 |
if limit_idxs is not None:
|
| 124 |
examples = [examples[idx] for idx in limit_idxs]
|
|
|
|
| 132 |
line_log_uncertainty: torch.Tensor = None,
|
| 133 |
is_gt=False,
|
| 134 |
):
|
| 135 |
+
lines = self._word_relations_to_lines(word_relations)
|
| 136 |
+
line_to_line_probs = self._build_line_relation_probs(
|
| 137 |
+
lines,
|
| 138 |
+
word_relations.shape[0],
|
| 139 |
+
line_logits,
|
| 140 |
+
line_log_uncertainty,
|
| 141 |
+
is_gt,
|
| 142 |
+
)
|
| 143 |
+
line_to_line_probs_cpu = _copy_tensors_to_cpu_batched(
|
| 144 |
+
[line_to_line_probs]
|
| 145 |
+
)[0]
|
| 146 |
+
return self._line_probs_to_graph(lines, line_to_line_probs_cpu)
|
| 147 |
+
|
| 148 |
+
@staticmethod
|
| 149 |
+
def _word_relations_to_lines(word_relations):
|
| 150 |
+
return [p[0] for p in cpp_dense_relations_to_graph(word_relations)]
|
| 151 |
+
|
| 152 |
+
@staticmethod
|
| 153 |
+
def _build_line_relation_probs(
|
| 154 |
+
lines,
|
| 155 |
+
n_words,
|
| 156 |
+
line_logits,
|
| 157 |
+
line_log_uncertainty=None,
|
| 158 |
+
is_gt=False,
|
| 159 |
+
):
|
| 160 |
dev = line_logits.device
|
| 161 |
line_logits = line_logits.float()
|
| 162 |
|
|
|
|
| 170 |
else:
|
| 171 |
inv_uncertainty = torch.ones_like(line_logits)
|
| 172 |
|
|
|
|
|
|
|
| 173 |
line_lengths = torch.tensor([len(line) for line in lines], dtype=torch.int64, device=dev)
|
| 174 |
word_indices = torch.tensor(
|
| 175 |
[word for line in lines for word in line], dtype=torch.int64, device=dev
|
| 176 |
)
|
| 177 |
line_ids = torch.repeat_interleave(
|
| 178 |
+
torch.arange(len(lines), dtype=torch.int64, device=dev),
|
| 179 |
+
line_lengths,
|
| 180 |
+
output_size=word_indices.numel(),
|
| 181 |
)
|
| 182 |
w_idx_to_line_map = torch.empty(n_words, dtype=torch.int64, device=dev)
|
| 183 |
w_idx_to_line_map[word_indices] = line_ids
|
|
|
|
| 235 |
)
|
| 236 |
line_to_line_probs = line_to_line_probs[:, 1:]
|
| 237 |
|
| 238 |
+
return line_to_line_probs
|
| 239 |
+
|
| 240 |
+
@staticmethod
|
| 241 |
+
def _line_probs_to_graph(lines, line_to_line_probs_cpu):
|
| 242 |
+
rel_lines = set(
|
| 243 |
+
tuple(p[0])
|
| 244 |
+
for p in cpp_dense_relations_to_graph(line_to_line_probs_cpu)
|
| 245 |
+
)
|
| 246 |
|
| 247 |
paragraphs = []
|
| 248 |
for rel_line in rel_lines:
|
nemotron-ocr/src/nemotron_ocr/inference/models/detector/aspp.py
CHANGED
|
@@ -3,8 +3,145 @@
|
|
| 3 |
|
| 4 |
"""Atrous Spatial Pyramid Pooling implementation."""
|
| 5 |
|
|
|
|
|
|
|
| 6 |
import torch
|
| 7 |
from torch import nn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def _grow(rate, power):
|
|
@@ -84,6 +221,9 @@ class ASPP(nn.Module):
|
|
| 84 |
nn.ReLU(inplace=True),
|
| 85 |
nn.Dropout(p=dropout),
|
| 86 |
)
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
def forward(self, x):
|
| 89 |
"""The module forward function.
|
|
@@ -96,12 +236,24 @@ class ASPP(nn.Module):
|
|
| 96 |
"""
|
| 97 |
outs = [kernel(x) for kernel in self.kernels]
|
| 98 |
|
| 99 |
-
global_pool = self.global_pool(x)
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
if x.shape == out.shape:
|
| 107 |
return x + out
|
|
|
|
| 3 |
|
| 4 |
"""Atrous Spatial Pyramid Pooling implementation."""
|
| 5 |
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
import torch
|
| 9 |
from torch import nn
|
| 10 |
+
from torch.nn import functional as F
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
import triton
|
| 14 |
+
import triton.language as tl
|
| 15 |
+
except ImportError:
|
| 16 |
+
triton = None
|
| 17 |
+
tl = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
if triton is not None:
|
| 21 |
+
|
| 22 |
+
@triton.jit
|
| 23 |
+
def _aspp_concat_kernel(
|
| 24 |
+
branch_0,
|
| 25 |
+
branch_1,
|
| 26 |
+
branch_2,
|
| 27 |
+
branch_3,
|
| 28 |
+
branch_4,
|
| 29 |
+
branch_5,
|
| 30 |
+
branch_6,
|
| 31 |
+
pooled,
|
| 32 |
+
output,
|
| 33 |
+
total_elements,
|
| 34 |
+
channels: tl.constexpr,
|
| 35 |
+
spatial_size: tl.constexpr,
|
| 36 |
+
BLOCK_SIZE: tl.constexpr,
|
| 37 |
+
):
|
| 38 |
+
offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
| 39 |
+
valid = offsets < total_elements
|
| 40 |
+
spatial = offsets % spatial_size
|
| 41 |
+
quotient = offsets // spatial_size
|
| 42 |
+
output_channel = quotient % (8 * channels)
|
| 43 |
+
batch = quotient // (8 * channels)
|
| 44 |
+
branch = output_channel // channels
|
| 45 |
+
channel = output_channel % channels
|
| 46 |
+
source_offsets = (batch * channels + channel) * spatial_size + spatial
|
| 47 |
+
|
| 48 |
+
values = tl.load(
|
| 49 |
+
branch_0 + source_offsets,
|
| 50 |
+
mask=valid & (branch == 0),
|
| 51 |
+
other=0.0,
|
| 52 |
+
)
|
| 53 |
+
branch_values = tl.load(
|
| 54 |
+
branch_1 + source_offsets,
|
| 55 |
+
mask=valid & (branch == 1),
|
| 56 |
+
other=0.0,
|
| 57 |
+
)
|
| 58 |
+
values = tl.where(branch == 1, branch_values, values)
|
| 59 |
+
branch_values = tl.load(
|
| 60 |
+
branch_2 + source_offsets,
|
| 61 |
+
mask=valid & (branch == 2),
|
| 62 |
+
other=0.0,
|
| 63 |
+
)
|
| 64 |
+
values = tl.where(branch == 2, branch_values, values)
|
| 65 |
+
branch_values = tl.load(
|
| 66 |
+
branch_3 + source_offsets,
|
| 67 |
+
mask=valid & (branch == 3),
|
| 68 |
+
other=0.0,
|
| 69 |
+
)
|
| 70 |
+
values = tl.where(branch == 3, branch_values, values)
|
| 71 |
+
branch_values = tl.load(
|
| 72 |
+
branch_4 + source_offsets,
|
| 73 |
+
mask=valid & (branch == 4),
|
| 74 |
+
other=0.0,
|
| 75 |
+
)
|
| 76 |
+
values = tl.where(branch == 4, branch_values, values)
|
| 77 |
+
branch_values = tl.load(
|
| 78 |
+
branch_5 + source_offsets,
|
| 79 |
+
mask=valid & (branch == 5),
|
| 80 |
+
other=0.0,
|
| 81 |
+
)
|
| 82 |
+
values = tl.where(branch == 5, branch_values, values)
|
| 83 |
+
branch_values = tl.load(
|
| 84 |
+
branch_6 + source_offsets,
|
| 85 |
+
mask=valid & (branch == 6),
|
| 86 |
+
other=0.0,
|
| 87 |
+
)
|
| 88 |
+
values = tl.where(branch == 6, branch_values, values)
|
| 89 |
+
pooled_offsets = batch * channels + channel
|
| 90 |
+
branch_values = tl.load(
|
| 91 |
+
pooled + pooled_offsets,
|
| 92 |
+
mask=valid & (branch == 7),
|
| 93 |
+
other=0.0,
|
| 94 |
+
)
|
| 95 |
+
values = tl.where(branch == 7, branch_values, values)
|
| 96 |
+
tl.store(output + offsets, values, mask=valid)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _aspp_concat(branches, pooled):
|
| 100 |
+
"""Concatenate fixed ASPP branches without expanding the pooled tensor."""
|
| 101 |
+
if len(branches) != 7:
|
| 102 |
+
raise ValueError("fused ASPP concat expects seven spatial branches")
|
| 103 |
+
|
| 104 |
+
def eager_concat():
|
| 105 |
+
expanded_pooled = pooled.expand(-1, -1, *branches[0].shape[2:])
|
| 106 |
+
return torch.cat([*branches, expanded_pooled], dim=1)
|
| 107 |
+
|
| 108 |
+
if triton is None or not branches[0].is_cuda:
|
| 109 |
+
return eager_concat()
|
| 110 |
+
|
| 111 |
+
batch, channels, height, width = branches[0].shape
|
| 112 |
+
if pooled.shape != (batch, channels, 1, 1):
|
| 113 |
+
raise ValueError("fused ASPP pooled branch has an unexpected shape")
|
| 114 |
+
expected_shape = branches[0].shape
|
| 115 |
+
if (
|
| 116 |
+
any(not branch.is_contiguous() for branch in branches)
|
| 117 |
+
or not pooled.is_contiguous()
|
| 118 |
+
or any(branch.shape != expected_shape for branch in branches)
|
| 119 |
+
or any(branch.dtype != branches[0].dtype for branch in branches)
|
| 120 |
+
or any(branch.device != branches[0].device for branch in branches)
|
| 121 |
+
or pooled.dtype != branches[0].dtype
|
| 122 |
+
or pooled.device != branches[0].device
|
| 123 |
+
):
|
| 124 |
+
# Channels-last and other non-standard layouts are valid eager inputs,
|
| 125 |
+
# but the Triton kernel indexes a dense NCHW allocation.
|
| 126 |
+
return eager_concat()
|
| 127 |
+
output = torch.empty(
|
| 128 |
+
(batch, 8 * channels, height, width),
|
| 129 |
+
dtype=branches[0].dtype,
|
| 130 |
+
device=branches[0].device,
|
| 131 |
+
)
|
| 132 |
+
total_elements = output.numel()
|
| 133 |
+
block_size = 4096
|
| 134 |
+
_aspp_concat_kernel[(triton.cdiv(total_elements, block_size),)](
|
| 135 |
+
*branches,
|
| 136 |
+
pooled,
|
| 137 |
+
output,
|
| 138 |
+
total_elements,
|
| 139 |
+
channels=channels,
|
| 140 |
+
spatial_size=height * width,
|
| 141 |
+
BLOCK_SIZE=block_size,
|
| 142 |
+
num_warps=8,
|
| 143 |
+
)
|
| 144 |
+
return output
|
| 145 |
|
| 146 |
|
| 147 |
def _grow(rate, power):
|
|
|
|
| 221 |
nn.ReLU(inplace=True),
|
| 222 |
nn.Dropout(p=dropout),
|
| 223 |
)
|
| 224 |
+
self._fused_concat = (
|
| 225 |
+
os.environ.get("NEMOTRON_OCR_FUSED_ASPP_CONCAT", "0") == "1"
|
| 226 |
+
)
|
| 227 |
|
| 228 |
def forward(self, x):
|
| 229 |
"""The module forward function.
|
|
|
|
| 236 |
"""
|
| 237 |
outs = [kernel(x) for kernel in self.kernels]
|
| 238 |
|
| 239 |
+
global_pool = self.global_pool(x)
|
| 240 |
+
if self._fused_concat:
|
| 241 |
+
concatenated = _aspp_concat(outs, global_pool)
|
| 242 |
+
else:
|
| 243 |
+
outs.append(global_pool.expand(-1, -1, *x.shape[2:]))
|
| 244 |
+
concatenated = torch.cat(outs, dim=1)
|
| 245 |
|
| 246 |
+
fused_norm_relu_add = getattr(self, "_fused_norm_relu_add", None)
|
| 247 |
+
if fused_norm_relu_add is not None:
|
| 248 |
+
out = self.final[0](concatenated)
|
| 249 |
+
if x.shape == out.shape:
|
| 250 |
+
out = fused_norm_relu_add(out, x)
|
| 251 |
+
return self.final[3](out)
|
| 252 |
+
# This model constructs residual ASPP modules, but retain a safe
|
| 253 |
+
# fallback if a future checkpoint changes the channel count.
|
| 254 |
+
out = self.final[3](F.relu(fused_norm_relu_add.batch_norm(out)))
|
| 255 |
+
else:
|
| 256 |
+
out = self.final(concatenated)
|
| 257 |
|
| 258 |
if x.shape == out.shape:
|
| 259 |
return x + out
|
nemotron-ocr/src/nemotron_ocr/inference/models/detector/fast_batch_norm.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
"""Optional inference-only fused BatchNorm detector kernels.
|
| 5 |
+
|
| 6 |
+
Fusion is a serve-time transformation: load the original checkpoint first,
|
| 7 |
+
put the detector in eval mode, and only then call the helpers in this module.
|
| 8 |
+
The wrappers intentionally change module structure and therefore do not accept
|
| 9 |
+
the original checkpoint key layout after fusion.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
import torch.nn as nn
|
| 16 |
+
import torch.nn.functional as F
|
| 17 |
+
import triton
|
| 18 |
+
import triton.language as tl
|
| 19 |
+
from triton.language.extra import libdevice
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
_VALID_NUM_WARPS = (1, 2, 4, 8)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _batch_norm_launch_parameters() -> tuple[int, int]:
|
| 26 |
+
"""Read and validate the launch configuration shared by every wrapper."""
|
| 27 |
+
try:
|
| 28 |
+
block_size = int(
|
| 29 |
+
os.environ.get("NEMOTRON_OCR_FUSED_BATCH_NORM_BLOCK_SIZE", "2048")
|
| 30 |
+
)
|
| 31 |
+
num_warps = int(os.environ.get("NEMOTRON_OCR_FUSED_BATCH_NORM_NUM_WARPS", "8"))
|
| 32 |
+
except ValueError as exc:
|
| 33 |
+
raise ValueError("fused BatchNorm launch parameters must be integers") from exc
|
| 34 |
+
if block_size <= 0 or block_size & (block_size - 1):
|
| 35 |
+
raise ValueError("fused BatchNorm block size must be a power of two")
|
| 36 |
+
if num_warps not in _VALID_NUM_WARPS:
|
| 37 |
+
raise ValueError(f"fused BatchNorm num warps must be one of {_VALID_NUM_WARPS}")
|
| 38 |
+
return block_size, num_warps
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _is_standard_eval_batch_norm(module: nn.Module) -> bool:
|
| 42 |
+
"""Return whether a BatchNorm has every tensor required by the kernels."""
|
| 43 |
+
return (
|
| 44 |
+
isinstance(module, nn.BatchNorm2d)
|
| 45 |
+
and not module.training
|
| 46 |
+
and module.affine
|
| 47 |
+
and module.track_running_stats
|
| 48 |
+
and module.weight is not None
|
| 49 |
+
and module.bias is not None
|
| 50 |
+
and module.running_mean is not None
|
| 51 |
+
and module.running_var is not None
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _require_standard_eval_batch_norm(batch_norm: nn.Module) -> nn.BatchNorm2d:
|
| 56 |
+
if not _is_standard_eval_batch_norm(batch_norm):
|
| 57 |
+
raise ValueError(
|
| 58 |
+
"fused BatchNorm requires an affine, running-stat-tracked "
|
| 59 |
+
"BatchNorm2d already in eval mode"
|
| 60 |
+
)
|
| 61 |
+
return batch_norm
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class _FusedBatchNormModule(nn.Module):
|
| 65 |
+
"""Refresh nonpersistent derived buffers whenever a wrapper returns to eval."""
|
| 66 |
+
|
| 67 |
+
def _inverse_std_sources(self) -> tuple[tuple[str, nn.BatchNorm2d], ...]:
|
| 68 |
+
return ()
|
| 69 |
+
|
| 70 |
+
def _register_inverse_std(self, name: str, batch_norm: nn.BatchNorm2d) -> None:
|
| 71 |
+
self.register_buffer(
|
| 72 |
+
name,
|
| 73 |
+
torch.rsqrt(batch_norm.running_var + batch_norm.eps),
|
| 74 |
+
persistent=False,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
@torch.no_grad()
|
| 78 |
+
def _refresh_inverse_std(self) -> None:
|
| 79 |
+
for name, batch_norm in self._inverse_std_sources():
|
| 80 |
+
refreshed = torch.rsqrt(batch_norm.running_var + batch_norm.eps)
|
| 81 |
+
current = getattr(self, name)
|
| 82 |
+
if (
|
| 83 |
+
current.shape == refreshed.shape
|
| 84 |
+
and current.dtype == refreshed.dtype
|
| 85 |
+
and current.device == refreshed.device
|
| 86 |
+
):
|
| 87 |
+
current.copy_(refreshed)
|
| 88 |
+
else:
|
| 89 |
+
setattr(self, name, refreshed)
|
| 90 |
+
|
| 91 |
+
def train(self, mode: bool = True):
|
| 92 |
+
super().train(mode)
|
| 93 |
+
if not mode:
|
| 94 |
+
self._refresh_inverse_std()
|
| 95 |
+
return self
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@triton.jit
|
| 99 |
+
def _batch_norm_relu_kernel(
|
| 100 |
+
input_ptr,
|
| 101 |
+
mean_ptr,
|
| 102 |
+
inverse_std_ptr,
|
| 103 |
+
weight_ptr,
|
| 104 |
+
bias_ptr,
|
| 105 |
+
output_ptr,
|
| 106 |
+
spatial_size: tl.constexpr,
|
| 107 |
+
channels: tl.constexpr,
|
| 108 |
+
BLOCK_SIZE: tl.constexpr,
|
| 109 |
+
):
|
| 110 |
+
spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
| 111 |
+
batch_channel = tl.program_id(1)
|
| 112 |
+
channel = batch_channel % channels
|
| 113 |
+
offsets = batch_channel * spatial_size + spatial_offsets
|
| 114 |
+
valid = spatial_offsets < spatial_size
|
| 115 |
+
values = tl.load(input_ptr + offsets, mask=valid).to(tl.float32)
|
| 116 |
+
# These are scalar values shared by the whole spatial tile. A block mask
|
| 117 |
+
# is invalid for a scalar pointer in recent Triton releases and would also
|
| 118 |
+
# imply redundant vector loads.
|
| 119 |
+
mean = tl.load(mean_ptr + channel)
|
| 120 |
+
inverse_std = tl.load(inverse_std_ptr + channel)
|
| 121 |
+
weight = tl.load(weight_ptr + channel)
|
| 122 |
+
bias = tl.load(bias_ptr + channel)
|
| 123 |
+
# Match cuDNN's fp32 inference arithmetic exactly: apply the affine weight
|
| 124 |
+
# to the centered value first, then use a round-to-nearest fused
|
| 125 |
+
# multiply-add for inverse standard deviation and bias. Reassociating
|
| 126 |
+
# these products changes rare fp16 rounding boundaries and compounds
|
| 127 |
+
# through the detector.
|
| 128 |
+
normalized = libdevice.fma_rn((values - mean) * weight, inverse_std, bias)
|
| 129 |
+
rounded = normalized.to(tl.float16)
|
| 130 |
+
tl.store(output_ptr + offsets, tl.maximum(rounded, 0.0), mask=valid)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@triton.jit
|
| 134 |
+
def _batch_norm_add_relu_kernel(
|
| 135 |
+
main_ptr,
|
| 136 |
+
residual_ptr,
|
| 137 |
+
mean_ptr,
|
| 138 |
+
inverse_std_ptr,
|
| 139 |
+
weight_ptr,
|
| 140 |
+
bias_ptr,
|
| 141 |
+
output_ptr,
|
| 142 |
+
spatial_size: tl.constexpr,
|
| 143 |
+
channels: tl.constexpr,
|
| 144 |
+
BLOCK_SIZE: tl.constexpr,
|
| 145 |
+
):
|
| 146 |
+
spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
| 147 |
+
batch_channel = tl.program_id(1)
|
| 148 |
+
channel = batch_channel % channels
|
| 149 |
+
offsets = batch_channel * spatial_size + spatial_offsets
|
| 150 |
+
valid = spatial_offsets < spatial_size
|
| 151 |
+
|
| 152 |
+
main = tl.load(main_ptr + offsets, mask=valid).to(tl.float32)
|
| 153 |
+
residual = tl.load(residual_ptr + offsets, mask=valid).to(tl.float32)
|
| 154 |
+
mean = tl.load(mean_ptr + channel)
|
| 155 |
+
inverse_std = tl.load(inverse_std_ptr + channel)
|
| 156 |
+
weight = tl.load(weight_ptr + channel)
|
| 157 |
+
bias = tl.load(bias_ptr + channel)
|
| 158 |
+
|
| 159 |
+
normalized = libdevice.fma_rn((main - mean) * weight, inverse_std, bias).to(
|
| 160 |
+
tl.float16
|
| 161 |
+
)
|
| 162 |
+
# The eager graph rounds BatchNorm to fp16, then performs an fp16
|
| 163 |
+
# residual add before ReLU. Preserve both rounding boundaries.
|
| 164 |
+
summed = (normalized.to(tl.float32) + residual).to(tl.float16)
|
| 165 |
+
tl.store(output_ptr + offsets, tl.maximum(summed, 0.0), mask=valid)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@triton.jit
|
| 169 |
+
def _batch_norm_relu_add_kernel(
|
| 170 |
+
input_ptr,
|
| 171 |
+
residual_ptr,
|
| 172 |
+
mean_ptr,
|
| 173 |
+
inverse_std_ptr,
|
| 174 |
+
weight_ptr,
|
| 175 |
+
bias_ptr,
|
| 176 |
+
output_ptr,
|
| 177 |
+
spatial_size: tl.constexpr,
|
| 178 |
+
channels: tl.constexpr,
|
| 179 |
+
BLOCK_SIZE: tl.constexpr,
|
| 180 |
+
):
|
| 181 |
+
spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
| 182 |
+
batch_channel = tl.program_id(1)
|
| 183 |
+
channel = batch_channel % channels
|
| 184 |
+
offsets = batch_channel * spatial_size + spatial_offsets
|
| 185 |
+
valid = spatial_offsets < spatial_size
|
| 186 |
+
values = tl.load(input_ptr + offsets, mask=valid).to(tl.float32)
|
| 187 |
+
residual = tl.load(residual_ptr + offsets, mask=valid).to(tl.float32)
|
| 188 |
+
normalized = libdevice.fma_rn(
|
| 189 |
+
(values - tl.load(mean_ptr + channel)) * tl.load(weight_ptr + channel),
|
| 190 |
+
tl.load(inverse_std_ptr + channel),
|
| 191 |
+
tl.load(bias_ptr + channel),
|
| 192 |
+
).to(tl.float16)
|
| 193 |
+
activated = tl.maximum(normalized, 0.0)
|
| 194 |
+
# ASPP applies its residual add after the fp16 ReLU.
|
| 195 |
+
summed = (activated.to(tl.float32) + residual).to(tl.float16)
|
| 196 |
+
tl.store(output_ptr + offsets, summed, mask=valid)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@triton.jit
|
| 200 |
+
def _dual_batch_norm_add_relu_kernel(
|
| 201 |
+
main_ptr,
|
| 202 |
+
residual_ptr,
|
| 203 |
+
main_mean_ptr,
|
| 204 |
+
main_inverse_std_ptr,
|
| 205 |
+
main_weight_ptr,
|
| 206 |
+
main_bias_ptr,
|
| 207 |
+
residual_mean_ptr,
|
| 208 |
+
residual_inverse_std_ptr,
|
| 209 |
+
residual_weight_ptr,
|
| 210 |
+
residual_bias_ptr,
|
| 211 |
+
output_ptr,
|
| 212 |
+
spatial_size: tl.constexpr,
|
| 213 |
+
channels: tl.constexpr,
|
| 214 |
+
BLOCK_SIZE: tl.constexpr,
|
| 215 |
+
):
|
| 216 |
+
spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
| 217 |
+
batch_channel = tl.program_id(1)
|
| 218 |
+
channel = batch_channel % channels
|
| 219 |
+
offsets = batch_channel * spatial_size + spatial_offsets
|
| 220 |
+
valid = spatial_offsets < spatial_size
|
| 221 |
+
|
| 222 |
+
main = tl.load(main_ptr + offsets, mask=valid).to(tl.float32)
|
| 223 |
+
residual = tl.load(residual_ptr + offsets, mask=valid).to(tl.float32)
|
| 224 |
+
|
| 225 |
+
main_normalized = libdevice.fma_rn(
|
| 226 |
+
(main - tl.load(main_mean_ptr + channel)) * tl.load(main_weight_ptr + channel),
|
| 227 |
+
tl.load(main_inverse_std_ptr + channel),
|
| 228 |
+
tl.load(main_bias_ptr + channel),
|
| 229 |
+
).to(tl.float16)
|
| 230 |
+
residual_normalized = libdevice.fma_rn(
|
| 231 |
+
(residual - tl.load(residual_mean_ptr + channel))
|
| 232 |
+
* tl.load(residual_weight_ptr + channel),
|
| 233 |
+
tl.load(residual_inverse_std_ptr + channel),
|
| 234 |
+
tl.load(residual_bias_ptr + channel),
|
| 235 |
+
).to(tl.float16)
|
| 236 |
+
|
| 237 |
+
summed = (main_normalized.to(tl.float32) + residual_normalized.to(tl.float32)).to(
|
| 238 |
+
tl.float16
|
| 239 |
+
)
|
| 240 |
+
tl.store(output_ptr + offsets, tl.maximum(summed, 0.0), mask=valid)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class FusedBatchNormReLU(_FusedBatchNormModule):
|
| 244 |
+
"""Evaluate an existing BatchNorm2d and ReLU in one Triton pass."""
|
| 245 |
+
|
| 246 |
+
def __init__(self, batch_norm: nn.BatchNorm2d):
|
| 247 |
+
super().__init__()
|
| 248 |
+
self.batch_norm = _require_standard_eval_batch_norm(batch_norm)
|
| 249 |
+
self._register_inverse_std("inverse_std", self.batch_norm)
|
| 250 |
+
self.block_size, self.num_warps = _batch_norm_launch_parameters()
|
| 251 |
+
# Replacement happens after the detector has been put in eval mode.
|
| 252 |
+
# New ``nn.Module`` instances otherwise default to training mode and
|
| 253 |
+
# silently route every inference call through the fallback below.
|
| 254 |
+
self.train(self.batch_norm.training)
|
| 255 |
+
|
| 256 |
+
def _inverse_std_sources(self):
|
| 257 |
+
return (("inverse_std", self.batch_norm),)
|
| 258 |
+
|
| 259 |
+
def forward(self, input_tensor):
|
| 260 |
+
if (
|
| 261 |
+
not input_tensor.is_cuda
|
| 262 |
+
or input_tensor.dtype != torch.float16
|
| 263 |
+
or not input_tensor.is_contiguous()
|
| 264 |
+
or self.batch_norm.training
|
| 265 |
+
):
|
| 266 |
+
return F.relu(self.batch_norm(input_tensor), inplace=True)
|
| 267 |
+
|
| 268 |
+
_, channels, height, width = input_tensor.shape
|
| 269 |
+
output = torch.empty_like(input_tensor)
|
| 270 |
+
grid = (
|
| 271 |
+
triton.cdiv(height * width, self.block_size),
|
| 272 |
+
input_tensor.numel() // (height * width),
|
| 273 |
+
)
|
| 274 |
+
_batch_norm_relu_kernel[grid](
|
| 275 |
+
input_tensor,
|
| 276 |
+
self.batch_norm.running_mean,
|
| 277 |
+
self.inverse_std,
|
| 278 |
+
self.batch_norm.weight,
|
| 279 |
+
self.batch_norm.bias,
|
| 280 |
+
output,
|
| 281 |
+
spatial_size=height * width,
|
| 282 |
+
channels=channels,
|
| 283 |
+
BLOCK_SIZE=self.block_size,
|
| 284 |
+
num_warps=self.num_warps,
|
| 285 |
+
)
|
| 286 |
+
return output
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
class FusedBatchNormReLUAdd(_FusedBatchNormModule):
|
| 290 |
+
"""Fuse BatchNorm, ReLU, then a residual add in exact eager order."""
|
| 291 |
+
|
| 292 |
+
def __init__(self, batch_norm: nn.BatchNorm2d):
|
| 293 |
+
super().__init__()
|
| 294 |
+
self.batch_norm = _require_standard_eval_batch_norm(batch_norm)
|
| 295 |
+
self._register_inverse_std("inverse_std", self.batch_norm)
|
| 296 |
+
self.block_size, self.num_warps = _batch_norm_launch_parameters()
|
| 297 |
+
self.train(self.batch_norm.training)
|
| 298 |
+
|
| 299 |
+
def _inverse_std_sources(self):
|
| 300 |
+
return (("inverse_std", self.batch_norm),)
|
| 301 |
+
|
| 302 |
+
def forward(self, input_tensor, residual):
|
| 303 |
+
if (
|
| 304 |
+
not input_tensor.is_cuda
|
| 305 |
+
or not residual.is_cuda
|
| 306 |
+
or input_tensor.dtype != torch.float16
|
| 307 |
+
or residual.dtype != torch.float16
|
| 308 |
+
or not input_tensor.is_contiguous()
|
| 309 |
+
or not residual.is_contiguous()
|
| 310 |
+
or input_tensor.shape != residual.shape
|
| 311 |
+
or self.batch_norm.training
|
| 312 |
+
):
|
| 313 |
+
return residual + F.relu(self.batch_norm(input_tensor), inplace=True)
|
| 314 |
+
|
| 315 |
+
_, channels, height, width = input_tensor.shape
|
| 316 |
+
output = torch.empty_like(input_tensor)
|
| 317 |
+
grid = (
|
| 318 |
+
triton.cdiv(height * width, self.block_size),
|
| 319 |
+
input_tensor.numel() // (height * width),
|
| 320 |
+
)
|
| 321 |
+
_batch_norm_relu_add_kernel[grid](
|
| 322 |
+
input_tensor,
|
| 323 |
+
residual,
|
| 324 |
+
self.batch_norm.running_mean,
|
| 325 |
+
self.inverse_std,
|
| 326 |
+
self.batch_norm.weight,
|
| 327 |
+
self.batch_norm.bias,
|
| 328 |
+
output,
|
| 329 |
+
spatial_size=height * width,
|
| 330 |
+
channels=channels,
|
| 331 |
+
BLOCK_SIZE=self.block_size,
|
| 332 |
+
num_warps=self.num_warps,
|
| 333 |
+
)
|
| 334 |
+
return output
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
class FusedResidualBlock(_FusedBatchNormModule):
|
| 338 |
+
"""Fuse final BatchNorm(s), residual add, and ReLU in a RegNet block."""
|
| 339 |
+
|
| 340 |
+
def __init__(self, block: nn.Module):
|
| 341 |
+
super().__init__()
|
| 342 |
+
if block.training:
|
| 343 |
+
raise ValueError("fused residual blocks require eval mode")
|
| 344 |
+
main_batch_norm = _require_standard_eval_batch_norm(block.f.c[-1])
|
| 345 |
+
projection_batch_norm = None
|
| 346 |
+
if block.proj is not None:
|
| 347 |
+
projection_batch_norm = _require_standard_eval_batch_norm(block.proj[-1])
|
| 348 |
+
block_size, num_warps = _batch_norm_launch_parameters()
|
| 349 |
+
|
| 350 |
+
self.transform = block.f
|
| 351 |
+
self.projection = block.proj
|
| 352 |
+
self.main_batch_norm = main_batch_norm
|
| 353 |
+
self.transform.c[-1] = nn.Identity()
|
| 354 |
+
self._register_inverse_std("main_inverse_std", self.main_batch_norm)
|
| 355 |
+
|
| 356 |
+
self.projection_batch_norm = projection_batch_norm
|
| 357 |
+
if self.projection is not None:
|
| 358 |
+
self.projection[-1] = nn.Identity()
|
| 359 |
+
self._register_inverse_std(
|
| 360 |
+
"projection_inverse_std",
|
| 361 |
+
self.projection_batch_norm,
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
self.block_size, self.num_warps = block_size, num_warps
|
| 365 |
+
self.train(block.training)
|
| 366 |
+
|
| 367 |
+
def _inverse_std_sources(self):
|
| 368 |
+
sources = [("main_inverse_std", self.main_batch_norm)]
|
| 369 |
+
if self.projection_batch_norm is not None:
|
| 370 |
+
sources.append(("projection_inverse_std", self.projection_batch_norm))
|
| 371 |
+
return tuple(sources)
|
| 372 |
+
|
| 373 |
+
def _can_fuse(self, main, residual):
|
| 374 |
+
return (
|
| 375 |
+
main.is_cuda
|
| 376 |
+
and residual.is_cuda
|
| 377 |
+
and main.dtype == torch.float16
|
| 378 |
+
and residual.dtype == torch.float16
|
| 379 |
+
and main.is_contiguous()
|
| 380 |
+
and residual.is_contiguous()
|
| 381 |
+
and main.shape == residual.shape
|
| 382 |
+
and not self.main_batch_norm.training
|
| 383 |
+
and (
|
| 384 |
+
self.projection_batch_norm is None
|
| 385 |
+
or not self.projection_batch_norm.training
|
| 386 |
+
)
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
def forward(self, input_tensor):
|
| 390 |
+
if self.projection is None:
|
| 391 |
+
residual = input_tensor
|
| 392 |
+
main = self.transform(input_tensor)
|
| 393 |
+
else:
|
| 394 |
+
# Preserve the original block's left-to-right evaluation order.
|
| 395 |
+
residual = self.projection(input_tensor)
|
| 396 |
+
main = self.transform(input_tensor)
|
| 397 |
+
|
| 398 |
+
if not self._can_fuse(main, residual):
|
| 399 |
+
main = self.main_batch_norm(main)
|
| 400 |
+
if self.projection_batch_norm is not None:
|
| 401 |
+
residual = self.projection_batch_norm(residual)
|
| 402 |
+
return F.relu(main + residual, inplace=True)
|
| 403 |
+
|
| 404 |
+
_, channels, height, width = main.shape
|
| 405 |
+
output = torch.empty_like(main)
|
| 406 |
+
grid = (
|
| 407 |
+
triton.cdiv(height * width, self.block_size),
|
| 408 |
+
main.numel() // (height * width),
|
| 409 |
+
)
|
| 410 |
+
if self.projection_batch_norm is None:
|
| 411 |
+
_batch_norm_add_relu_kernel[grid](
|
| 412 |
+
main,
|
| 413 |
+
residual,
|
| 414 |
+
self.main_batch_norm.running_mean,
|
| 415 |
+
self.main_inverse_std,
|
| 416 |
+
self.main_batch_norm.weight,
|
| 417 |
+
self.main_batch_norm.bias,
|
| 418 |
+
output,
|
| 419 |
+
spatial_size=height * width,
|
| 420 |
+
channels=channels,
|
| 421 |
+
BLOCK_SIZE=self.block_size,
|
| 422 |
+
num_warps=self.num_warps,
|
| 423 |
+
)
|
| 424 |
+
else:
|
| 425 |
+
_dual_batch_norm_add_relu_kernel[grid](
|
| 426 |
+
main,
|
| 427 |
+
residual,
|
| 428 |
+
self.main_batch_norm.running_mean,
|
| 429 |
+
self.main_inverse_std,
|
| 430 |
+
self.main_batch_norm.weight,
|
| 431 |
+
self.main_batch_norm.bias,
|
| 432 |
+
self.projection_batch_norm.running_mean,
|
| 433 |
+
self.projection_inverse_std,
|
| 434 |
+
self.projection_batch_norm.weight,
|
| 435 |
+
self.projection_batch_norm.bias,
|
| 436 |
+
output,
|
| 437 |
+
spatial_size=height * width,
|
| 438 |
+
channels=channels,
|
| 439 |
+
BLOCK_SIZE=self.block_size,
|
| 440 |
+
num_warps=self.num_warps,
|
| 441 |
+
)
|
| 442 |
+
return output
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def fuse_batch_norm_relu(module: nn.Module) -> int:
|
| 446 |
+
"""Replace eligible BatchNorm2d + ReLU pairs after checkpoint loading."""
|
| 447 |
+
replacements = 0
|
| 448 |
+
for child in list(module.children()):
|
| 449 |
+
replacements += fuse_batch_norm_relu(child)
|
| 450 |
+
if not isinstance(module, nn.Sequential):
|
| 451 |
+
return replacements
|
| 452 |
+
|
| 453 |
+
names = list(module._modules)
|
| 454 |
+
for batch_norm_name, relu_name in zip(names, names[1:]):
|
| 455 |
+
batch_norm = module._modules[batch_norm_name]
|
| 456 |
+
relu = module._modules[relu_name]
|
| 457 |
+
if _is_standard_eval_batch_norm(batch_norm) and isinstance(relu, nn.ReLU):
|
| 458 |
+
module._modules[batch_norm_name] = FusedBatchNormReLU(batch_norm)
|
| 459 |
+
module._modules[relu_name] = nn.Identity()
|
| 460 |
+
replacements += 1
|
| 461 |
+
return replacements
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def fuse_residual_batch_norm_add_relu(module: nn.Module) -> int:
|
| 465 |
+
"""Replace eligible eval RegNet blocks after checkpoint loading."""
|
| 466 |
+
from nemotron_ocr.inference.models.detector.regnet import (
|
| 467 |
+
ResBottleneckBlock,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
replacements = 0
|
| 471 |
+
for name, child in list(module.named_children()):
|
| 472 |
+
if isinstance(child, ResBottleneckBlock) and not child.training:
|
| 473 |
+
main_batch_norm = child.f.c[-1]
|
| 474 |
+
projection_batch_norm = child.proj[-1] if child.proj is not None else None
|
| 475 |
+
eligible = _is_standard_eval_batch_norm(main_batch_norm) and (
|
| 476 |
+
projection_batch_norm is None
|
| 477 |
+
or _is_standard_eval_batch_norm(projection_batch_norm)
|
| 478 |
+
)
|
| 479 |
+
else:
|
| 480 |
+
eligible = False
|
| 481 |
+
if eligible:
|
| 482 |
+
module._modules[name] = FusedResidualBlock(child)
|
| 483 |
+
replacements += 1
|
| 484 |
+
else:
|
| 485 |
+
replacements += fuse_residual_batch_norm_add_relu(child)
|
| 486 |
+
return replacements
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def fuse_aspp_batch_norm_relu_add(module: nn.Module) -> int:
|
| 490 |
+
"""Fuse safe eval-only residual ASPP paths after checkpoint loading."""
|
| 491 |
+
from nemotron_ocr.inference.models.detector.aspp import ASPP
|
| 492 |
+
|
| 493 |
+
replacements = 0
|
| 494 |
+
for child in module.modules():
|
| 495 |
+
if not isinstance(child, ASPP):
|
| 496 |
+
continue
|
| 497 |
+
batch_norm = child.final[1]
|
| 498 |
+
relu = child.final[2]
|
| 499 |
+
dropout = child.final[3]
|
| 500 |
+
residual_channels_match = (
|
| 501 |
+
child.final[0].out_channels == child.kernels[0][0].in_channels
|
| 502 |
+
)
|
| 503 |
+
if (
|
| 504 |
+
child.training
|
| 505 |
+
or not _is_standard_eval_batch_norm(batch_norm)
|
| 506 |
+
or not isinstance(relu, nn.ReLU)
|
| 507 |
+
or not isinstance(dropout, nn.Dropout)
|
| 508 |
+
or dropout.p != 0
|
| 509 |
+
or not residual_channels_match
|
| 510 |
+
):
|
| 511 |
+
continue
|
| 512 |
+
child._fused_norm_relu_add = FusedBatchNormReLUAdd(batch_norm)
|
| 513 |
+
child.final[1] = nn.Identity()
|
| 514 |
+
child.final[2] = nn.Identity()
|
| 515 |
+
replacements += 1
|
| 516 |
+
return replacements
|
nemotron-ocr/src/nemotron_ocr/inference/models/detector/fots_detector.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
|
| 4 |
import logging
|
|
|
|
| 5 |
from typing import List, Optional, Tuple
|
| 6 |
|
| 7 |
import torch
|
|
@@ -9,6 +10,15 @@ import torch.nn as nn
|
|
| 9 |
import torch.nn.functional as F
|
| 10 |
import math
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
from nemotron_ocr.inference.models.detector.aspp import ASPP
|
| 14 |
from nemotron_ocr.inference.models.detector import regnet
|
|
@@ -16,6 +26,156 @@ from nemotron_ocr.inference.models.detector import regnet
|
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
def get_prior_offsets(output_shape, downsample):
|
| 20 |
"""
|
| 21 |
Returns the locations of the priors in normalized image space.
|
|
@@ -131,6 +291,9 @@ class merge(nn.Module):
|
|
| 131 |
)
|
| 132 |
|
| 133 |
self.num_features = num_features
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
for m in self.modules():
|
| 136 |
if isinstance(m, nn.Conv2d):
|
|
@@ -149,9 +312,12 @@ class merge(nn.Module):
|
|
| 149 |
y = x[0]
|
| 150 |
for i in range(len(x) - 1):
|
| 151 |
y = self.pre_upsamples[i](y)
|
| 152 |
-
y = self.interpolate(y)
|
| 153 |
side = self.pre_sides[i](x[i + 1])
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
y = self.post_upsamples[i](y)
|
| 156 |
feats.append(y)
|
| 157 |
|
|
@@ -243,7 +409,9 @@ class output(nn.Module):
|
|
| 243 |
|
| 244 |
def forward(
|
| 245 |
self, feats: List[torch.Tensor]
|
| 246 |
-
) -> Tuple[
|
|
|
|
|
|
|
| 247 |
x = feats[-1]
|
| 248 |
|
| 249 |
preds = self.preds(x)
|
|
@@ -276,28 +444,41 @@ class output(nn.Module):
|
|
| 276 |
|
| 277 |
class FOTSDetector(nn.Module):
|
| 278 |
def __init__(
|
| 279 |
-
self,
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
):
|
| 282 |
super().__init__()
|
| 283 |
|
| 284 |
self.extractor = extractor(backbone, **kwargs)
|
| 285 |
self.merge = merge(self.extractor.depths)
|
| 286 |
self.num_features = self.merge.num_features
|
| 287 |
-
self.output = output(
|
|
|
|
|
|
|
| 288 |
self.verbose = verbose
|
| 289 |
self.inference_mode = False
|
| 290 |
self.scope = scope # Store for reference
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
self.downsample = self.extractor.downsample
|
| 293 |
|
| 294 |
self.register_buffer(
|
| 295 |
"input_mean",
|
| 296 |
-
torch.tensor([0.485, 0.456, 0.406], dtype=torch.float16).reshape(
|
|
|
|
|
|
|
| 297 |
)
|
| 298 |
self.register_buffer(
|
| 299 |
"input_std",
|
| 300 |
-
torch.tensor([0.229, 0.224, 0.225], dtype=torch.float16).reshape(
|
|
|
|
|
|
|
| 301 |
)
|
| 302 |
|
| 303 |
def set_current_and_total_steps(self, current_step, total_steps):
|
|
@@ -312,7 +493,10 @@ class FOTSDetector(nn.Module):
|
|
| 312 |
List[torch.Tensor],
|
| 313 |
List[torch.Tensor],
|
| 314 |
]:
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
| 316 |
feats = self.extractor(x)
|
| 317 |
|
| 318 |
mg = self.merge(feats)
|
|
|
|
| 2 |
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
|
| 4 |
import logging
|
| 5 |
+
import os
|
| 6 |
from typing import List, Optional, Tuple
|
| 7 |
|
| 8 |
import torch
|
|
|
|
| 10 |
import torch.nn.functional as F
|
| 11 |
import math
|
| 12 |
|
| 13 |
+
try:
|
| 14 |
+
import triton
|
| 15 |
+
import triton.language as tl
|
| 16 |
+
from triton.language.extra import libdevice
|
| 17 |
+
except ImportError:
|
| 18 |
+
triton = None
|
| 19 |
+
tl = None
|
| 20 |
+
libdevice = None
|
| 21 |
+
|
| 22 |
|
| 23 |
from nemotron_ocr.inference.models.detector.aspp import ASPP
|
| 24 |
from nemotron_ocr.inference.models.detector import regnet
|
|
|
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
|
| 29 |
+
if triton is not None:
|
| 30 |
+
|
| 31 |
+
@triton.jit
|
| 32 |
+
def _normalize_detector_input_kernel(
|
| 33 |
+
input_ptr,
|
| 34 |
+
mean_ptr,
|
| 35 |
+
std_ptr,
|
| 36 |
+
output_ptr,
|
| 37 |
+
total_elements,
|
| 38 |
+
spatial_size: tl.constexpr,
|
| 39 |
+
channels: tl.constexpr,
|
| 40 |
+
BLOCK_SIZE: tl.constexpr,
|
| 41 |
+
):
|
| 42 |
+
offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
| 43 |
+
valid = offsets < total_elements
|
| 44 |
+
channel = (offsets // spatial_size) % channels
|
| 45 |
+
values = tl.load(input_ptr + offsets, mask=valid).to(tl.float32)
|
| 46 |
+
mean = tl.load(mean_ptr + channel, mask=valid).to(tl.float32)
|
| 47 |
+
std = tl.load(std_ptr + channel, mask=valid).to(tl.float32)
|
| 48 |
+
# Eager autocast performs fp16 subtraction followed by fp16 division.
|
| 49 |
+
# Preserve the intermediate rounding boundary while eliminating one
|
| 50 |
+
# full activation read/write pass.
|
| 51 |
+
centered = (values - mean).to(tl.float16)
|
| 52 |
+
normalized = libdevice.div_rn(centered.to(tl.float32), std).to(tl.float16)
|
| 53 |
+
tl.store(output_ptr + offsets, normalized, mask=valid)
|
| 54 |
+
|
| 55 |
+
@triton.jit
|
| 56 |
+
def _upsample_concat_nearest_kernel(
|
| 57 |
+
input_ptr,
|
| 58 |
+
side_ptr,
|
| 59 |
+
output_ptr,
|
| 60 |
+
total_elements,
|
| 61 |
+
input_channels: tl.constexpr,
|
| 62 |
+
side_channels: tl.constexpr,
|
| 63 |
+
output_height: tl.constexpr,
|
| 64 |
+
output_width: tl.constexpr,
|
| 65 |
+
BLOCK_SIZE: tl.constexpr,
|
| 66 |
+
):
|
| 67 |
+
offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
| 68 |
+
valid = offsets < total_elements
|
| 69 |
+
output_x = offsets % output_width
|
| 70 |
+
quotient = offsets // output_width
|
| 71 |
+
output_y = quotient % output_height
|
| 72 |
+
quotient = quotient // output_height
|
| 73 |
+
output_channel = quotient % (input_channels + side_channels)
|
| 74 |
+
batch = quotient // (input_channels + side_channels)
|
| 75 |
+
|
| 76 |
+
input_offsets = (
|
| 77 |
+
(batch * input_channels + output_channel) * (output_height // 2)
|
| 78 |
+
+ output_y // 2
|
| 79 |
+
) * (output_width // 2) + output_x // 2
|
| 80 |
+
side_channel = output_channel - input_channels
|
| 81 |
+
side_offsets = (
|
| 82 |
+
(batch * side_channels + side_channel) * output_height + output_y
|
| 83 |
+
) * output_width + output_x
|
| 84 |
+
|
| 85 |
+
from_input = output_channel < input_channels
|
| 86 |
+
values = tl.load(
|
| 87 |
+
input_ptr + input_offsets,
|
| 88 |
+
mask=valid & from_input,
|
| 89 |
+
other=0.0,
|
| 90 |
+
)
|
| 91 |
+
side_values = tl.load(
|
| 92 |
+
side_ptr + side_offsets,
|
| 93 |
+
mask=valid & ~from_input,
|
| 94 |
+
other=0.0,
|
| 95 |
+
)
|
| 96 |
+
tl.store(
|
| 97 |
+
output_ptr + offsets,
|
| 98 |
+
tl.where(from_input, values, side_values),
|
| 99 |
+
mask=valid,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _upsample_concat_nearest(input_tensor, side_tensor):
|
| 104 |
+
"""Fuse exact 2x nearest upsampling with channel concatenation."""
|
| 105 |
+
|
| 106 |
+
def eager_upsample_concat():
|
| 107 |
+
return torch.cat(
|
| 108 |
+
(F.interpolate(input_tensor, scale_factor=2, mode="nearest"), side_tensor),
|
| 109 |
+
dim=1,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if triton is None or not input_tensor.is_cuda:
|
| 113 |
+
return eager_upsample_concat()
|
| 114 |
+
|
| 115 |
+
batch, input_channels, input_height, input_width = input_tensor.shape
|
| 116 |
+
side_batch, side_channels, output_height, output_width = side_tensor.shape
|
| 117 |
+
if (
|
| 118 |
+
batch != side_batch
|
| 119 |
+
or output_height != input_height * 2
|
| 120 |
+
or output_width != input_width * 2
|
| 121 |
+
):
|
| 122 |
+
raise ValueError("fused upsample-concat requires an exact 2x side tensor")
|
| 123 |
+
if (
|
| 124 |
+
not input_tensor.is_contiguous()
|
| 125 |
+
or not side_tensor.is_contiguous()
|
| 126 |
+
or input_tensor.dtype != side_tensor.dtype
|
| 127 |
+
or input_tensor.device != side_tensor.device
|
| 128 |
+
):
|
| 129 |
+
# Channels-last and other non-standard layouts remain correct through
|
| 130 |
+
# the eager implementation; the Triton kernel assumes dense NCHW.
|
| 131 |
+
return eager_upsample_concat()
|
| 132 |
+
|
| 133 |
+
output = torch.empty(
|
| 134 |
+
(batch, input_channels + side_channels, output_height, output_width),
|
| 135 |
+
dtype=input_tensor.dtype,
|
| 136 |
+
device=input_tensor.device,
|
| 137 |
+
)
|
| 138 |
+
total_elements = output.numel()
|
| 139 |
+
block_size = 4096
|
| 140 |
+
_upsample_concat_nearest_kernel[(triton.cdiv(total_elements, block_size),)](
|
| 141 |
+
input_tensor,
|
| 142 |
+
side_tensor,
|
| 143 |
+
output,
|
| 144 |
+
total_elements,
|
| 145 |
+
input_channels=input_channels,
|
| 146 |
+
side_channels=side_channels,
|
| 147 |
+
output_height=output_height,
|
| 148 |
+
output_width=output_width,
|
| 149 |
+
BLOCK_SIZE=block_size,
|
| 150 |
+
num_warps=8,
|
| 151 |
+
)
|
| 152 |
+
return output
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _normalize_detector_input(input_tensor, mean, std):
|
| 156 |
+
"""Fuse the detector's exact fp16 channel normalization."""
|
| 157 |
+
if triton is None or not input_tensor.is_cuda:
|
| 158 |
+
return (input_tensor - mean) / std
|
| 159 |
+
if input_tensor.dtype != torch.float16 or not input_tensor.is_contiguous():
|
| 160 |
+
return (input_tensor - mean) / std
|
| 161 |
+
|
| 162 |
+
output = torch.empty_like(input_tensor)
|
| 163 |
+
spatial_size = input_tensor.shape[-2] * input_tensor.shape[-1]
|
| 164 |
+
block_size = 1024
|
| 165 |
+
_normalize_detector_input_kernel[(triton.cdiv(input_tensor.numel(), block_size),)](
|
| 166 |
+
input_tensor,
|
| 167 |
+
mean,
|
| 168 |
+
std,
|
| 169 |
+
output,
|
| 170 |
+
input_tensor.numel(),
|
| 171 |
+
spatial_size=spatial_size,
|
| 172 |
+
channels=input_tensor.shape[1],
|
| 173 |
+
BLOCK_SIZE=block_size,
|
| 174 |
+
num_warps=4,
|
| 175 |
+
)
|
| 176 |
+
return output
|
| 177 |
+
|
| 178 |
+
|
| 179 |
def get_prior_offsets(output_shape, downsample):
|
| 180 |
"""
|
| 181 |
Returns the locations of the priors in normalized image space.
|
|
|
|
| 291 |
)
|
| 292 |
|
| 293 |
self.num_features = num_features
|
| 294 |
+
self._fused_upsample_concat = (
|
| 295 |
+
os.environ.get("NEMOTRON_OCR_FUSED_UPSAMPLE_CONCAT", "0") == "1"
|
| 296 |
+
)
|
| 297 |
|
| 298 |
for m in self.modules():
|
| 299 |
if isinstance(m, nn.Conv2d):
|
|
|
|
| 312 |
y = x[0]
|
| 313 |
for i in range(len(x) - 1):
|
| 314 |
y = self.pre_upsamples[i](y)
|
|
|
|
| 315 |
side = self.pre_sides[i](x[i + 1])
|
| 316 |
+
if self._fused_upsample_concat:
|
| 317 |
+
y = _upsample_concat_nearest(y, side)
|
| 318 |
+
else:
|
| 319 |
+
y = self.interpolate(y)
|
| 320 |
+
y = torch.cat((y, side), 1)
|
| 321 |
y = self.post_upsamples[i](y)
|
| 322 |
feats.append(y)
|
| 323 |
|
|
|
|
| 409 |
|
| 410 |
def forward(
|
| 411 |
self, feats: List[torch.Tensor]
|
| 412 |
+
) -> Tuple[
|
| 413 |
+
torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor], List[torch.Tensor]
|
| 414 |
+
]:
|
| 415 |
x = feats[-1]
|
| 416 |
|
| 417 |
preds = self.preds(x)
|
|
|
|
| 444 |
|
| 445 |
class FOTSDetector(nn.Module):
|
| 446 |
def __init__(
|
| 447 |
+
self,
|
| 448 |
+
verbose=True,
|
| 449 |
+
coordinate_mode: str = "RBOX",
|
| 450 |
+
backbone: str = "regnet_y_8gf",
|
| 451 |
+
scope: int = 512,
|
| 452 |
+
**kwargs,
|
| 453 |
):
|
| 454 |
super().__init__()
|
| 455 |
|
| 456 |
self.extractor = extractor(backbone, **kwargs)
|
| 457 |
self.merge = merge(self.extractor.depths)
|
| 458 |
self.num_features = self.merge.num_features
|
| 459 |
+
self.output = output(
|
| 460 |
+
self.num_features, self.extractor.downsample, coordinate_mode, scope=scope
|
| 461 |
+
)
|
| 462 |
self.verbose = verbose
|
| 463 |
self.inference_mode = False
|
| 464 |
self.scope = scope # Store for reference
|
| 465 |
+
self._fused_input_normalization = (
|
| 466 |
+
os.environ.get("NEMOTRON_OCR_FUSED_INPUT_NORMALIZATION", "0") == "1"
|
| 467 |
+
)
|
| 468 |
|
| 469 |
self.downsample = self.extractor.downsample
|
| 470 |
|
| 471 |
self.register_buffer(
|
| 472 |
"input_mean",
|
| 473 |
+
torch.tensor([0.485, 0.456, 0.406], dtype=torch.float16).reshape(
|
| 474 |
+
1, -1, 1, 1
|
| 475 |
+
),
|
| 476 |
)
|
| 477 |
self.register_buffer(
|
| 478 |
"input_std",
|
| 479 |
+
torch.tensor([0.229, 0.224, 0.225], dtype=torch.float16).reshape(
|
| 480 |
+
1, -1, 1, 1
|
| 481 |
+
),
|
| 482 |
)
|
| 483 |
|
| 484 |
def set_current_and_total_steps(self, current_step, total_steps):
|
|
|
|
| 493 |
List[torch.Tensor],
|
| 494 |
List[torch.Tensor],
|
| 495 |
]:
|
| 496 |
+
if self._fused_input_normalization:
|
| 497 |
+
x = _normalize_detector_input(x, self.input_mean, self.input_std)
|
| 498 |
+
else:
|
| 499 |
+
x = (x - self.input_mean) / self.input_std
|
| 500 |
feats = self.extractor(x)
|
| 501 |
|
| 502 |
mg = self.merge(feats)
|
nemotron-ocr/src/nemotron_ocr/inference/models/relational.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
|
| 4 |
import logging
|
| 5 |
import math
|
|
|
|
| 6 |
|
| 7 |
import torch
|
| 8 |
import torch.nn as nn
|
|
@@ -23,17 +24,29 @@ NULL_CONNECTION_WEIGHT = -math.inf
|
|
| 23 |
DEFAULT_CHUNK = 128
|
| 24 |
|
| 25 |
|
| 26 |
-
def _pad_flat_to_batched(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""Reshape flat [N_total, ...] tensors into padded [B, k_max, ...] batches."""
|
| 28 |
batch_size = int(region_counts.shape[0])
|
| 29 |
rest = flat.shape[1:]
|
| 30 |
out = flat.new_full((batch_size, k_max, *rest), pad_value)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return out
|
| 38 |
|
| 39 |
|
|
@@ -65,6 +78,7 @@ class GlobalRelationalModel(nn.Module):
|
|
| 65 |
dropout=0.1,
|
| 66 |
num_layers=4,
|
| 67 |
chunk_size=DEFAULT_CHUNK,
|
|
|
|
| 68 |
):
|
| 69 |
super().__init__()
|
| 70 |
|
|
@@ -74,6 +88,9 @@ class GlobalRelationalModel(nn.Module):
|
|
| 74 |
self.total_steps = 1
|
| 75 |
self.k = k
|
| 76 |
self.chunk_size = chunk_size
|
|
|
|
|
|
|
|
|
|
| 77 |
self.quad_rectify_grid_size = (2, 3)
|
| 78 |
self.quad_downscale = 1024.0
|
| 79 |
self.inference_mode = False
|
|
@@ -129,12 +146,27 @@ class GlobalRelationalModel(nn.Module):
|
|
| 129 |
xc = x[start:end]
|
| 130 |
ec = [e[start:end] for e in extra]
|
| 131 |
if real_n < cs:
|
| 132 |
-
xc = torch.cat(
|
|
|
|
|
|
|
| 133 |
for i, e in enumerate(ec):
|
| 134 |
if pad_extra_ones and e.dtype == torch.bool:
|
| 135 |
-
ec[i] = torch.cat(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
else:
|
| 137 |
-
ec[i] = torch.cat(
|
|
|
|
|
|
|
| 138 |
parts.append(fn(xc, *ec)[:real_n])
|
| 139 |
return torch.cat(parts, dim=0)
|
| 140 |
|
|
@@ -168,19 +200,25 @@ class GlobalRelationalModel(nn.Module):
|
|
| 168 |
to_rects = torch.gather(
|
| 169 |
to_rects,
|
| 170 |
dim=1,
|
| 171 |
-
index=closest_other_idxs.unsqueeze(2).expand(
|
|
|
|
|
|
|
| 172 |
)
|
| 173 |
# K,K-1
|
| 174 |
all_dists = torch.gather(all_dists, dim=1, index=closest_other_idxs)
|
| 175 |
# K,K-1,2
|
| 176 |
to_centers = torch.gather(
|
| 177 |
-
to_centers,
|
|
|
|
|
|
|
| 178 |
)
|
| 179 |
|
| 180 |
# Add the null column to rects
|
| 181 |
to_rects = torch.cat(
|
| 182 |
(
|
| 183 |
-
torch.zeros(
|
|
|
|
|
|
|
| 184 |
to_rects,
|
| 185 |
),
|
| 186 |
dim=1,
|
|
@@ -217,7 +255,9 @@ class GlobalRelationalModel(nn.Module):
|
|
| 217 |
# Add the null column
|
| 218 |
closest_other_idxs = torch.cat(
|
| 219 |
[
|
| 220 |
-
torch.zeros(
|
|
|
|
|
|
|
| 221 |
closest_other_idxs + 1,
|
| 222 |
],
|
| 223 |
dim=1,
|
|
@@ -225,11 +265,15 @@ class GlobalRelationalModel(nn.Module):
|
|
| 225 |
|
| 226 |
return to_rects, closest_other_idxs
|
| 227 |
|
| 228 |
-
def prohibit_self_connection(
|
|
|
|
|
|
|
| 229 |
dots = dots.float()
|
| 230 |
|
| 231 |
if closest_other_idxs is None:
|
| 232 |
-
neg_inf = torch.full(
|
|
|
|
|
|
|
| 233 |
|
| 234 |
neg_inf = torch.cat(
|
| 235 |
(torch.zeros(neg_inf.shape[0], 1, **options(neg_inf)), neg_inf), dim=1
|
|
@@ -264,7 +308,9 @@ class GlobalRelationalModel(nn.Module):
|
|
| 264 |
recog_features: torch.Tensor,
|
| 265 |
):
|
| 266 |
cs_rg = torch.cumsum(region_counts, 0)
|
| 267 |
-
cs_rg = torch.cat(
|
|
|
|
|
|
|
| 268 |
ex_offsets = cs_rg
|
| 269 |
|
| 270 |
g_height, g_width = self.quad_rectify_grid_size
|
|
@@ -274,7 +320,9 @@ class GlobalRelationalModel(nn.Module):
|
|
| 274 |
original_quads, rectified_quads.shape[-2], 1, rectified_quads.shape[-1]
|
| 275 |
)
|
| 276 |
else:
|
| 277 |
-
quad_widths = torch.full(
|
|
|
|
|
|
|
| 278 |
num_valid_pos = (quad_widths * g_height).clamp_min(1)
|
| 279 |
|
| 280 |
# Ensure that these values aren't very large
|
|
@@ -288,7 +336,10 @@ class GlobalRelationalModel(nn.Module):
|
|
| 288 |
return self.combined_proj(torch.cat((avg, rec), dim=1))
|
| 289 |
|
| 290 |
semantic_encoding = self._chunked_forward(
|
| 291 |
-
_input_enc_nn,
|
|
|
|
|
|
|
|
|
|
| 292 |
)
|
| 293 |
|
| 294 |
h1 = original_quads[:, 3] - original_quads[:, 0]
|
|
@@ -324,6 +375,111 @@ class GlobalRelationalModel(nn.Module):
|
|
| 324 |
|
| 325 |
return full_encoding, ex_offsets, region_counts, mid_pts
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
def forward(
|
| 328 |
self,
|
| 329 |
rectified_quads: torch.Tensor,
|
|
@@ -334,8 +490,9 @@ class GlobalRelationalModel(nn.Module):
|
|
| 334 |
rectified_quads = rectified_quads.float()
|
| 335 |
recog_features = recog_features.float()
|
| 336 |
|
| 337 |
-
|
| 338 |
-
|
|
|
|
| 339 |
|
| 340 |
proj_rects, _, region_counts, mid_pts = self.get_input_encoding(
|
| 341 |
rectified_quads, original_quads, region_counts, recog_features
|
|
@@ -344,9 +501,15 @@ class GlobalRelationalModel(nn.Module):
|
|
| 344 |
quads = original_quads / self.quad_downscale
|
| 345 |
|
| 346 |
if not self.inference_mode:
|
| 347 |
-
assert torch.all(torch.isfinite(proj_rects)),
|
|
|
|
|
|
|
| 348 |
|
| 349 |
-
counts_list =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
batch_size = len(counts_list)
|
| 351 |
device = proj_rects.device
|
| 352 |
dtype = proj_rects.dtype
|
|
@@ -356,10 +519,17 @@ class GlobalRelationalModel(nn.Module):
|
|
| 356 |
|
| 357 |
if max(counts_list, default=0) == 0:
|
| 358 |
return {
|
| 359 |
-
"words": [
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
"line_log_var_unc": [
|
| 362 |
-
torch.empty(0, 1, dtype=dtype, device=device)
|
|
|
|
| 363 |
],
|
| 364 |
}
|
| 365 |
|
|
@@ -369,61 +539,109 @@ class GlobalRelationalModel(nn.Module):
|
|
| 369 |
offsets.append(offsets[-1] + c)
|
| 370 |
n_total = offsets[-1]
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
# Chunked encoder on flat regions — always sees [chunk_size, seq_len, dim]
|
| 406 |
def _run_encoder(enc, mask):
|
| 407 |
out = self.encoder[0](enc, src_key_padding_mask=mask)
|
| 408 |
return self.encoder[1](out)
|
| 409 |
|
| 410 |
-
dots_flat = self._chunked_forward(
|
|
|
|
|
|
|
| 411 |
|
| 412 |
# Per-image: scatter encoder output into full relation matrices
|
| 413 |
all_dots = dict(words=[], lines=[], line_log_var_unc=[])
|
| 414 |
for i, n_i in enumerate(counts_list):
|
| 415 |
if n_i == 0:
|
| 416 |
-
all_dots["words"].append(
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
continue
|
| 420 |
s, e = offsets[i], offsets[i + 1]
|
| 421 |
dots_i = dots_flat[s:e].unsqueeze(0).permute(0, 3, 1, 2)
|
| 422 |
cidx_i = closest_flat[s:e].unsqueeze(0)
|
| 423 |
dots_i = self.prohibit_self_connection(dots_i, cidx_i)
|
| 424 |
-
all_dots["words"].append(dots_i[0, 0, :n_i, :n_i + 1])
|
| 425 |
-
all_dots["lines"].append(dots_i[0, 1, :n_i, :n_i + 1])
|
| 426 |
-
all_dots["line_log_var_unc"].append(dots_i[0, 2, :n_i, :n_i + 1])
|
| 427 |
|
| 428 |
return {
|
| 429 |
"words": all_dots["words"],
|
|
@@ -433,9 +651,14 @@ class GlobalRelationalModel(nn.Module):
|
|
| 433 |
|
| 434 |
|
| 435 |
def get_cdist(
|
| 436 |
-
quads: torch.Tensor,
|
|
|
|
|
|
|
|
|
|
| 437 |
):
|
| 438 |
-
region_counts = torch.tensor(
|
|
|
|
|
|
|
| 439 |
|
| 440 |
ret = ragged_quad_all_2_all_distance_v2(
|
| 441 |
quads.unsqueeze(0), region_counts, x_factor, y_factor, allow_self_distance=False
|
|
@@ -445,7 +668,10 @@ def get_cdist(
|
|
| 445 |
|
| 446 |
|
| 447 |
def get_cdist_batched(
|
| 448 |
-
quads: torch.Tensor,
|
|
|
|
|
|
|
|
|
|
| 449 |
):
|
| 450 |
return ragged_quad_all_2_all_distance_v2(
|
| 451 |
quads.contiguous(),
|
|
|
|
| 3 |
|
| 4 |
import logging
|
| 5 |
import math
|
| 6 |
+
import os
|
| 7 |
|
| 8 |
import torch
|
| 9 |
import torch.nn as nn
|
|
|
|
| 24 |
DEFAULT_CHUNK = 128
|
| 25 |
|
| 26 |
|
| 27 |
+
def _pad_flat_to_batched(
|
| 28 |
+
flat: torch.Tensor,
|
| 29 |
+
region_counts: torch.Tensor,
|
| 30 |
+
k_max: int,
|
| 31 |
+
pad_value: float = 0.0,
|
| 32 |
+
):
|
| 33 |
"""Reshape flat [N_total, ...] tensors into padded [B, k_max, ...] batches."""
|
| 34 |
batch_size = int(region_counts.shape[0])
|
| 35 |
rest = flat.shape[1:]
|
| 36 |
out = flat.new_full((batch_size, k_max, *rest), pad_value)
|
| 37 |
+
|
| 38 |
+
if flat.shape[0] == 0:
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
counts = region_counts.to(device=flat.device, dtype=torch.long)
|
| 42 |
+
offsets = torch.cumsum(counts, dim=0) - counts
|
| 43 |
+
batch_indices = torch.repeat_interleave(
|
| 44 |
+
torch.arange(batch_size, device=flat.device),
|
| 45 |
+
counts,
|
| 46 |
+
output_size=flat.shape[0],
|
| 47 |
+
)
|
| 48 |
+
positions = torch.arange(flat.shape[0], device=flat.device) - offsets[batch_indices]
|
| 49 |
+
out[batch_indices, positions] = flat
|
| 50 |
return out
|
| 51 |
|
| 52 |
|
|
|
|
| 78 |
dropout=0.1,
|
| 79 |
num_layers=4,
|
| 80 |
chunk_size=DEFAULT_CHUNK,
|
| 81 |
+
batched_geometry=False,
|
| 82 |
):
|
| 83 |
super().__init__()
|
| 84 |
|
|
|
|
| 88 |
self.total_steps = 1
|
| 89 |
self.k = k
|
| 90 |
self.chunk_size = chunk_size
|
| 91 |
+
self.batched_geometry = batched_geometry or os.environ.get(
|
| 92 |
+
"NEMOTRON_OCR_BATCHED_RELATIONAL_GEOMETRY", "0"
|
| 93 |
+
).strip().lower() in {"1", "true", "yes", "on"}
|
| 94 |
self.quad_rectify_grid_size = (2, 3)
|
| 95 |
self.quad_downscale = 1024.0
|
| 96 |
self.inference_mode = False
|
|
|
|
| 146 |
xc = x[start:end]
|
| 147 |
ec = [e[start:end] for e in extra]
|
| 148 |
if real_n < cs:
|
| 149 |
+
xc = torch.cat(
|
| 150 |
+
(xc, xc[:1].expand(cs - real_n, *[-1] * (xc.ndim - 1))), dim=0
|
| 151 |
+
)
|
| 152 |
for i, e in enumerate(ec):
|
| 153 |
if pad_extra_ones and e.dtype == torch.bool:
|
| 154 |
+
ec[i] = torch.cat(
|
| 155 |
+
(
|
| 156 |
+
e,
|
| 157 |
+
torch.ones(
|
| 158 |
+
cs - real_n,
|
| 159 |
+
*e.shape[1:],
|
| 160 |
+
dtype=torch.bool,
|
| 161 |
+
device=e.device,
|
| 162 |
+
),
|
| 163 |
+
),
|
| 164 |
+
dim=0,
|
| 165 |
+
)
|
| 166 |
else:
|
| 167 |
+
ec[i] = torch.cat(
|
| 168 |
+
(e, e[:1].expand(cs - real_n, *[-1] * (e.ndim - 1))), dim=0
|
| 169 |
+
)
|
| 170 |
parts.append(fn(xc, *ec)[:real_n])
|
| 171 |
return torch.cat(parts, dim=0)
|
| 172 |
|
|
|
|
| 200 |
to_rects = torch.gather(
|
| 201 |
to_rects,
|
| 202 |
dim=1,
|
| 203 |
+
index=closest_other_idxs.unsqueeze(2).expand(
|
| 204 |
+
-1, -1, curr_rects.shape[1]
|
| 205 |
+
),
|
| 206 |
)
|
| 207 |
# K,K-1
|
| 208 |
all_dists = torch.gather(all_dists, dim=1, index=closest_other_idxs)
|
| 209 |
# K,K-1,2
|
| 210 |
to_centers = torch.gather(
|
| 211 |
+
to_centers,
|
| 212 |
+
dim=1,
|
| 213 |
+
index=closest_other_idxs.unsqueeze(2).expand(-1, -1, 2),
|
| 214 |
)
|
| 215 |
|
| 216 |
# Add the null column to rects
|
| 217 |
to_rects = torch.cat(
|
| 218 |
(
|
| 219 |
+
torch.zeros(
|
| 220 |
+
to_rects.shape[0], 1, to_rects.shape[2], **options(to_rects)
|
| 221 |
+
),
|
| 222 |
to_rects,
|
| 223 |
),
|
| 224 |
dim=1,
|
|
|
|
| 255 |
# Add the null column
|
| 256 |
closest_other_idxs = torch.cat(
|
| 257 |
[
|
| 258 |
+
torch.zeros(
|
| 259 |
+
closest_other_idxs.shape[0], 1, **options(closest_other_idxs)
|
| 260 |
+
),
|
| 261 |
closest_other_idxs + 1,
|
| 262 |
],
|
| 263 |
dim=1,
|
|
|
|
| 265 |
|
| 266 |
return to_rects, closest_other_idxs
|
| 267 |
|
| 268 |
+
def prohibit_self_connection(
|
| 269 |
+
self, dots: torch.Tensor, closest_other_idxs: torch.Tensor = None
|
| 270 |
+
):
|
| 271 |
dots = dots.float()
|
| 272 |
|
| 273 |
if closest_other_idxs is None:
|
| 274 |
+
neg_inf = torch.full(
|
| 275 |
+
(dots.shape[-2],), NULL_CONNECTION_WEIGHT, **options(dots)
|
| 276 |
+
).diag()
|
| 277 |
|
| 278 |
neg_inf = torch.cat(
|
| 279 |
(torch.zeros(neg_inf.shape[0], 1, **options(neg_inf)), neg_inf), dim=1
|
|
|
|
| 308 |
recog_features: torch.Tensor,
|
| 309 |
):
|
| 310 |
cs_rg = torch.cumsum(region_counts, 0)
|
| 311 |
+
cs_rg = torch.cat(
|
| 312 |
+
[torch.zeros(1, dtype=cs_rg.dtype, device=cs_rg.device), cs_rg]
|
| 313 |
+
)
|
| 314 |
ex_offsets = cs_rg
|
| 315 |
|
| 316 |
g_height, g_width = self.quad_rectify_grid_size
|
|
|
|
| 320 |
original_quads, rectified_quads.shape[-2], 1, rectified_quads.shape[-1]
|
| 321 |
)
|
| 322 |
else:
|
| 323 |
+
quad_widths = torch.full(
|
| 324 |
+
(original_quads.shape[0],), g_width, **options(original_quads)
|
| 325 |
+
)
|
| 326 |
num_valid_pos = (quad_widths * g_height).clamp_min(1)
|
| 327 |
|
| 328 |
# Ensure that these values aren't very large
|
|
|
|
| 336 |
return self.combined_proj(torch.cat((avg, rec), dim=1))
|
| 337 |
|
| 338 |
semantic_encoding = self._chunked_forward(
|
| 339 |
+
_input_enc_nn,
|
| 340 |
+
rectified_quads,
|
| 341 |
+
recog_features,
|
| 342 |
+
num_valid_pos,
|
| 343 |
)
|
| 344 |
|
| 345 |
h1 = original_quads[:, 3] - original_quads[:, 0]
|
|
|
|
| 375 |
|
| 376 |
return full_encoding, ex_offsets, region_counts, mid_pts
|
| 377 |
|
| 378 |
+
def _build_batched_geometry_inputs(
|
| 379 |
+
self,
|
| 380 |
+
proj_rects: torch.Tensor,
|
| 381 |
+
mid_pts: torch.Tensor,
|
| 382 |
+
quads: torch.Tensor,
|
| 383 |
+
region_counts: torch.Tensor,
|
| 384 |
+
counts_list: list[int],
|
| 385 |
+
):
|
| 386 |
+
"""Build relational inputs with one ragged distance launch.
|
| 387 |
+
|
| 388 |
+
Images are grouped by their exact region count for ``topk``. This keeps
|
| 389 |
+
the last-dimension width and ``k`` identical to the legacy per-image
|
| 390 |
+
calls, including the ordering produced by ``sorted=False``. CPU region
|
| 391 |
+
counts are required so choosing the padded extent never synchronizes a
|
| 392 |
+
CUDA scalar back to the host.
|
| 393 |
+
"""
|
| 394 |
+
device = proj_rects.device
|
| 395 |
+
dtype = proj_rects.dtype
|
| 396 |
+
feat_dim = proj_rects.shape[1]
|
| 397 |
+
z = self.k - 1
|
| 398 |
+
seq_len = z + 1
|
| 399 |
+
n_total = proj_rects.shape[0]
|
| 400 |
+
k_max = max(counts_list, default=0)
|
| 401 |
+
|
| 402 |
+
enc_input_flat = torch.zeros(
|
| 403 |
+
n_total,
|
| 404 |
+
seq_len,
|
| 405 |
+
2 * feat_dim + 2,
|
| 406 |
+
dtype=dtype,
|
| 407 |
+
device=device,
|
| 408 |
+
)
|
| 409 |
+
mask_flat = torch.ones(n_total, seq_len, dtype=torch.bool, device=device)
|
| 410 |
+
closest_flat = torch.zeros(n_total, seq_len, dtype=torch.long, device=device)
|
| 411 |
+
|
| 412 |
+
enc_input_flat[:, 0, :feat_dim] = proj_rects
|
| 413 |
+
enc_input_flat[:, 0, 2 * feat_dim] = -1
|
| 414 |
+
enc_input_flat[:, 0, 2 * feat_dim + 1] = -2
|
| 415 |
+
mask_flat[:, 0] = False
|
| 416 |
+
|
| 417 |
+
if n_total == 0 or z == 0:
|
| 418 |
+
return enc_input_flat, mask_flat, closest_flat
|
| 419 |
+
|
| 420 |
+
counts_device = region_counts.to(device=device, dtype=torch.long)
|
| 421 |
+
starts = torch.cumsum(counts_device, dim=0) - counts_device
|
| 422 |
+
padded_quads = _pad_flat_to_batched(quads, counts_device, k_max)
|
| 423 |
+
padded_rects = _pad_flat_to_batched(proj_rects, counts_device, k_max)
|
| 424 |
+
padded_centers = _pad_flat_to_batched(mid_pts, counts_device, k_max)
|
| 425 |
+
all_dists = get_cdist_batched(padded_quads, counts_device)
|
| 426 |
+
|
| 427 |
+
count_groups = {}
|
| 428 |
+
for image_index, count in enumerate(counts_list):
|
| 429 |
+
if count > 1:
|
| 430 |
+
count_groups.setdefault(count, []).append(image_index)
|
| 431 |
+
|
| 432 |
+
for count, image_indices in count_groups.items():
|
| 433 |
+
z_i = min(count - 1, z)
|
| 434 |
+
group_indices = torch.tensor(image_indices, dtype=torch.long, device=device)
|
| 435 |
+
group_rows = starts[group_indices, None] + torch.arange(
|
| 436 |
+
count, device=device
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
dists = all_dists.index_select(0, group_indices)[:, :count, :count]
|
| 440 |
+
topk_d, topk_idx = torch.topk(
|
| 441 |
+
dists,
|
| 442 |
+
k=z_i,
|
| 443 |
+
dim=2,
|
| 444 |
+
largest=False,
|
| 445 |
+
sorted=False,
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
rects = padded_rects.index_select(0, group_indices)[:, :count]
|
| 449 |
+
centers = padded_centers.index_select(0, group_indices)[:, :count]
|
| 450 |
+
group_quads = padded_quads.index_select(0, group_indices)[:, :count]
|
| 451 |
+
|
| 452 |
+
neighbor_rects = torch.gather(
|
| 453 |
+
rects.unsqueeze(1).expand(-1, count, -1, -1),
|
| 454 |
+
dim=2,
|
| 455 |
+
index=topk_idx.unsqueeze(3).expand(-1, -1, -1, feat_dim),
|
| 456 |
+
)
|
| 457 |
+
neighbor_centers = torch.gather(
|
| 458 |
+
centers.unsqueeze(1).expand(-1, count, -1, -1),
|
| 459 |
+
dim=2,
|
| 460 |
+
index=topk_idx.unsqueeze(3).expand(-1, -1, -1, 2),
|
| 461 |
+
)
|
| 462 |
+
directions = (
|
| 463 |
+
get_directions(
|
| 464 |
+
group_quads.reshape(-1, 4, 2),
|
| 465 |
+
neighbor_centers.reshape(-1, z_i, 2),
|
| 466 |
+
)
|
| 467 |
+
.reshape(-1, count, z_i)
|
| 468 |
+
.to(dtype=dtype)
|
| 469 |
+
)
|
| 470 |
+
from_rects = rects.unsqueeze(2).expand(-1, -1, z_i, -1)
|
| 471 |
+
|
| 472 |
+
enc_input_flat[group_rows, 1 : z_i + 1, :feat_dim] = from_rects
|
| 473 |
+
enc_input_flat[group_rows, 1 : z_i + 1, feat_dim : 2 * feat_dim] = (
|
| 474 |
+
neighbor_rects
|
| 475 |
+
)
|
| 476 |
+
enc_input_flat[group_rows, 1 : z_i + 1, 2 * feat_dim] = topk_d
|
| 477 |
+
enc_input_flat[group_rows, 1 : z_i + 1, 2 * feat_dim + 1] = directions
|
| 478 |
+
mask_flat[group_rows, 1 : z_i + 1] = False
|
| 479 |
+
closest_flat[group_rows, 1 : z_i + 1] = topk_idx + 1
|
| 480 |
+
|
| 481 |
+
return enc_input_flat, mask_flat, closest_flat
|
| 482 |
+
|
| 483 |
def forward(
|
| 484 |
self,
|
| 485 |
rectified_quads: torch.Tensor,
|
|
|
|
| 490 |
rectified_quads = rectified_quads.float()
|
| 491 |
recog_features = recog_features.float()
|
| 492 |
|
| 493 |
+
if not self.inference_mode:
|
| 494 |
+
assert torch.all(torch.isfinite(rectified_quads))
|
| 495 |
+
assert torch.all(torch.isfinite(recog_features))
|
| 496 |
|
| 497 |
proj_rects, _, region_counts, mid_pts = self.get_input_encoding(
|
| 498 |
rectified_quads, original_quads, region_counts, recog_features
|
|
|
|
| 501 |
quads = original_quads / self.quad_downscale
|
| 502 |
|
| 503 |
if not self.inference_mode:
|
| 504 |
+
assert torch.all(torch.isfinite(proj_rects)), (
|
| 505 |
+
"Not all proj_rects were finite!"
|
| 506 |
+
)
|
| 507 |
|
| 508 |
+
counts_list = (
|
| 509 |
+
region_counts.tolist()
|
| 510 |
+
if region_counts.dim() > 0
|
| 511 |
+
else [int(region_counts.item())]
|
| 512 |
+
)
|
| 513 |
batch_size = len(counts_list)
|
| 514 |
device = proj_rects.device
|
| 515 |
dtype = proj_rects.dtype
|
|
|
|
| 519 |
|
| 520 |
if max(counts_list, default=0) == 0:
|
| 521 |
return {
|
| 522 |
+
"words": [
|
| 523 |
+
torch.empty(0, 1, dtype=dtype, device=device)
|
| 524 |
+
for _ in range(batch_size)
|
| 525 |
+
],
|
| 526 |
+
"lines": [
|
| 527 |
+
torch.empty(0, 1, dtype=dtype, device=device)
|
| 528 |
+
for _ in range(batch_size)
|
| 529 |
+
],
|
| 530 |
"line_log_var_unc": [
|
| 531 |
+
torch.empty(0, 1, dtype=dtype, device=device)
|
| 532 |
+
for _ in range(batch_size)
|
| 533 |
],
|
| 534 |
}
|
| 535 |
|
|
|
|
| 539 |
offsets.append(offsets[-1] + c)
|
| 540 |
n_total = offsets[-1]
|
| 541 |
|
| 542 |
+
use_batched_geometry = (
|
| 543 |
+
self.batched_geometry
|
| 544 |
+
and region_counts.device.type == "cpu"
|
| 545 |
+
and region_counts.dim() == 1
|
| 546 |
+
and all(count >= 0 for count in counts_list)
|
| 547 |
+
and sum(counts_list) == n_total
|
| 548 |
+
)
|
| 549 |
+
if use_batched_geometry:
|
| 550 |
+
enc_input_flat, mask_flat, closest_flat = (
|
| 551 |
+
self._build_batched_geometry_inputs(
|
| 552 |
+
proj_rects,
|
| 553 |
+
mid_pts,
|
| 554 |
+
quads,
|
| 555 |
+
region_counts,
|
| 556 |
+
counts_list,
|
| 557 |
+
)
|
| 558 |
+
)
|
| 559 |
+
else:
|
| 560 |
+
enc_input_flat = torch.zeros(
|
| 561 |
+
n_total,
|
| 562 |
+
seq_len,
|
| 563 |
+
2 * feat_dim + 2,
|
| 564 |
+
dtype=dtype,
|
| 565 |
+
device=device,
|
| 566 |
+
)
|
| 567 |
+
mask_flat = torch.ones(n_total, seq_len, dtype=torch.bool, device=device)
|
| 568 |
+
closest_flat = torch.zeros(
|
| 569 |
+
n_total, seq_len, dtype=torch.long, device=device
|
| 570 |
+
)
|
| 571 |
|
| 572 |
+
for i, n_i in enumerate(counts_list):
|
| 573 |
+
if n_i == 0:
|
| 574 |
+
continue
|
| 575 |
+
s, e = offsets[i], offsets[i + 1]
|
| 576 |
+
rects_i = proj_rects[s:e]
|
| 577 |
+
centers_i = mid_pts[s:e]
|
| 578 |
+
quads_i = quads[s:e]
|
| 579 |
+
z_i = min(n_i - 1, z)
|
| 580 |
+
|
| 581 |
+
from_r = rects_i.unsqueeze(1).expand(-1, seq_len, -1)
|
| 582 |
+
enc_input_flat[s:e, 0, :feat_dim] = rects_i
|
| 583 |
+
enc_input_flat[s:e, 0, 2 * feat_dim] = -1
|
| 584 |
+
enc_input_flat[s:e, 0, 2 * feat_dim + 1] = -2
|
| 585 |
+
mask_flat[s:e, 0] = False
|
| 586 |
+
|
| 587 |
+
if z_i > 0:
|
| 588 |
+
dists_i = get_cdist(quads_i, centers_i)
|
| 589 |
+
topk_d, topk_idx = torch.topk(
|
| 590 |
+
dists_i,
|
| 591 |
+
k=z_i,
|
| 592 |
+
dim=1,
|
| 593 |
+
largest=False,
|
| 594 |
+
sorted=False,
|
| 595 |
+
)
|
| 596 |
+
nb_r = torch.gather(
|
| 597 |
+
rects_i.unsqueeze(0).expand(n_i, -1, -1),
|
| 598 |
+
1,
|
| 599 |
+
topk_idx.unsqueeze(2).expand(-1, -1, feat_dim),
|
| 600 |
+
)
|
| 601 |
+
nb_c = torch.gather(
|
| 602 |
+
centers_i.unsqueeze(0).expand(n_i, -1, -1),
|
| 603 |
+
1,
|
| 604 |
+
topk_idx.unsqueeze(2).expand(-1, -1, 2),
|
| 605 |
+
)
|
| 606 |
+
dirs_i = get_directions(quads_i, nb_c)
|
| 607 |
+
|
| 608 |
+
enc_input_flat[s:e, 1 : z_i + 1, :feat_dim] = from_r[:, 1 : z_i + 1]
|
| 609 |
+
enc_input_flat[s:e, 1 : z_i + 1, feat_dim : 2 * feat_dim] = nb_r
|
| 610 |
+
enc_input_flat[s:e, 1 : z_i + 1, 2 * feat_dim] = topk_d
|
| 611 |
+
enc_input_flat[s:e, 1 : z_i + 1, 2 * feat_dim + 1] = dirs_i
|
| 612 |
+
mask_flat[s:e, 1 : z_i + 1] = False
|
| 613 |
+
closest_flat[s:e, 1 : z_i + 1] = topk_idx + 1
|
| 614 |
|
| 615 |
# Chunked encoder on flat regions — always sees [chunk_size, seq_len, dim]
|
| 616 |
def _run_encoder(enc, mask):
|
| 617 |
out = self.encoder[0](enc, src_key_padding_mask=mask)
|
| 618 |
return self.encoder[1](out)
|
| 619 |
|
| 620 |
+
dots_flat = self._chunked_forward(
|
| 621 |
+
_run_encoder, enc_input_flat, mask_flat, pad_extra_ones=True
|
| 622 |
+
)
|
| 623 |
|
| 624 |
# Per-image: scatter encoder output into full relation matrices
|
| 625 |
all_dots = dict(words=[], lines=[], line_log_var_unc=[])
|
| 626 |
for i, n_i in enumerate(counts_list):
|
| 627 |
if n_i == 0:
|
| 628 |
+
all_dots["words"].append(
|
| 629 |
+
torch.empty(0, 1, dtype=torch.float32, device=device)
|
| 630 |
+
)
|
| 631 |
+
all_dots["lines"].append(
|
| 632 |
+
torch.empty(0, 1, dtype=torch.float32, device=device)
|
| 633 |
+
)
|
| 634 |
+
all_dots["line_log_var_unc"].append(
|
| 635 |
+
torch.empty(0, 1, dtype=torch.float32, device=device)
|
| 636 |
+
)
|
| 637 |
continue
|
| 638 |
s, e = offsets[i], offsets[i + 1]
|
| 639 |
dots_i = dots_flat[s:e].unsqueeze(0).permute(0, 3, 1, 2)
|
| 640 |
cidx_i = closest_flat[s:e].unsqueeze(0)
|
| 641 |
dots_i = self.prohibit_self_connection(dots_i, cidx_i)
|
| 642 |
+
all_dots["words"].append(dots_i[0, 0, :n_i, : n_i + 1])
|
| 643 |
+
all_dots["lines"].append(dots_i[0, 1, :n_i, : n_i + 1])
|
| 644 |
+
all_dots["line_log_var_unc"].append(dots_i[0, 2, :n_i, : n_i + 1])
|
| 645 |
|
| 646 |
return {
|
| 647 |
"words": all_dots["words"],
|
|
|
|
| 651 |
|
| 652 |
|
| 653 |
def get_cdist(
|
| 654 |
+
quads: torch.Tensor,
|
| 655 |
+
centers: torch.Tensor,
|
| 656 |
+
x_factor: float = 1.0,
|
| 657 |
+
y_factor: float = 1.0,
|
| 658 |
):
|
| 659 |
+
region_counts = torch.tensor(
|
| 660 |
+
[quads.shape[0]], dtype=torch.int64, device=quads.device
|
| 661 |
+
)
|
| 662 |
|
| 663 |
ret = ragged_quad_all_2_all_distance_v2(
|
| 664 |
quads.unsqueeze(0), region_counts, x_factor, y_factor, allow_self_distance=False
|
|
|
|
| 668 |
|
| 669 |
|
| 670 |
def get_cdist_batched(
|
| 671 |
+
quads: torch.Tensor,
|
| 672 |
+
region_counts: torch.Tensor,
|
| 673 |
+
x_factor: float = 1.0,
|
| 674 |
+
y_factor: float = 1.0,
|
| 675 |
):
|
| 676 |
return ragged_quad_all_2_all_distance_v2(
|
| 677 |
quads.contiguous(),
|
nemotron-ocr/src/nemotron_ocr/inference/pipeline_v2.py
CHANGED
|
@@ -21,6 +21,13 @@ import torch.nn as nn
|
|
| 21 |
import torch.nn.functional as F
|
| 22 |
from torch import amp
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from nemotron_ocr.inference.pipeline import (
|
| 25 |
NemotronOCR,
|
| 26 |
DETECTOR_DOWNSAMPLE,
|
|
@@ -51,6 +58,57 @@ _DEFAULT_MAX_WIDTH = 32
|
|
| 51 |
_DEFAULT_NUM_TOKENS = 858
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
class NemotronOCRV2(NemotronOCR):
|
| 55 |
"""Batched OCR inference pipeline.
|
| 56 |
|
|
@@ -109,6 +167,62 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 109 |
|
| 110 |
torch.backends.cuda.matmul.allow_tf32 = True
|
| 111 |
torch.backends.cudnn.allow_tf32 = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
# ── pad_color ────────────────────────────────────────────────
|
| 114 |
if pad_color is not None:
|
|
@@ -140,11 +254,27 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 140 |
if hasattr(self, "relational"):
|
| 141 |
self.relational.chunk_size = relational_chunk_size
|
| 142 |
|
| 143 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
self.relation_encoder._verbose = True
|
| 145 |
|
| 146 |
if not self._detector_only and hasattr(self, "recognizer"):
|
| 147 |
self._pad_classifier_for_alignment(64)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
# ------------------------------------------------------------------
|
| 150 |
# Tensor-core alignment
|
|
@@ -175,7 +305,9 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 175 |
self.recognizer.classifier = new_cls
|
| 176 |
logger.info(
|
| 177 |
"Padded recognizer classifier %d -> %d (alignment=%d)",
|
| 178 |
-
real,
|
|
|
|
|
|
|
| 179 |
)
|
| 180 |
|
| 181 |
# ------------------------------------------------------------------
|
|
@@ -257,7 +389,9 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 257 |
del tensor_gpu
|
| 258 |
|
| 259 |
resized = interpolate_and_pad(
|
| 260 |
-
padded.unsqueeze(0),
|
|
|
|
|
|
|
| 261 |
).squeeze(0)
|
| 262 |
del padded
|
| 263 |
resized_list.append(resized)
|
|
@@ -287,14 +421,22 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 287 |
if t.shape[0] == 4:
|
| 288 |
t = t[:3]
|
| 289 |
if t.dtype != torch.uint8:
|
| 290 |
-
t = (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
return t
|
| 292 |
if t.ndim == 3 and t.shape[2] in (1, 3, 4):
|
| 293 |
t = t.permute(2, 0, 1)
|
| 294 |
if t.shape[0] == 4:
|
| 295 |
t = t[:3]
|
| 296 |
if t.dtype != torch.uint8:
|
| 297 |
-
t = (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
return t
|
| 299 |
raise ValueError(f"Unsupported tensor shape: {image.shape}")
|
| 300 |
|
|
@@ -304,19 +446,29 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 304 |
if image.shape[2] == 4:
|
| 305 |
image = image[..., :3]
|
| 306 |
if image.dtype != np.uint8:
|
| 307 |
-
image = (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
return torch.from_numpy(image).permute(2, 0, 1)
|
| 309 |
|
| 310 |
from torchvision.io import read_image, decode_image
|
|
|
|
| 311 |
if isinstance(image, (str, os.PathLike)):
|
| 312 |
return read_image(str(image), mode="RGB")
|
| 313 |
if isinstance(image, bytes):
|
| 314 |
import base64
|
|
|
|
| 315 |
img_bytes = base64.b64decode(image)
|
| 316 |
-
return decode_image(
|
| 317 |
-
|
|
|
|
|
|
|
| 318 |
image.seek(0)
|
| 319 |
-
return decode_image(
|
|
|
|
|
|
|
| 320 |
|
| 321 |
raise TypeError(f"Unsupported input type: {type(image)}")
|
| 322 |
|
|
@@ -339,6 +491,8 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 339 |
for start in range(0, n, self.detector_max_batch_size):
|
| 340 |
end = min(start + self.detector_max_batch_size, n)
|
| 341 |
chunk = resized_batch[start:end]
|
|
|
|
|
|
|
| 342 |
c, _, r, f = self.detector(chunk)
|
| 343 |
conf_parts.append(c)
|
| 344 |
rbox_parts.append(r)
|
|
@@ -363,8 +517,13 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 363 |
to suppress edge pixels, then keeps only local maxima. This prevents
|
| 364 |
the O(n^2) NMS adjacency blowup on images with large text regions
|
| 365 |
while preserving all real detection peaks.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
"""
|
| 367 |
with torch.inference_mode():
|
|
|
|
| 368 |
d_top = det_rboxes[..., 0].float()
|
| 369 |
d_right = det_rboxes[..., 1].float()
|
| 370 |
d_bottom = det_rboxes[..., 2].float()
|
|
@@ -377,31 +536,42 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 377 |
|
| 378 |
centerness = torch.sqrt((lr_min / lr_max) * (tb_min / tb_max))
|
| 379 |
|
| 380 |
-
conf_sigmoid = torch.sigmoid(det_conf.float())
|
| 381 |
adjusted = conf_sigmoid * centerness
|
| 382 |
|
| 383 |
k = self._prefilter_peak_kernel
|
| 384 |
pad = k // 2
|
| 385 |
adj_4d = adjusted.unsqueeze(1)
|
| 386 |
pooled = F.max_pool2d(adj_4d, k, stride=1, padding=pad)
|
| 387 |
-
pooled = pooled[:, 0, :det_conf.shape[1], :det_conf.shape[2]]
|
| 388 |
|
| 389 |
peaks = (adjusted == pooled) & (adjusted > NMS_PROB_THRESHOLD)
|
| 390 |
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
"""Sigmoid + rrect_to_quads + NMS.
|
| 398 |
|
| 399 |
Returns:
|
| 400 |
(quads, confidence, region_counts, e2e_det_conf) or None if
|
| 401 |
zero detections.
|
| 402 |
"""
|
| 403 |
-
|
| 404 |
-
|
|
|
|
| 405 |
|
| 406 |
with amp.autocast("cuda", enabled=True), torch.inference_mode():
|
| 407 |
e2e_det_coords = rrect_to_quads(det_rboxes.float(), DETECTOR_DOWNSAMPLE)
|
|
@@ -443,13 +613,19 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 443 |
input_indices_cuda = input_indices.cuda(non_blocking=True)
|
| 444 |
region_counts_cpu = region_counts.cpu()
|
| 445 |
|
| 446 |
-
|
| 447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
|
| 449 |
rel_quads = None
|
| 450 |
if not self._skip_relational:
|
| 451 |
rel_rectified = self.relational_quad_rectifier(quads_cuda, h, w)
|
| 452 |
-
rel_quads = self.grid_sampler(
|
|
|
|
|
|
|
| 453 |
|
| 454 |
return quads_cuda, rec_quads, rel_quads, region_counts_cpu
|
| 455 |
|
|
@@ -478,7 +654,13 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 478 |
return (
|
| 479 |
torch.empty(0, self.max_width, dtype=torch.int64, device=device),
|
| 480 |
torch.empty(0, self.max_width, dtype=torch.float32, device=device),
|
| 481 |
-
torch.empty(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
)
|
| 483 |
|
| 484 |
cs = self.recognizer_chunk_size
|
|
@@ -489,23 +671,32 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 489 |
chunk = rec_quads[start:end].half()
|
| 490 |
real_n = chunk.shape[0]
|
| 491 |
if real_n < cs:
|
| 492 |
-
pad = torch.zeros(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
chunk = torch.cat([chunk, pad], dim=0)
|
| 494 |
logits, feats = self.recognizer(chunk)
|
| 495 |
|
| 496 |
-
ids =
|
| 497 |
-
probs = torch.softmax(logits, dim=2).gather(
|
| 498 |
-
2, ids.unsqueeze(2)
|
| 499 |
-
).squeeze(2).float()
|
| 500 |
|
| 501 |
ids_parts.append(ids[:real_n])
|
| 502 |
probs_parts.append(probs[:real_n])
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
def _cat_or_single(parts):
|
| 506 |
return parts[0] if len(parts) == 1 else torch.cat(parts, dim=0)
|
| 507 |
|
| 508 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
|
| 510 |
# ------------------------------------------------------------------
|
| 511 |
# Phase 6 — Relational model + build output dict
|
|
@@ -545,10 +736,17 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 545 |
|
| 546 |
# Vectorized quad scaling
|
| 547 |
scale_factors = torch.as_tensor(
|
| 548 |
-
padded_lengths,
|
|
|
|
|
|
|
| 549 |
) / float(self.infer_length)
|
| 550 |
counts_long = region_counts.to(dtype=torch.long, device=quads_cuda.device)
|
| 551 |
-
scale_per_region = torch.repeat_interleave(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
|
| 553 |
|
| 554 |
seq_ids = rec_ids
|
|
@@ -562,11 +760,9 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 562 |
|
| 563 |
output = {
|
| 564 |
"sequences": seq_ids.cpu(),
|
| 565 |
-
"sequence_probs": seq_probs.cpu(),
|
| 566 |
"text_confidence": text_confidence.cpu(),
|
| 567 |
-
"region_counts":
|
| 568 |
"quads": quads_scaled.cpu(),
|
| 569 |
-
"raw_detector_confidence": e2e_det_conf,
|
| 570 |
"confidence": confidence.cpu(),
|
| 571 |
"relations": words,
|
| 572 |
"line_relations": lines,
|
|
@@ -574,7 +770,6 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 574 |
"fg_colors": None,
|
| 575 |
"fonts": None,
|
| 576 |
"tt_log_var_uncertainty": None,
|
| 577 |
-
"e2e_recog_features": rec_features,
|
| 578 |
}
|
| 579 |
|
| 580 |
return output
|
|
@@ -586,24 +781,35 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 586 |
def _decode_with_fallback(self, output):
|
| 587 |
"""Decode recognized sequences using pre-computed argmax indices."""
|
| 588 |
return self.recog_encoder.convert_targets_to_labels(
|
| 589 |
-
output,
|
|
|
|
|
|
|
| 590 |
)
|
| 591 |
|
| 592 |
-
def _postprocess_batch(
|
|
|
|
|
|
|
| 593 |
"""Decode sequences, build relation graphs, format per-image results."""
|
| 594 |
import gc
|
|
|
|
| 595 |
gc_was_enabled = gc.isenabled()
|
| 596 |
gc.disable()
|
| 597 |
|
| 598 |
try:
|
| 599 |
return self._postprocess_batch_inner(
|
| 600 |
-
output,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
)
|
| 602 |
finally:
|
| 603 |
if gc_was_enabled:
|
| 604 |
gc.enable()
|
| 605 |
|
| 606 |
-
def _postprocess_batch_inner(
|
|
|
|
|
|
|
| 607 |
_t = time.perf_counter
|
| 608 |
|
| 609 |
t0 = _t()
|
|
@@ -611,7 +817,9 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 611 |
recog_decode_ms = (_t() - t0) * 1000
|
| 612 |
|
| 613 |
t0 = _t()
|
| 614 |
-
relation_batch = self.relation_encoder.convert_targets_to_labels(
|
|
|
|
|
|
|
| 615 |
rel_decode_ms = (_t() - t0) * 1000
|
| 616 |
|
| 617 |
t0 = _t()
|
|
@@ -634,7 +842,9 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 634 |
for example in batch:
|
| 635 |
for text_region in example:
|
| 636 |
v = text_region.region.vertices
|
| 637 |
-
text_region.region =
|
|
|
|
|
|
|
| 638 |
graph_build_ms = (_t() - t0) * 1000
|
| 639 |
|
| 640 |
t0 = _t()
|
|
@@ -647,7 +857,9 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 647 |
continue
|
| 648 |
|
| 649 |
boxes, texts, scores = parse_relational_results(example, level=merge_level)
|
| 650 |
-
boxes, texts, scores = reorder_boxes(
|
|
|
|
|
|
|
| 651 |
|
| 652 |
if len(boxes) == 0:
|
| 653 |
all_predictions.append([])
|
|
@@ -659,14 +871,16 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 659 |
|
| 660 |
preds = []
|
| 661 |
for box, text, conf in zip(boxes_array, texts, scores):
|
| 662 |
-
preds.append(
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
|
|
|
|
|
|
| 670 |
all_predictions.append(preds)
|
| 671 |
format_ms = (_t() - t0) * 1000
|
| 672 |
|
|
@@ -701,7 +915,8 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 701 |
if profile:
|
| 702 |
t0 = time.perf_counter()
|
| 703 |
resized_batch, original_shapes, padded_lengths = self._preprocess_batch(
|
| 704 |
-
images,
|
|
|
|
| 705 |
)
|
| 706 |
if profile:
|
| 707 |
torch.cuda.synchronize()
|
|
@@ -717,10 +932,11 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 717 |
T["detector"] = (time.perf_counter() - t0) * 1000
|
| 718 |
|
| 719 |
# Phase 2.5: centerness + peak prefilter
|
|
|
|
| 720 |
if self._use_prefilter:
|
| 721 |
if profile:
|
| 722 |
t0 = time.perf_counter()
|
| 723 |
-
|
| 724 |
if profile:
|
| 725 |
torch.cuda.synchronize()
|
| 726 |
T["prefilter"] = (time.perf_counter() - t0) * 1000
|
|
@@ -728,7 +944,7 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 728 |
# Phase 3: NMS
|
| 729 |
if profile:
|
| 730 |
t0 = time.perf_counter()
|
| 731 |
-
nms_result = self._run_nms(det_conf, det_rboxes)
|
| 732 |
del det_conf, det_rboxes
|
| 733 |
if profile:
|
| 734 |
torch.cuda.synchronize()
|
|
@@ -739,7 +955,10 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 739 |
T["total"] = (time.perf_counter() - t_wall) * 1000
|
| 740 |
logger.info(
|
| 741 |
"batch=%d regions=0 det=%.1f nms=%.1f total=%.1f ms (no detections)",
|
| 742 |
-
num_images,
|
|
|
|
|
|
|
|
|
|
| 743 |
)
|
| 744 |
return [[] for _ in range(num_images)]
|
| 745 |
|
|
@@ -750,10 +969,17 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 750 |
if self._detector_only:
|
| 751 |
with torch.inference_mode():
|
| 752 |
scale_factors = torch.as_tensor(
|
| 753 |
-
padded_lengths,
|
|
|
|
|
|
|
| 754 |
) / float(self.infer_length)
|
| 755 |
counts_long = region_counts.to(dtype=torch.long, device=quads.device)
|
| 756 |
-
scale_per_region = torch.repeat_interleave(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
quads_scaled = quads * scale_per_region.view(-1, 1, 1)
|
| 758 |
|
| 759 |
region_counts_cpu = region_counts.cpu()
|
|
@@ -768,14 +994,16 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 768 |
predictions = []
|
| 769 |
for i in range(n):
|
| 770 |
q = quads_np[offset + i]
|
| 771 |
-
predictions.append(
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
|
|
|
|
|
|
| 779 |
offset += n
|
| 780 |
all_predictions.append(predictions)
|
| 781 |
|
|
@@ -783,15 +1011,23 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 783 |
T["total"] = (time.perf_counter() - t_wall) * 1000
|
| 784 |
logger.info(
|
| 785 |
"batch=%d regions=%d det=%.1f nms=%.1f total=%.1f ms (detector_only)",
|
| 786 |
-
num_images,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
)
|
| 788 |
return all_predictions
|
| 789 |
|
| 790 |
# Phase 4: rectify + grid sample
|
| 791 |
if profile:
|
| 792 |
t0 = time.perf_counter()
|
| 793 |
-
quads_cuda, rec_quads, rel_quads, region_counts_cpu =
|
| 794 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 795 |
)
|
| 796 |
del det_features
|
| 797 |
if profile:
|
|
@@ -811,10 +1047,19 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 811 |
if self._skip_relational:
|
| 812 |
with amp.autocast("cuda", enabled=True), torch.inference_mode():
|
| 813 |
scale_factors = torch.as_tensor(
|
| 814 |
-
padded_lengths,
|
|
|
|
|
|
|
| 815 |
) / float(self.infer_length)
|
| 816 |
-
counts_long = region_counts.to(
|
| 817 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
|
| 819 |
|
| 820 |
seq_ids = rec_ids
|
|
@@ -822,35 +1067,41 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 822 |
_before_eos = (seq_ids == 1).cumsum(dim=1) == 0
|
| 823 |
_real = (seq_ids != 0) & _before_eos
|
| 824 |
_counts = _real.sum(dim=1).clamp(min=1).float()
|
| 825 |
-
_log_sum = (torch.log(seq_probs.clamp(min=1e-8)) * _real.float()).sum(
|
|
|
|
|
|
|
| 826 |
text_confidence = torch.exp(_log_sum / _counts)
|
| 827 |
|
| 828 |
-
batch = self._decode_with_fallback(
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
|
|
|
|
|
|
| 836 |
all_predictions = []
|
| 837 |
for img_idx, example in enumerate(batch):
|
| 838 |
orig_h, orig_w = original_shapes[img_idx]
|
| 839 |
predictions = []
|
| 840 |
for text_region in example:
|
| 841 |
v = text_region.region.vertices
|
| 842 |
-
v = v.cpu().numpy() if hasattr(v,
|
| 843 |
v_norm = v.copy()
|
| 844 |
v_norm[:, 0] /= orig_w
|
| 845 |
v_norm[:, 1] /= orig_h
|
| 846 |
-
predictions.append(
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
|
|
|
|
|
|
| 854 |
all_predictions.append(predictions)
|
| 855 |
|
| 856 |
if profile:
|
|
@@ -859,9 +1110,14 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 859 |
logger.info(
|
| 860 |
"batch=%d regions=%d(%s) det=%.1f nms=%.1f "
|
| 861 |
"rectify=%.1f recog=%.1f total=%.1f ms (skip_relational)",
|
| 862 |
-
num_images,
|
| 863 |
-
|
| 864 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
)
|
| 866 |
return all_predictions
|
| 867 |
|
|
@@ -889,7 +1145,11 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 889 |
if profile:
|
| 890 |
t0 = time.perf_counter()
|
| 891 |
results = self._postprocess_batch(
|
| 892 |
-
output,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 893 |
)
|
| 894 |
if profile:
|
| 895 |
T["post_total"] = (time.perf_counter() - t0) * 1000
|
|
@@ -902,12 +1162,21 @@ class NemotronOCRV2(NemotronOCR):
|
|
| 902 |
"rectify=%.1f recog=%.1f rel=%.1f "
|
| 903 |
"post[decode=%.1f rel=%.1f graph=%.1f fmt=%.1f]=%.1f "
|
| 904 |
"total=%.1f ms",
|
| 905 |
-
num_images,
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
T.get("
|
| 909 |
-
T.get("
|
| 910 |
-
T.get("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 911 |
T.get("post_total", 0),
|
| 912 |
T["total"],
|
| 913 |
)
|
|
|
|
| 21 |
import torch.nn.functional as F
|
| 22 |
from torch import amp
|
| 23 |
|
| 24 |
+
try:
|
| 25 |
+
import triton
|
| 26 |
+
import triton.language as tl
|
| 27 |
+
except ImportError: # The standalone model package does not require Triton.
|
| 28 |
+
triton = None
|
| 29 |
+
tl = None
|
| 30 |
+
|
| 31 |
from nemotron_ocr.inference.pipeline import (
|
| 32 |
NemotronOCR,
|
| 33 |
DETECTOR_DOWNSAMPLE,
|
|
|
|
| 58 |
_DEFAULT_NUM_TOKENS = 858
|
| 59 |
|
| 60 |
|
| 61 |
+
if triton is not None:
|
| 62 |
+
|
| 63 |
+
@triton.jit
|
| 64 |
+
def _argmax_softmax_probability_kernel(
|
| 65 |
+
logits_ptr,
|
| 66 |
+
ids_ptr,
|
| 67 |
+
probabilities_ptr,
|
| 68 |
+
row_stride: tl.constexpr,
|
| 69 |
+
num_columns: tl.constexpr,
|
| 70 |
+
BLOCK_SIZE: tl.constexpr,
|
| 71 |
+
):
|
| 72 |
+
row = tl.program_id(0)
|
| 73 |
+
offsets = tl.arange(0, BLOCK_SIZE)
|
| 74 |
+
values = tl.load(
|
| 75 |
+
logits_ptr + row * row_stride + offsets,
|
| 76 |
+
mask=offsets < num_columns,
|
| 77 |
+
other=-float("inf"),
|
| 78 |
+
).to(tl.float32)
|
| 79 |
+
max_value = tl.max(values, axis=0)
|
| 80 |
+
max_index = tl.argmax(values, axis=0)
|
| 81 |
+
denominator = tl.sum(tl.exp(values - max_value), axis=0)
|
| 82 |
+
tl.store(ids_ptr + row, max_index)
|
| 83 |
+
tl.store(probabilities_ptr + row, 1.0 / denominator)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _argmax_softmax_probability(logits):
|
| 87 |
+
"""Return argmax IDs and their softmax probabilities without a full output."""
|
| 88 |
+
if triton is None or not logits.is_cuda:
|
| 89 |
+
ids = logits.argmax(dim=2)
|
| 90 |
+
probabilities = (
|
| 91 |
+
torch.softmax(logits, dim=2).gather(2, ids.unsqueeze(2)).squeeze(2)
|
| 92 |
+
)
|
| 93 |
+
return ids, probabilities.float()
|
| 94 |
+
|
| 95 |
+
rows = logits.reshape(-1, logits.shape[-1])
|
| 96 |
+
ids = torch.empty(rows.shape[0], dtype=torch.int64, device=logits.device)
|
| 97 |
+
probabilities = torch.empty(rows.shape[0], dtype=logits.dtype, device=logits.device)
|
| 98 |
+
block_size = triton.next_power_of_2(rows.shape[1])
|
| 99 |
+
_argmax_softmax_probability_kernel[(rows.shape[0],)](
|
| 100 |
+
rows,
|
| 101 |
+
ids,
|
| 102 |
+
probabilities,
|
| 103 |
+
rows.stride(0),
|
| 104 |
+
rows.shape[1],
|
| 105 |
+
BLOCK_SIZE=block_size,
|
| 106 |
+
num_warps=8,
|
| 107 |
+
)
|
| 108 |
+
output_shape = logits.shape[:-1]
|
| 109 |
+
return ids.view(output_shape), probabilities.view(output_shape).float()
|
| 110 |
+
|
| 111 |
+
|
| 112 |
class NemotronOCRV2(NemotronOCR):
|
| 113 |
"""Batched OCR inference pipeline.
|
| 114 |
|
|
|
|
| 167 |
|
| 168 |
torch.backends.cuda.matmul.allow_tf32 = True
|
| 169 |
torch.backends.cudnn.allow_tf32 = True
|
| 170 |
+
torch.backends.cudnn.benchmark = (
|
| 171 |
+
os.environ.get("NEMOTRON_OCR_CUDNN_BENCHMARK", "0") == "1"
|
| 172 |
+
)
|
| 173 |
+
self._detector_channels_last = (
|
| 174 |
+
os.environ.get("NEMOTRON_OCR_DETECTOR_CHANNELS_LAST", "0") == "1"
|
| 175 |
+
)
|
| 176 |
+
self._mixed_grid_sample = (
|
| 177 |
+
os.environ.get("NEMOTRON_OCR_MIXED_GRID_SAMPLE", "0") == "1"
|
| 178 |
+
)
|
| 179 |
+
self._fused_batch_norm_relu_count = 0
|
| 180 |
+
self._fused_residual_norm_count = 0
|
| 181 |
+
self._fused_aspp_norm_add_count = 0
|
| 182 |
+
if os.environ.get("NEMOTRON_OCR_FUSED_ASPP_NORM_ADD", "0") == "1":
|
| 183 |
+
from nemotron_ocr.inference.models.detector.fast_batch_norm import (
|
| 184 |
+
fuse_aspp_batch_norm_relu_add,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
self._fused_aspp_norm_add_count = fuse_aspp_batch_norm_relu_add(
|
| 188 |
+
self.detector
|
| 189 |
+
)
|
| 190 |
+
logger.info(
|
| 191 |
+
"Replaced %d ASPP norm/ReLU/residual-add paths",
|
| 192 |
+
self._fused_aspp_norm_add_count,
|
| 193 |
+
)
|
| 194 |
+
if os.environ.get("NEMOTRON_OCR_FUSED_RESIDUAL_NORM", "0") == "1":
|
| 195 |
+
from nemotron_ocr.inference.models.detector.fast_batch_norm import (
|
| 196 |
+
fuse_residual_batch_norm_add_relu,
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
self._fused_residual_norm_count = fuse_residual_batch_norm_add_relu(
|
| 200 |
+
self.detector
|
| 201 |
+
)
|
| 202 |
+
logger.info(
|
| 203 |
+
"Replaced %d detector residual norm/add/ReLU blocks",
|
| 204 |
+
self._fused_residual_norm_count,
|
| 205 |
+
)
|
| 206 |
+
if os.environ.get("NEMOTRON_OCR_FUSED_BATCH_NORM_RELU", "0") == "1":
|
| 207 |
+
from nemotron_ocr.inference.models.detector.fast_batch_norm import (
|
| 208 |
+
fuse_batch_norm_relu,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
self._fused_batch_norm_relu_count = fuse_batch_norm_relu(self.detector)
|
| 212 |
+
logger.info(
|
| 213 |
+
"Replaced %d detector BatchNorm+ReLU pairs with fused kernels",
|
| 214 |
+
self._fused_batch_norm_relu_count,
|
| 215 |
+
)
|
| 216 |
+
if self._detector_channels_last:
|
| 217 |
+
self.detector.to(memory_format=torch.channels_last)
|
| 218 |
+
detector_compile_mode = os.environ.get("NEMOTRON_OCR_COMPILE_DETECTOR")
|
| 219 |
+
if detector_compile_mode:
|
| 220 |
+
self.detector = torch.compile(
|
| 221 |
+
self.detector,
|
| 222 |
+
mode=detector_compile_mode,
|
| 223 |
+
dynamic=False,
|
| 224 |
+
fullgraph=False,
|
| 225 |
+
)
|
| 226 |
|
| 227 |
# ── pad_color ────────────────────────────────────────────────
|
| 228 |
if pad_color is not None:
|
|
|
|
| 254 |
if hasattr(self, "relational"):
|
| 255 |
self.relational.chunk_size = relational_chunk_size
|
| 256 |
|
| 257 |
+
if (
|
| 258 |
+
verbose_post
|
| 259 |
+
and hasattr(self, "relation_encoder")
|
| 260 |
+
and hasattr(self.relation_encoder, "_verbose")
|
| 261 |
+
):
|
| 262 |
self.relation_encoder._verbose = True
|
| 263 |
|
| 264 |
if not self._detector_only and hasattr(self, "recognizer"):
|
| 265 |
self._pad_classifier_for_alignment(64)
|
| 266 |
+
recognizer_compile_mode = os.environ.get("NEMOTRON_OCR_COMPILE_RECOGNIZER")
|
| 267 |
+
self._recognizer_clone_compiled_outputs = recognizer_compile_mode in {
|
| 268 |
+
"reduce-overhead",
|
| 269 |
+
"max-autotune",
|
| 270 |
+
}
|
| 271 |
+
if recognizer_compile_mode:
|
| 272 |
+
self.recognizer = torch.compile(
|
| 273 |
+
self.recognizer,
|
| 274 |
+
mode=recognizer_compile_mode,
|
| 275 |
+
dynamic=False,
|
| 276 |
+
fullgraph=False,
|
| 277 |
+
)
|
| 278 |
|
| 279 |
# ------------------------------------------------------------------
|
| 280 |
# Tensor-core alignment
|
|
|
|
| 305 |
self.recognizer.classifier = new_cls
|
| 306 |
logger.info(
|
| 307 |
"Padded recognizer classifier %d -> %d (alignment=%d)",
|
| 308 |
+
real,
|
| 309 |
+
padded,
|
| 310 |
+
alignment,
|
| 311 |
)
|
| 312 |
|
| 313 |
# ------------------------------------------------------------------
|
|
|
|
| 389 |
del tensor_gpu
|
| 390 |
|
| 391 |
resized = interpolate_and_pad(
|
| 392 |
+
padded.unsqueeze(0),
|
| 393 |
+
self._pad_color,
|
| 394 |
+
self.infer_length,
|
| 395 |
).squeeze(0)
|
| 396 |
del padded
|
| 397 |
resized_list.append(resized)
|
|
|
|
| 421 |
if t.shape[0] == 4:
|
| 422 |
t = t[:3]
|
| 423 |
if t.dtype != torch.uint8:
|
| 424 |
+
t = (
|
| 425 |
+
(t * 255).clamp(0, 255).to(torch.uint8)
|
| 426 |
+
if t.is_floating_point()
|
| 427 |
+
else t.to(torch.uint8)
|
| 428 |
+
)
|
| 429 |
return t
|
| 430 |
if t.ndim == 3 and t.shape[2] in (1, 3, 4):
|
| 431 |
t = t.permute(2, 0, 1)
|
| 432 |
if t.shape[0] == 4:
|
| 433 |
t = t[:3]
|
| 434 |
if t.dtype != torch.uint8:
|
| 435 |
+
t = (
|
| 436 |
+
(t * 255).clamp(0, 255).to(torch.uint8)
|
| 437 |
+
if t.is_floating_point()
|
| 438 |
+
else t.to(torch.uint8)
|
| 439 |
+
)
|
| 440 |
return t
|
| 441 |
raise ValueError(f"Unsupported tensor shape: {image.shape}")
|
| 442 |
|
|
|
|
| 446 |
if image.shape[2] == 4:
|
| 447 |
image = image[..., :3]
|
| 448 |
if image.dtype != np.uint8:
|
| 449 |
+
image = (
|
| 450 |
+
(image * 255).clip(0, 255).astype(np.uint8)
|
| 451 |
+
if image.max() <= 1.0
|
| 452 |
+
else image.astype(np.uint8)
|
| 453 |
+
)
|
| 454 |
return torch.from_numpy(image).permute(2, 0, 1)
|
| 455 |
|
| 456 |
from torchvision.io import read_image, decode_image
|
| 457 |
+
|
| 458 |
if isinstance(image, (str, os.PathLike)):
|
| 459 |
return read_image(str(image), mode="RGB")
|
| 460 |
if isinstance(image, bytes):
|
| 461 |
import base64
|
| 462 |
+
|
| 463 |
img_bytes = base64.b64decode(image)
|
| 464 |
+
return decode_image(
|
| 465 |
+
torch.frombuffer(img_bytes, dtype=torch.uint8), mode="RGB"
|
| 466 |
+
)
|
| 467 |
+
if hasattr(image, "read"):
|
| 468 |
image.seek(0)
|
| 469 |
+
return decode_image(
|
| 470 |
+
torch.frombuffer(image.getvalue(), dtype=torch.uint8), mode="RGB"
|
| 471 |
+
)
|
| 472 |
|
| 473 |
raise TypeError(f"Unsupported input type: {type(image)}")
|
| 474 |
|
|
|
|
| 491 |
for start in range(0, n, self.detector_max_batch_size):
|
| 492 |
end = min(start + self.detector_max_batch_size, n)
|
| 493 |
chunk = resized_batch[start:end]
|
| 494 |
+
if self._detector_channels_last:
|
| 495 |
+
chunk = chunk.contiguous(memory_format=torch.channels_last)
|
| 496 |
c, _, r, f = self.detector(chunk)
|
| 497 |
conf_parts.append(c)
|
| 498 |
rbox_parts.append(r)
|
|
|
|
| 517 |
to suppress edge pixels, then keeps only local maxima. This prevents
|
| 518 |
the O(n^2) NMS adjacency blowup on images with large text regions
|
| 519 |
while preserving all real detection peaks.
|
| 520 |
+
|
| 521 |
+
Returns sigmoid probabilities with non-peaks masked exactly as in the
|
| 522 |
+
legacy filtered-logit path. NMS reuses them so the dense confidence
|
| 523 |
+
map is not passed through sigmoid a second time.
|
| 524 |
"""
|
| 525 |
with torch.inference_mode():
|
| 526 |
+
conf_sigmoid = torch.sigmoid(det_conf.float())
|
| 527 |
d_top = det_rboxes[..., 0].float()
|
| 528 |
d_right = det_rboxes[..., 1].float()
|
| 529 |
d_bottom = det_rboxes[..., 2].float()
|
|
|
|
| 536 |
|
| 537 |
centerness = torch.sqrt((lr_min / lr_max) * (tb_min / tb_max))
|
| 538 |
|
|
|
|
| 539 |
adjusted = conf_sigmoid * centerness
|
| 540 |
|
| 541 |
k = self._prefilter_peak_kernel
|
| 542 |
pad = k // 2
|
| 543 |
adj_4d = adjusted.unsqueeze(1)
|
| 544 |
pooled = F.max_pool2d(adj_4d, k, stride=1, padding=pad)
|
| 545 |
+
pooled = pooled[:, 0, : det_conf.shape[1], : det_conf.shape[2]]
|
| 546 |
|
| 547 |
peaks = (adjusted == pooled) & (adjusted > NMS_PROB_THRESHOLD)
|
| 548 |
|
| 549 |
+
# Match ``torch.sigmoid(filtered_logits)`` exactly while reusing
|
| 550 |
+
# the probabilities already computed for peak selection.
|
| 551 |
+
# Detector logits can be fp16/bf16 under autocast, so cast back to
|
| 552 |
+
# their dtype before NMS. Sigmoid(-100) is exactly zero in each
|
| 553 |
+
# detector dtype. Keep a legacy fallback for non-detector dtypes
|
| 554 |
+
# (notably float64, where sigmoid(-100) remains nonzero).
|
| 555 |
+
if det_conf.dtype in (torch.float16, torch.bfloat16, torch.float32):
|
| 556 |
+
e2e_det_conf = conf_sigmoid.to(dtype=det_conf.dtype)
|
| 557 |
+
e2e_det_conf.masked_fill_(~peaks, 0.0)
|
| 558 |
+
else:
|
| 559 |
+
filtered = det_conf.clone()
|
| 560 |
+
filtered[~peaks] = -100.0
|
| 561 |
+
e2e_det_conf = torch.sigmoid(filtered)
|
| 562 |
+
|
| 563 |
+
return e2e_det_conf
|
| 564 |
+
|
| 565 |
+
def _run_nms(self, det_conf, det_rboxes, e2e_det_conf=None):
|
| 566 |
"""Sigmoid + rrect_to_quads + NMS.
|
| 567 |
|
| 568 |
Returns:
|
| 569 |
(quads, confidence, region_counts, e2e_det_conf) or None if
|
| 570 |
zero detections.
|
| 571 |
"""
|
| 572 |
+
if e2e_det_conf is None:
|
| 573 |
+
with torch.inference_mode():
|
| 574 |
+
e2e_det_conf = torch.sigmoid(det_conf)
|
| 575 |
|
| 576 |
with amp.autocast("cuda", enabled=True), torch.inference_mode():
|
| 577 |
e2e_det_coords = rrect_to_quads(det_rboxes.float(), DETECTOR_DOWNSAMPLE)
|
|
|
|
| 613 |
input_indices_cuda = input_indices.cuda(non_blocking=True)
|
| 614 |
region_counts_cpu = region_counts.cpu()
|
| 615 |
|
| 616 |
+
grid_features = (
|
| 617 |
+
det_features if self._mixed_grid_sample else det_features.float()
|
| 618 |
+
)
|
| 619 |
+
rec_quads = self.grid_sampler(
|
| 620 |
+
grid_features, rec_rectified, input_indices_cuda
|
| 621 |
+
)
|
| 622 |
|
| 623 |
rel_quads = None
|
| 624 |
if not self._skip_relational:
|
| 625 |
rel_rectified = self.relational_quad_rectifier(quads_cuda, h, w)
|
| 626 |
+
rel_quads = self.grid_sampler(
|
| 627 |
+
grid_features, rel_rectified, input_indices_cuda
|
| 628 |
+
)
|
| 629 |
|
| 630 |
return quads_cuda, rec_quads, rel_quads, region_counts_cpu
|
| 631 |
|
|
|
|
| 654 |
return (
|
| 655 |
torch.empty(0, self.max_width, dtype=torch.int64, device=device),
|
| 656 |
torch.empty(0, self.max_width, dtype=torch.float32, device=device),
|
| 657 |
+
torch.empty(
|
| 658 |
+
0,
|
| 659 |
+
self.max_width,
|
| 660 |
+
self.recognizer.feature_depth,
|
| 661 |
+
dtype=torch.float16,
|
| 662 |
+
device=device,
|
| 663 |
+
),
|
| 664 |
)
|
| 665 |
|
| 666 |
cs = self.recognizer_chunk_size
|
|
|
|
| 671 |
chunk = rec_quads[start:end].half()
|
| 672 |
real_n = chunk.shape[0]
|
| 673 |
if real_n < cs:
|
| 674 |
+
pad = torch.zeros(
|
| 675 |
+
cs - real_n,
|
| 676 |
+
*chunk.shape[1:],
|
| 677 |
+
dtype=chunk.dtype,
|
| 678 |
+
device=chunk.device,
|
| 679 |
+
)
|
| 680 |
chunk = torch.cat([chunk, pad], dim=0)
|
| 681 |
logits, feats = self.recognizer(chunk)
|
| 682 |
|
| 683 |
+
ids, probs = _argmax_softmax_probability(logits)
|
|
|
|
|
|
|
|
|
|
| 684 |
|
| 685 |
ids_parts.append(ids[:real_n])
|
| 686 |
probs_parts.append(probs[:real_n])
|
| 687 |
+
real_features = feats[:real_n]
|
| 688 |
+
if self._recognizer_clone_compiled_outputs:
|
| 689 |
+
real_features = real_features.clone()
|
| 690 |
+
feat_parts.append(real_features)
|
| 691 |
|
| 692 |
def _cat_or_single(parts):
|
| 693 |
return parts[0] if len(parts) == 1 else torch.cat(parts, dim=0)
|
| 694 |
|
| 695 |
+
return (
|
| 696 |
+
_cat_or_single(ids_parts),
|
| 697 |
+
_cat_or_single(probs_parts),
|
| 698 |
+
_cat_or_single(feat_parts),
|
| 699 |
+
)
|
| 700 |
|
| 701 |
# ------------------------------------------------------------------
|
| 702 |
# Phase 6 — Relational model + build output dict
|
|
|
|
| 736 |
|
| 737 |
# Vectorized quad scaling
|
| 738 |
scale_factors = torch.as_tensor(
|
| 739 |
+
padded_lengths,
|
| 740 |
+
dtype=torch.float32,
|
| 741 |
+
device=quads_cuda.device,
|
| 742 |
) / float(self.infer_length)
|
| 743 |
counts_long = region_counts.to(dtype=torch.long, device=quads_cuda.device)
|
| 744 |
+
scale_per_region = torch.repeat_interleave(
|
| 745 |
+
scale_factors,
|
| 746 |
+
counts_long,
|
| 747 |
+
dim=0,
|
| 748 |
+
output_size=quads_cuda.shape[0],
|
| 749 |
+
)
|
| 750 |
quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
|
| 751 |
|
| 752 |
seq_ids = rec_ids
|
|
|
|
| 760 |
|
| 761 |
output = {
|
| 762 |
"sequences": seq_ids.cpu(),
|
|
|
|
| 763 |
"text_confidence": text_confidence.cpu(),
|
| 764 |
+
"region_counts": region_counts_cpu,
|
| 765 |
"quads": quads_scaled.cpu(),
|
|
|
|
| 766 |
"confidence": confidence.cpu(),
|
| 767 |
"relations": words,
|
| 768 |
"line_relations": lines,
|
|
|
|
| 770 |
"fg_colors": None,
|
| 771 |
"fonts": None,
|
| 772 |
"tt_log_var_uncertainty": None,
|
|
|
|
| 773 |
}
|
| 774 |
|
| 775 |
return output
|
|
|
|
| 781 |
def _decode_with_fallback(self, output):
|
| 782 |
"""Decode recognized sequences using pre-computed argmax indices."""
|
| 783 |
return self.recog_encoder.convert_targets_to_labels(
|
| 784 |
+
output,
|
| 785 |
+
image_size=None,
|
| 786 |
+
is_gt=False,
|
| 787 |
)
|
| 788 |
|
| 789 |
+
def _postprocess_batch(
|
| 790 |
+
self, output, original_shapes, merge_level, include_invalid, timings=None
|
| 791 |
+
):
|
| 792 |
"""Decode sequences, build relation graphs, format per-image results."""
|
| 793 |
import gc
|
| 794 |
+
|
| 795 |
gc_was_enabled = gc.isenabled()
|
| 796 |
gc.disable()
|
| 797 |
|
| 798 |
try:
|
| 799 |
return self._postprocess_batch_inner(
|
| 800 |
+
output,
|
| 801 |
+
original_shapes,
|
| 802 |
+
merge_level,
|
| 803 |
+
include_invalid,
|
| 804 |
+
timings,
|
| 805 |
)
|
| 806 |
finally:
|
| 807 |
if gc_was_enabled:
|
| 808 |
gc.enable()
|
| 809 |
|
| 810 |
+
def _postprocess_batch_inner(
|
| 811 |
+
self, output, original_shapes, merge_level, include_invalid, timings=None
|
| 812 |
+
):
|
| 813 |
_t = time.perf_counter
|
| 814 |
|
| 815 |
t0 = _t()
|
|
|
|
| 817 |
recog_decode_ms = (_t() - t0) * 1000
|
| 818 |
|
| 819 |
t0 = _t()
|
| 820 |
+
relation_batch = self.relation_encoder.convert_targets_to_labels(
|
| 821 |
+
output, image_size=None, is_gt=False
|
| 822 |
+
)
|
| 823 |
rel_decode_ms = (_t() - t0) * 1000
|
| 824 |
|
| 825 |
t0 = _t()
|
|
|
|
| 842 |
for example in batch:
|
| 843 |
for text_region in example:
|
| 844 |
v = text_region.region.vertices
|
| 845 |
+
text_region.region = (
|
| 846 |
+
v.cpu().numpy() if hasattr(v, "cpu") else np.asarray(v)
|
| 847 |
+
)
|
| 848 |
graph_build_ms = (_t() - t0) * 1000
|
| 849 |
|
| 850 |
t0 = _t()
|
|
|
|
| 857 |
continue
|
| 858 |
|
| 859 |
boxes, texts, scores = parse_relational_results(example, level=merge_level)
|
| 860 |
+
boxes, texts, scores = reorder_boxes(
|
| 861 |
+
boxes, texts, scores, mode="top_left", dbscan_eps=10
|
| 862 |
+
)
|
| 863 |
|
| 864 |
if len(boxes) == 0:
|
| 865 |
all_predictions.append([])
|
|
|
|
| 871 |
|
| 872 |
preds = []
|
| 873 |
for box, text, conf in zip(boxes_array, texts, scores):
|
| 874 |
+
preds.append(
|
| 875 |
+
{
|
| 876 |
+
"text": text,
|
| 877 |
+
"confidence": float(conf),
|
| 878 |
+
"left": float(box[:, 0].min()),
|
| 879 |
+
"upper": float(box[:, 1].max()),
|
| 880 |
+
"right": float(box[:, 0].max()),
|
| 881 |
+
"lower": float(box[:, 1].min()),
|
| 882 |
+
}
|
| 883 |
+
)
|
| 884 |
all_predictions.append(preds)
|
| 885 |
format_ms = (_t() - t0) * 1000
|
| 886 |
|
|
|
|
| 915 |
if profile:
|
| 916 |
t0 = time.perf_counter()
|
| 917 |
resized_batch, original_shapes, padded_lengths = self._preprocess_batch(
|
| 918 |
+
images,
|
| 919 |
+
timings=T,
|
| 920 |
)
|
| 921 |
if profile:
|
| 922 |
torch.cuda.synchronize()
|
|
|
|
| 932 |
T["detector"] = (time.perf_counter() - t0) * 1000
|
| 933 |
|
| 934 |
# Phase 2.5: centerness + peak prefilter
|
| 935 |
+
e2e_det_conf = None
|
| 936 |
if self._use_prefilter:
|
| 937 |
if profile:
|
| 938 |
t0 = time.perf_counter()
|
| 939 |
+
e2e_det_conf = self._prefilter_detections(det_conf, det_rboxes)
|
| 940 |
if profile:
|
| 941 |
torch.cuda.synchronize()
|
| 942 |
T["prefilter"] = (time.perf_counter() - t0) * 1000
|
|
|
|
| 944 |
# Phase 3: NMS
|
| 945 |
if profile:
|
| 946 |
t0 = time.perf_counter()
|
| 947 |
+
nms_result = self._run_nms(det_conf, det_rboxes, e2e_det_conf)
|
| 948 |
del det_conf, det_rboxes
|
| 949 |
if profile:
|
| 950 |
torch.cuda.synchronize()
|
|
|
|
| 955 |
T["total"] = (time.perf_counter() - t_wall) * 1000
|
| 956 |
logger.info(
|
| 957 |
"batch=%d regions=0 det=%.1f nms=%.1f total=%.1f ms (no detections)",
|
| 958 |
+
num_images,
|
| 959 |
+
T["detector"],
|
| 960 |
+
T["nms"],
|
| 961 |
+
T["total"],
|
| 962 |
)
|
| 963 |
return [[] for _ in range(num_images)]
|
| 964 |
|
|
|
|
| 969 |
if self._detector_only:
|
| 970 |
with torch.inference_mode():
|
| 971 |
scale_factors = torch.as_tensor(
|
| 972 |
+
padded_lengths,
|
| 973 |
+
dtype=torch.float32,
|
| 974 |
+
device=quads.device,
|
| 975 |
) / float(self.infer_length)
|
| 976 |
counts_long = region_counts.to(dtype=torch.long, device=quads.device)
|
| 977 |
+
scale_per_region = torch.repeat_interleave(
|
| 978 |
+
scale_factors,
|
| 979 |
+
counts_long,
|
| 980 |
+
dim=0,
|
| 981 |
+
output_size=quads.shape[0],
|
| 982 |
+
)
|
| 983 |
quads_scaled = quads * scale_per_region.view(-1, 1, 1)
|
| 984 |
|
| 985 |
region_counts_cpu = region_counts.cpu()
|
|
|
|
| 994 |
predictions = []
|
| 995 |
for i in range(n):
|
| 996 |
q = quads_np[offset + i]
|
| 997 |
+
predictions.append(
|
| 998 |
+
{
|
| 999 |
+
"quad": q.tolist(),
|
| 1000 |
+
"confidence": float(confs_np[offset + i]),
|
| 1001 |
+
"left": float(q[:, 0].min() / orig_w),
|
| 1002 |
+
"right": float(q[:, 0].max() / orig_w),
|
| 1003 |
+
"upper": float(q[:, 1].max() / orig_h),
|
| 1004 |
+
"lower": float(q[:, 1].min() / orig_h),
|
| 1005 |
+
}
|
| 1006 |
+
)
|
| 1007 |
offset += n
|
| 1008 |
all_predictions.append(predictions)
|
| 1009 |
|
|
|
|
| 1011 |
T["total"] = (time.perf_counter() - t_wall) * 1000
|
| 1012 |
logger.info(
|
| 1013 |
"batch=%d regions=%d det=%.1f nms=%.1f total=%.1f ms (detector_only)",
|
| 1014 |
+
num_images,
|
| 1015 |
+
total_regions,
|
| 1016 |
+
T["detector"],
|
| 1017 |
+
T["nms"],
|
| 1018 |
+
T["total"],
|
| 1019 |
)
|
| 1020 |
return all_predictions
|
| 1021 |
|
| 1022 |
# Phase 4: rectify + grid sample
|
| 1023 |
if profile:
|
| 1024 |
t0 = time.perf_counter()
|
| 1025 |
+
quads_cuda, rec_quads, rel_quads, region_counts_cpu = (
|
| 1026 |
+
self._run_rectify_and_sample(
|
| 1027 |
+
quads,
|
| 1028 |
+
region_counts,
|
| 1029 |
+
det_features,
|
| 1030 |
+
)
|
| 1031 |
)
|
| 1032 |
del det_features
|
| 1033 |
if profile:
|
|
|
|
| 1047 |
if self._skip_relational:
|
| 1048 |
with amp.autocast("cuda", enabled=True), torch.inference_mode():
|
| 1049 |
scale_factors = torch.as_tensor(
|
| 1050 |
+
padded_lengths,
|
| 1051 |
+
dtype=torch.float32,
|
| 1052 |
+
device=quads_cuda.device,
|
| 1053 |
) / float(self.infer_length)
|
| 1054 |
+
counts_long = region_counts.to(
|
| 1055 |
+
dtype=torch.long, device=quads_cuda.device
|
| 1056 |
+
)
|
| 1057 |
+
scale_per_region = torch.repeat_interleave(
|
| 1058 |
+
scale_factors,
|
| 1059 |
+
counts_long,
|
| 1060 |
+
dim=0,
|
| 1061 |
+
output_size=quads_cuda.shape[0],
|
| 1062 |
+
)
|
| 1063 |
quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
|
| 1064 |
|
| 1065 |
seq_ids = rec_ids
|
|
|
|
| 1067 |
_before_eos = (seq_ids == 1).cumsum(dim=1) == 0
|
| 1068 |
_real = (seq_ids != 0) & _before_eos
|
| 1069 |
_counts = _real.sum(dim=1).clamp(min=1).float()
|
| 1070 |
+
_log_sum = (torch.log(seq_probs.clamp(min=1e-8)) * _real.float()).sum(
|
| 1071 |
+
dim=1
|
| 1072 |
+
)
|
| 1073 |
text_confidence = torch.exp(_log_sum / _counts)
|
| 1074 |
|
| 1075 |
+
batch = self._decode_with_fallback(
|
| 1076 |
+
{
|
| 1077 |
+
"sequences": seq_ids.cpu(),
|
| 1078 |
+
"sequence_probs": seq_probs.cpu(),
|
| 1079 |
+
"text_confidence": text_confidence.cpu(),
|
| 1080 |
+
"region_counts": region_counts.cpu(),
|
| 1081 |
+
"quads": quads_scaled.cpu(),
|
| 1082 |
+
"confidence": confidence.cpu(),
|
| 1083 |
+
}
|
| 1084 |
+
)
|
| 1085 |
all_predictions = []
|
| 1086 |
for img_idx, example in enumerate(batch):
|
| 1087 |
orig_h, orig_w = original_shapes[img_idx]
|
| 1088 |
predictions = []
|
| 1089 |
for text_region in example:
|
| 1090 |
v = text_region.region.vertices
|
| 1091 |
+
v = v.cpu().numpy() if hasattr(v, "cpu") else np.asarray(v)
|
| 1092 |
v_norm = v.copy()
|
| 1093 |
v_norm[:, 0] /= orig_w
|
| 1094 |
v_norm[:, 1] /= orig_h
|
| 1095 |
+
predictions.append(
|
| 1096 |
+
{
|
| 1097 |
+
"text": text_region.text,
|
| 1098 |
+
"confidence": text_region.confidence,
|
| 1099 |
+
"left": float(v_norm[:, 0].min()),
|
| 1100 |
+
"right": float(v_norm[:, 0].max()),
|
| 1101 |
+
"upper": float(v_norm[:, 1].max()),
|
| 1102 |
+
"lower": float(v_norm[:, 1].min()),
|
| 1103 |
+
}
|
| 1104 |
+
)
|
| 1105 |
all_predictions.append(predictions)
|
| 1106 |
|
| 1107 |
if profile:
|
|
|
|
| 1110 |
logger.info(
|
| 1111 |
"batch=%d regions=%d(%s) det=%.1f nms=%.1f "
|
| 1112 |
"rectify=%.1f recog=%.1f total=%.1f ms (skip_relational)",
|
| 1113 |
+
num_images,
|
| 1114 |
+
total_regions,
|
| 1115 |
+
counts_str,
|
| 1116 |
+
T["detector"],
|
| 1117 |
+
T["nms"],
|
| 1118 |
+
T["rectify"],
|
| 1119 |
+
T["recognizer"],
|
| 1120 |
+
T["total"],
|
| 1121 |
)
|
| 1122 |
return all_predictions
|
| 1123 |
|
|
|
|
| 1145 |
if profile:
|
| 1146 |
t0 = time.perf_counter()
|
| 1147 |
results = self._postprocess_batch(
|
| 1148 |
+
output,
|
| 1149 |
+
original_shapes,
|
| 1150 |
+
merge_level,
|
| 1151 |
+
include_invalid,
|
| 1152 |
+
timings=T,
|
| 1153 |
)
|
| 1154 |
if profile:
|
| 1155 |
T["post_total"] = (time.perf_counter() - t0) * 1000
|
|
|
|
| 1162 |
"rectify=%.1f recog=%.1f rel=%.1f "
|
| 1163 |
"post[decode=%.1f rel=%.1f graph=%.1f fmt=%.1f]=%.1f "
|
| 1164 |
"total=%.1f ms",
|
| 1165 |
+
num_images,
|
| 1166 |
+
total_regions,
|
| 1167 |
+
counts_str,
|
| 1168 |
+
T.get("img_load", 0),
|
| 1169 |
+
T.get("gpu_preproc", 0),
|
| 1170 |
+
T.get("detector", 0),
|
| 1171 |
+
T.get("prefilter", 0),
|
| 1172 |
+
T.get("nms", 0),
|
| 1173 |
+
T.get("rectify", 0),
|
| 1174 |
+
T.get("recognizer", 0),
|
| 1175 |
+
T.get("relational", 0),
|
| 1176 |
+
T.get("post_recog_decode", 0),
|
| 1177 |
+
T.get("post_rel_decode", 0),
|
| 1178 |
+
T.get("post_graph_build", 0),
|
| 1179 |
+
T.get("post_format", 0),
|
| 1180 |
T.get("post_total", 0),
|
| 1181 |
T["total"],
|
| 1182 |
)
|
nemotron-ocr/tests/test_detector_exact_copy_helpers.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
import nemotron_ocr.inference.models.detector.aspp as aspp_module
|
| 9 |
+
import nemotron_ocr.inference.models.detector.fots_detector as fots_module
|
| 10 |
+
from nemotron_ocr.inference.models.detector.aspp import ASPP, _aspp_concat
|
| 11 |
+
from nemotron_ocr.inference.models.detector.fots_detector import (
|
| 12 |
+
_upsample_concat_nearest,
|
| 13 |
+
merge,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32])
|
| 18 |
+
@pytest.mark.parametrize(
|
| 19 |
+
("batch", "input_channels", "side_channels", "height", "width"),
|
| 20 |
+
[(1, 1, 2, 2, 3), (2, 3, 1, 3, 2), (1, 2, 3, 1, 5)],
|
| 21 |
+
)
|
| 22 |
+
def test_upsample_concat_cpu_fallback_is_exact(
|
| 23 |
+
dtype, batch, input_channels, side_channels, height, width
|
| 24 |
+
):
|
| 25 |
+
input_tensor = torch.arange(
|
| 26 |
+
batch * input_channels * height * width, dtype=torch.float32
|
| 27 |
+
).reshape(batch, input_channels, height, width)
|
| 28 |
+
input_tensor = input_tensor.to(dtype)
|
| 29 |
+
side_tensor = torch.arange(
|
| 30 |
+
batch * side_channels * height * 2 * width * 2, dtype=torch.float32
|
| 31 |
+
).reshape(batch, side_channels, height * 2, width * 2)
|
| 32 |
+
side_tensor = side_tensor.to(dtype).add(100)
|
| 33 |
+
|
| 34 |
+
expected = torch.cat(
|
| 35 |
+
(F.interpolate(input_tensor, scale_factor=2, mode="nearest"), side_tensor),
|
| 36 |
+
dim=1,
|
| 37 |
+
)
|
| 38 |
+
actual = _upsample_concat_nearest(input_tensor, side_tensor)
|
| 39 |
+
|
| 40 |
+
assert actual.dtype == dtype
|
| 41 |
+
assert actual.is_contiguous()
|
| 42 |
+
assert torch.equal(actual, expected)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32])
|
| 46 |
+
@pytest.mark.parametrize(
|
| 47 |
+
("batch", "channels", "height", "width"),
|
| 48 |
+
[(1, 2, 3, 5), (2, 1, 1, 4), (2, 3, 2, 2)],
|
| 49 |
+
)
|
| 50 |
+
def test_aspp_concat_cpu_fallback_is_exact(dtype, batch, channels, height, width):
|
| 51 |
+
elements = batch * channels * height * width
|
| 52 |
+
base = torch.arange(elements, dtype=torch.float32).reshape(
|
| 53 |
+
batch, channels, height, width
|
| 54 |
+
)
|
| 55 |
+
branches = [(base + branch * 100).to(dtype) for branch in range(7)]
|
| 56 |
+
pooled = torch.arange(batch * channels, dtype=torch.float32).reshape(
|
| 57 |
+
batch, channels, 1, 1
|
| 58 |
+
)
|
| 59 |
+
pooled = pooled.to(dtype).add(1000)
|
| 60 |
+
|
| 61 |
+
expected_pooled = pooled.expand(-1, -1, height, width)
|
| 62 |
+
expected = torch.cat([*branches, expected_pooled], dim=1)
|
| 63 |
+
actual = _aspp_concat(branches, pooled)
|
| 64 |
+
|
| 65 |
+
assert actual.dtype == dtype
|
| 66 |
+
assert actual.is_contiguous()
|
| 67 |
+
assert torch.equal(actual, expected)
|
| 68 |
+
assert torch.equal(actual[:, 7 * channels :], expected_pooled)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class _FakeCudaTensor:
|
| 72 |
+
def __init__(self, shape, *, contiguous=True):
|
| 73 |
+
self.shape = shape
|
| 74 |
+
self.is_cuda = True
|
| 75 |
+
self._contiguous = contiguous
|
| 76 |
+
|
| 77 |
+
def is_contiguous(self):
|
| 78 |
+
return self._contiguous
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.mark.parametrize(
|
| 82 |
+
("input_tensor", "side_tensor", "message"),
|
| 83 |
+
[
|
| 84 |
+
(
|
| 85 |
+
_FakeCudaTensor((1, 2, 2, 3)),
|
| 86 |
+
_FakeCudaTensor((2, 4, 4, 6)),
|
| 87 |
+
"fused upsample-concat requires an exact 2x side tensor",
|
| 88 |
+
),
|
| 89 |
+
(
|
| 90 |
+
_FakeCudaTensor((1, 2, 2, 3)),
|
| 91 |
+
_FakeCudaTensor((1, 4, 5, 6)),
|
| 92 |
+
"fused upsample-concat requires an exact 2x side tensor",
|
| 93 |
+
),
|
| 94 |
+
(
|
| 95 |
+
_FakeCudaTensor((1, 2, 2, 3)),
|
| 96 |
+
_FakeCudaTensor((1, 4, 4, 5)),
|
| 97 |
+
"fused upsample-concat requires an exact 2x side tensor",
|
| 98 |
+
),
|
| 99 |
+
],
|
| 100 |
+
)
|
| 101 |
+
def test_upsample_concat_validation_without_cuda(
|
| 102 |
+
monkeypatch, input_tensor, side_tensor, message
|
| 103 |
+
):
|
| 104 |
+
monkeypatch.setattr(fots_module, "triton", object())
|
| 105 |
+
|
| 106 |
+
with pytest.raises(ValueError, match=message):
|
| 107 |
+
_upsample_concat_nearest(input_tensor, side_tensor)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_upsample_concat_noncontiguous_cuda_falls_back(monkeypatch):
|
| 111 |
+
input_tensor = _FakeCudaTensor((1, 2, 2, 3), contiguous=False)
|
| 112 |
+
side_tensor = _FakeCudaTensor((1, 4, 4, 6))
|
| 113 |
+
sentinel = object()
|
| 114 |
+
monkeypatch.setattr(fots_module, "triton", object())
|
| 115 |
+
monkeypatch.setattr(fots_module.F, "interpolate", lambda *args, **kwargs: sentinel)
|
| 116 |
+
monkeypatch.setattr(
|
| 117 |
+
fots_module.torch,
|
| 118 |
+
"cat",
|
| 119 |
+
lambda tensors, dim: (tensors, dim),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
tensors, dim = _upsample_concat_nearest(input_tensor, side_tensor)
|
| 123 |
+
|
| 124 |
+
assert tensors == (sentinel, side_tensor)
|
| 125 |
+
assert dim == 1
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_aspp_concat_requires_seven_branches_without_cuda(monkeypatch):
|
| 129 |
+
monkeypatch.setattr(aspp_module, "triton", object())
|
| 130 |
+
branch = _FakeCudaTensor((1, 2, 3, 4))
|
| 131 |
+
|
| 132 |
+
with pytest.raises(
|
| 133 |
+
ValueError, match="fused ASPP concat expects seven spatial branches"
|
| 134 |
+
):
|
| 135 |
+
_aspp_concat([branch] * 6, _FakeCudaTensor((1, 2, 1, 1)))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_aspp_concat_noncontiguous_cuda_falls_back(monkeypatch):
|
| 139 |
+
monkeypatch.setattr(aspp_module, "triton", object())
|
| 140 |
+
branches = [_FakeCudaTensor((1, 2, 3, 4)) for _ in range(7)]
|
| 141 |
+
branches[3] = _FakeCudaTensor((1, 2, 3, 4), contiguous=False)
|
| 142 |
+
pooled = _FakeCudaTensor((1, 2, 1, 1))
|
| 143 |
+
sentinel = object()
|
| 144 |
+
monkeypatch.setattr(pooled, "expand", lambda *args: sentinel, raising=False)
|
| 145 |
+
monkeypatch.setattr(
|
| 146 |
+
aspp_module.torch,
|
| 147 |
+
"cat",
|
| 148 |
+
lambda tensors, dim: (tensors, dim),
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
tensors, dim = _aspp_concat(branches, pooled)
|
| 152 |
+
|
| 153 |
+
assert tensors == [*branches, sentinel]
|
| 154 |
+
assert dim == 1
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_aspp_concat_validates_pooled_shape_without_cuda(monkeypatch):
|
| 158 |
+
monkeypatch.setattr(aspp_module, "triton", object())
|
| 159 |
+
branches = [_FakeCudaTensor((2, 3, 4, 5)) for _ in range(7)]
|
| 160 |
+
|
| 161 |
+
with pytest.raises(
|
| 162 |
+
ValueError, match="fused ASPP pooled branch has an unexpected shape"
|
| 163 |
+
):
|
| 164 |
+
_aspp_concat(branches, _FakeCudaTensor((2, 3, 4, 5)))
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_exact_copy_helpers_are_disabled_by_default(monkeypatch):
|
| 168 |
+
monkeypatch.delenv("NEMOTRON_OCR_FUSED_UPSAMPLE_CONCAT", raising=False)
|
| 169 |
+
monkeypatch.delenv("NEMOTRON_OCR_FUSED_ASPP_CONCAT", raising=False)
|
| 170 |
+
|
| 171 |
+
assert merge([4])._fused_upsample_concat is False
|
| 172 |
+
assert ASPP(in_channels=1, num_channels=1)._fused_concat is False
|
nemotron-ocr/tests/test_fast_batch_norm.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import copy
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
import torch
|
| 10 |
+
from torch import nn
|
| 11 |
+
|
| 12 |
+
from nemotron_ocr.inference.models.detector.aspp import ASPP
|
| 13 |
+
from nemotron_ocr.inference.models.detector.fast_batch_norm import (
|
| 14 |
+
FusedBatchNormReLU,
|
| 15 |
+
FusedBatchNormReLUAdd,
|
| 16 |
+
FusedResidualBlock,
|
| 17 |
+
fuse_aspp_batch_norm_relu_add,
|
| 18 |
+
fuse_batch_norm_relu,
|
| 19 |
+
fuse_residual_batch_norm_add_relu,
|
| 20 |
+
)
|
| 21 |
+
from nemotron_ocr.inference.models.detector.regnet import ResBottleneckBlock
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _residual_block(*, projected: bool = False) -> ResBottleneckBlock:
|
| 25 |
+
return ResBottleneckBlock(
|
| 26 |
+
width_in=4,
|
| 27 |
+
width_out=8 if projected else 4,
|
| 28 |
+
stride=2 if projected else 1,
|
| 29 |
+
norm_layer=nn.BatchNorm2d,
|
| 30 |
+
activation_layer=nn.ReLU,
|
| 31 |
+
group_width=1,
|
| 32 |
+
bottleneck_multiplier=1.0,
|
| 33 |
+
se_ratio=None,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_batch_norm_relu_cpu_fallback_is_exact_and_idempotent():
|
| 38 |
+
original = nn.Sequential(nn.BatchNorm2d(3), nn.ReLU(inplace=True)).eval()
|
| 39 |
+
transformed = copy.deepcopy(original)
|
| 40 |
+
inputs = torch.randn(2, 3, 7, 9)
|
| 41 |
+
|
| 42 |
+
assert fuse_batch_norm_relu(transformed) == 1
|
| 43 |
+
assert fuse_batch_norm_relu(transformed) == 0
|
| 44 |
+
assert torch.equal(transformed(inputs), original(inputs))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@pytest.mark.parametrize("projected", [False, True])
|
| 48 |
+
def test_residual_cpu_fallback_is_exact(projected):
|
| 49 |
+
original = nn.Sequential(_residual_block(projected=projected)).eval()
|
| 50 |
+
transformed = copy.deepcopy(original)
|
| 51 |
+
inputs = torch.randn(2, 4, 8, 10)
|
| 52 |
+
|
| 53 |
+
assert fuse_residual_batch_norm_add_relu(transformed) == 1
|
| 54 |
+
assert torch.equal(transformed(inputs), original(inputs))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_aspp_fusion_requires_zero_dropout_eval_residual():
|
| 58 |
+
eligible = ASPP(in_channels=3, num_channels=3, dropout=0.0).eval()
|
| 59 |
+
nonzero_dropout = ASPP(in_channels=3, num_channels=3, dropout=0.5).eval()
|
| 60 |
+
training = ASPP(in_channels=3, num_channels=3, dropout=0.0).train()
|
| 61 |
+
nonresidual = ASPP(in_channels=4, num_channels=3, dropout=0.0).eval()
|
| 62 |
+
|
| 63 |
+
assert fuse_aspp_batch_norm_relu_add(eligible) == 1
|
| 64 |
+
assert fuse_aspp_batch_norm_relu_add(nonzero_dropout) == 0
|
| 65 |
+
assert fuse_aspp_batch_norm_relu_add(training) == 0
|
| 66 |
+
assert fuse_aspp_batch_norm_relu_add(nonresidual) == 0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@pytest.mark.parametrize(
|
| 70 |
+
"batch_norm",
|
| 71 |
+
[
|
| 72 |
+
nn.BatchNorm2d(3, affine=False).eval(),
|
| 73 |
+
nn.BatchNorm2d(3, track_running_stats=False).eval(),
|
| 74 |
+
nn.BatchNorm2d(3).train(),
|
| 75 |
+
],
|
| 76 |
+
)
|
| 77 |
+
def test_generic_fusion_skips_unsupported_batch_norm(batch_norm):
|
| 78 |
+
module = nn.Sequential(batch_norm, nn.ReLU())
|
| 79 |
+
|
| 80 |
+
assert fuse_batch_norm_relu(module) == 0
|
| 81 |
+
assert module[0] is batch_norm
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_inverse_std_is_nonpersistent_and_refreshes_on_eval():
|
| 85 |
+
module = FusedBatchNormReLU(nn.BatchNorm2d(3).eval())
|
| 86 |
+
original_inverse_std = module.inverse_std.clone()
|
| 87 |
+
assert "inverse_std" not in module.state_dict()
|
| 88 |
+
|
| 89 |
+
module.train()
|
| 90 |
+
module(torch.randn(4, 3, 5, 5))
|
| 91 |
+
module.eval()
|
| 92 |
+
|
| 93 |
+
assert not torch.equal(module.inverse_std, original_inverse_std)
|
| 94 |
+
assert torch.equal(
|
| 95 |
+
module.inverse_std,
|
| 96 |
+
torch.rsqrt(module.batch_norm.running_var + module.batch_norm.eps),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_original_checkpoint_must_load_before_fusion():
|
| 101 |
+
module = nn.Sequential(nn.BatchNorm2d(3), nn.ReLU()).eval()
|
| 102 |
+
original_state = copy.deepcopy(module.state_dict())
|
| 103 |
+
fuse_batch_norm_relu(module)
|
| 104 |
+
|
| 105 |
+
with pytest.raises(RuntimeError, match="Missing key"):
|
| 106 |
+
module.load_state_dict(original_state, strict=True)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_launch_parameters_are_validated_for_every_wrapper(monkeypatch):
|
| 110 |
+
monkeypatch.setenv("NEMOTRON_OCR_FUSED_BATCH_NORM_BLOCK_SIZE", "3")
|
| 111 |
+
|
| 112 |
+
with pytest.raises(ValueError, match="power of two"):
|
| 113 |
+
FusedBatchNormReLU(nn.BatchNorm2d(3).eval())
|
| 114 |
+
with pytest.raises(ValueError, match="power of two"):
|
| 115 |
+
FusedBatchNormReLUAdd(nn.BatchNorm2d(3).eval())
|
| 116 |
+
block = _residual_block().eval()
|
| 117 |
+
final_batch_norm = block.f.c[-1]
|
| 118 |
+
with pytest.raises(ValueError, match="power of two"):
|
| 119 |
+
FusedResidualBlock(block)
|
| 120 |
+
assert block.f.c[-1] is final_batch_norm
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_num_warps_validation_is_shared(monkeypatch):
|
| 124 |
+
monkeypatch.setenv("NEMOTRON_OCR_FUSED_BATCH_NORM_NUM_WARPS", "16")
|
| 125 |
+
|
| 126 |
+
with pytest.raises(ValueError, match="must be one of"):
|
| 127 |
+
FusedBatchNormReLUAdd(nn.BatchNorm2d(3).eval())
|
nemotron-ocr/tests/test_pipeline_v2_prefilter.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
import nemotron_ocr.inference.pipeline_v2 as pipeline_v2
|
| 9 |
+
from nemotron_ocr.inference.pipeline import NMS_PROB_THRESHOLD
|
| 10 |
+
from nemotron_ocr.inference.pipeline_v2 import NemotronOCRV2
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _pipeline(peak_kernel=3):
|
| 14 |
+
pipeline = NemotronOCRV2.__new__(NemotronOCRV2)
|
| 15 |
+
object.__setattr__(pipeline, "_prefilter_peak_kernel", peak_kernel)
|
| 16 |
+
return pipeline
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _legacy_prefilter(det_conf, det_rboxes, peak_kernel):
|
| 20 |
+
d_top = det_rboxes[..., 0].float()
|
| 21 |
+
d_right = det_rboxes[..., 1].float()
|
| 22 |
+
d_bottom = det_rboxes[..., 2].float()
|
| 23 |
+
d_left = det_rboxes[..., 3].float()
|
| 24 |
+
|
| 25 |
+
lr_min = torch.minimum(d_left, d_right)
|
| 26 |
+
lr_max = torch.maximum(d_left, d_right).clamp(min=1.0)
|
| 27 |
+
tb_min = torch.minimum(d_top, d_bottom)
|
| 28 |
+
tb_max = torch.maximum(d_top, d_bottom).clamp(min=1.0)
|
| 29 |
+
centerness = torch.sqrt((lr_min / lr_max) * (tb_min / tb_max))
|
| 30 |
+
|
| 31 |
+
adjusted = torch.sigmoid(det_conf.float()) * centerness
|
| 32 |
+
pooled = F.max_pool2d(
|
| 33 |
+
adjusted.unsqueeze(1),
|
| 34 |
+
peak_kernel,
|
| 35 |
+
stride=1,
|
| 36 |
+
padding=peak_kernel // 2,
|
| 37 |
+
)
|
| 38 |
+
pooled = pooled[:, 0, : det_conf.shape[1], : det_conf.shape[2]]
|
| 39 |
+
peaks = (adjusted == pooled) & (adjusted > NMS_PROB_THRESHOLD)
|
| 40 |
+
|
| 41 |
+
filtered = det_conf.clone()
|
| 42 |
+
filtered[~peaks] = -100.0
|
| 43 |
+
return filtered
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest.mark.parametrize(
|
| 47 |
+
"dtype", [torch.float64, torch.float32, torch.float16, torch.bfloat16]
|
| 48 |
+
)
|
| 49 |
+
def test_prefilter_reused_probability_matches_legacy_dense_sigmoid(dtype):
|
| 50 |
+
generator = torch.Generator().manual_seed(7)
|
| 51 |
+
det_conf = torch.randn((2, 9, 11), generator=generator).to(dtype)
|
| 52 |
+
det_rboxes = (
|
| 53 |
+
torch.rand((2, 9, 11, 5), generator=generator).mul_(80).add_(0.25)
|
| 54 |
+
).to(dtype)
|
| 55 |
+
|
| 56 |
+
expected_probability = torch.sigmoid(
|
| 57 |
+
_legacy_prefilter(det_conf, det_rboxes, peak_kernel=3)
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
original_logits = det_conf.clone()
|
| 61 |
+
actual_probability = _pipeline()._prefilter_detections(det_conf, det_rboxes)
|
| 62 |
+
|
| 63 |
+
assert actual_probability.dtype == expected_probability.dtype
|
| 64 |
+
assert torch.equal(actual_probability, expected_probability)
|
| 65 |
+
assert torch.equal(det_conf, original_logits)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_run_nms_forwards_precomputed_probability_without_recomputing(monkeypatch):
|
| 69 |
+
supplied_probability = torch.rand((1, 2, 3), dtype=torch.float32)
|
| 70 |
+
captured = {}
|
| 71 |
+
|
| 72 |
+
def fake_rrect_to_quads(det_rboxes, downsample):
|
| 73 |
+
return torch.zeros((*det_rboxes.shape[:-1], 4, 2), dtype=torch.float32)
|
| 74 |
+
|
| 75 |
+
def fake_nms(coords, probability, **kwargs):
|
| 76 |
+
captured["probability"] = probability
|
| 77 |
+
return (
|
| 78 |
+
torch.zeros((1, 4, 2), dtype=torch.float32),
|
| 79 |
+
torch.ones(1, dtype=torch.float32),
|
| 80 |
+
torch.ones(1, dtype=torch.int64),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
monkeypatch.setattr(pipeline_v2, "rrect_to_quads", fake_rrect_to_quads)
|
| 84 |
+
monkeypatch.setattr(pipeline_v2, "quad_non_maximal_suppression", fake_nms)
|
| 85 |
+
|
| 86 |
+
result = _pipeline()._run_nms(
|
| 87 |
+
torch.randn((1, 2, 3)),
|
| 88 |
+
torch.ones((1, 2, 3, 5)),
|
| 89 |
+
supplied_probability,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
assert captured["probability"] is supplied_probability
|
| 93 |
+
assert result[3] is supplied_probability
|
nemotron-ocr/tests/test_relational_batched_geometry.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
import nemotron_ocr.inference.models.relational as relational
|
| 7 |
+
from nemotron_ocr.inference.models.relational import (
|
| 8 |
+
GlobalRelationalModel,
|
| 9 |
+
_pad_flat_to_batched,
|
| 10 |
+
get_directions,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _make_model(**kwargs):
|
| 15 |
+
return GlobalRelationalModel(
|
| 16 |
+
num_input_channels=[4],
|
| 17 |
+
recog_feature_depth=4,
|
| 18 |
+
k=4,
|
| 19 |
+
num_layers=1,
|
| 20 |
+
**kwargs,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _distance_matrix(quads):
|
| 25 |
+
distances = torch.cdist(quads.mean(dim=1), quads.mean(dim=1))
|
| 26 |
+
distances.fill_diagonal_(torch.inf)
|
| 27 |
+
return distances
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _legacy_geometry_inputs(model, proj_rects, mid_pts, quads, counts):
|
| 31 |
+
feat_dim = proj_rects.shape[1]
|
| 32 |
+
z = model.k - 1
|
| 33 |
+
seq_len = z + 1
|
| 34 |
+
n_total = proj_rects.shape[0]
|
| 35 |
+
enc_input = torch.zeros(n_total, seq_len, 2 * feat_dim + 2)
|
| 36 |
+
mask = torch.ones(n_total, seq_len, dtype=torch.bool)
|
| 37 |
+
closest = torch.zeros(n_total, seq_len, dtype=torch.long)
|
| 38 |
+
|
| 39 |
+
offsets = [0]
|
| 40 |
+
for count in counts:
|
| 41 |
+
offsets.append(offsets[-1] + count)
|
| 42 |
+
|
| 43 |
+
for image_index, count in enumerate(counts):
|
| 44 |
+
if count == 0:
|
| 45 |
+
continue
|
| 46 |
+
start, end = offsets[image_index : image_index + 2]
|
| 47 |
+
rects = proj_rects[start:end]
|
| 48 |
+
centers = mid_pts[start:end]
|
| 49 |
+
image_quads = quads[start:end]
|
| 50 |
+
z_i = min(count - 1, z)
|
| 51 |
+
|
| 52 |
+
from_rects = rects.unsqueeze(1).expand(-1, seq_len, -1)
|
| 53 |
+
enc_input[start:end, 0, :feat_dim] = rects
|
| 54 |
+
enc_input[start:end, 0, 2 * feat_dim] = -1
|
| 55 |
+
enc_input[start:end, 0, 2 * feat_dim + 1] = -2
|
| 56 |
+
mask[start:end, 0] = False
|
| 57 |
+
|
| 58 |
+
if z_i == 0:
|
| 59 |
+
continue
|
| 60 |
+
topk_d, topk_idx = torch.topk(
|
| 61 |
+
_distance_matrix(image_quads),
|
| 62 |
+
k=z_i,
|
| 63 |
+
dim=1,
|
| 64 |
+
largest=False,
|
| 65 |
+
sorted=False,
|
| 66 |
+
)
|
| 67 |
+
neighbor_rects = torch.gather(
|
| 68 |
+
rects.unsqueeze(0).expand(count, -1, -1),
|
| 69 |
+
dim=1,
|
| 70 |
+
index=topk_idx.unsqueeze(2).expand(-1, -1, feat_dim),
|
| 71 |
+
)
|
| 72 |
+
neighbor_centers = torch.gather(
|
| 73 |
+
centers.unsqueeze(0).expand(count, -1, -1),
|
| 74 |
+
dim=1,
|
| 75 |
+
index=topk_idx.unsqueeze(2).expand(-1, -1, 2),
|
| 76 |
+
)
|
| 77 |
+
directions = get_directions(image_quads, neighbor_centers)
|
| 78 |
+
|
| 79 |
+
enc_input[start:end, 1 : z_i + 1, :feat_dim] = from_rects[:, 1 : z_i + 1]
|
| 80 |
+
enc_input[start:end, 1 : z_i + 1, feat_dim : 2 * feat_dim] = neighbor_rects
|
| 81 |
+
enc_input[start:end, 1 : z_i + 1, 2 * feat_dim] = topk_d
|
| 82 |
+
enc_input[start:end, 1 : z_i + 1, 2 * feat_dim + 1] = directions
|
| 83 |
+
mask[start:end, 1 : z_i + 1] = False
|
| 84 |
+
closest[start:end, 1 : z_i + 1] = topk_idx + 1
|
| 85 |
+
|
| 86 |
+
return enc_input, mask, closest
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_pad_flat_to_batched_preserves_ragged_order_without_scalar_reads():
|
| 90 |
+
flat = torch.arange(9 * 2, dtype=torch.float32).reshape(9, 2)
|
| 91 |
+
counts = torch.tensor([3, 0, 2, 4], dtype=torch.long)
|
| 92 |
+
|
| 93 |
+
actual = _pad_flat_to_batched(flat, counts, k_max=4, pad_value=-1)
|
| 94 |
+
|
| 95 |
+
expected = torch.full((4, 4, 2), -1, dtype=torch.float32)
|
| 96 |
+
expected[0, :3] = flat[:3]
|
| 97 |
+
expected[2, :2] = flat[3:5]
|
| 98 |
+
expected[3, :4] = flat[5:]
|
| 99 |
+
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_batched_geometry_exactly_matches_per_image_topk_with_ties(monkeypatch):
|
| 103 |
+
counts = torch.tensor([4, 2, 4, 1, 0], dtype=torch.long)
|
| 104 |
+
counts_list = counts.tolist()
|
| 105 |
+
n_total = sum(counts_list)
|
| 106 |
+
proj_rects = torch.arange(n_total * 5, dtype=torch.float32).reshape(n_total, 5) / 17
|
| 107 |
+
mid_pts = torch.tensor(
|
| 108 |
+
[
|
| 109 |
+
[0.0, 0.0],
|
| 110 |
+
[1.0, 0.0],
|
| 111 |
+
[-1.0, 0.0],
|
| 112 |
+
[0.0, 2.0],
|
| 113 |
+
[3.0, 0.0],
|
| 114 |
+
[5.0, 0.0],
|
| 115 |
+
[10.0, 0.0],
|
| 116 |
+
[11.0, 0.0],
|
| 117 |
+
[9.0, 0.0],
|
| 118 |
+
[10.0, 2.0],
|
| 119 |
+
[20.0, 0.0],
|
| 120 |
+
]
|
| 121 |
+
)
|
| 122 |
+
quads = mid_pts[:, None, :].expand(-1, 4, -1).clone()
|
| 123 |
+
quads[:, (0, 3), 0] -= 0.1
|
| 124 |
+
quads[:, (1, 2), 0] += 0.1
|
| 125 |
+
|
| 126 |
+
calls = []
|
| 127 |
+
|
| 128 |
+
def fake_batched_cdist(padded_quads, region_counts, *args, **kwargs):
|
| 129 |
+
calls.append((padded_quads.shape, region_counts.clone()))
|
| 130 |
+
batch_size, k_max = padded_quads.shape[:2]
|
| 131 |
+
output = padded_quads.new_zeros(batch_size, k_max, k_max)
|
| 132 |
+
for image_index, count in enumerate(region_counts.tolist()):
|
| 133 |
+
output[image_index, :count, :count] = _distance_matrix(
|
| 134 |
+
padded_quads[image_index, :count]
|
| 135 |
+
)
|
| 136 |
+
return output
|
| 137 |
+
|
| 138 |
+
monkeypatch.setattr(relational, "get_cdist_batched", fake_batched_cdist)
|
| 139 |
+
model = _make_model(batched_geometry=True)
|
| 140 |
+
|
| 141 |
+
actual = model._build_batched_geometry_inputs(
|
| 142 |
+
proj_rects,
|
| 143 |
+
mid_pts,
|
| 144 |
+
quads,
|
| 145 |
+
counts,
|
| 146 |
+
counts_list,
|
| 147 |
+
)
|
| 148 |
+
expected = _legacy_geometry_inputs(model, proj_rects, mid_pts, quads, counts_list)
|
| 149 |
+
|
| 150 |
+
assert len(calls) == 1
|
| 151 |
+
assert calls[0][0] == torch.Size([5, 4, 4, 2])
|
| 152 |
+
torch.testing.assert_close(calls[0][1], counts, rtol=0, atol=0)
|
| 153 |
+
for actual_tensor, expected_tensor in zip(actual, expected):
|
| 154 |
+
torch.testing.assert_close(actual_tensor, expected_tensor, rtol=0, atol=0)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_batched_geometry_is_default_off_and_environment_opt_in(monkeypatch):
|
| 158 |
+
monkeypatch.delenv("NEMOTRON_OCR_BATCHED_RELATIONAL_GEOMETRY", raising=False)
|
| 159 |
+
assert not _make_model().batched_geometry
|
| 160 |
+
|
| 161 |
+
monkeypatch.setenv("NEMOTRON_OCR_BATCHED_RELATIONAL_GEOMETRY", "true")
|
| 162 |
+
assert _make_model().batched_geometry
|
nemotron-ocr/tests/test_relational_encoder_staging.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from nemotron_ocr.inference.encoders.relational_encoder import (
|
| 8 |
+
RelationalTargetEncoder,
|
| 9 |
+
_copy_tensors_to_cpu_batched,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
pytestmark = pytest.mark.skipif(
|
| 14 |
+
not torch.cuda.is_available(), reason="CUDA is required for D2H staging tests"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _canonical_graph(graph):
|
| 19 |
+
return tuple(
|
| 20 |
+
tuple(tuple(line) for line in paragraph) for paragraph in graph.paragraphs
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_batched_cpu_copy_preserves_noncontiguous_views_on_current_stream():
|
| 25 |
+
expected_base = (
|
| 26 |
+
torch.arange(42, dtype=torch.float32).reshape(6, 7).mul_(3).add_(1)
|
| 27 |
+
)
|
| 28 |
+
expected = [expected_base[:, 1:6], expected_base.t()[1:5, ::2]]
|
| 29 |
+
|
| 30 |
+
stream = torch.cuda.Stream()
|
| 31 |
+
with torch.cuda.stream(stream):
|
| 32 |
+
base = torch.arange(42, dtype=torch.float32, device="cuda").reshape(6, 7)
|
| 33 |
+
base.mul_(3).add_(1)
|
| 34 |
+
sources = [base[:, 1:6], base.t()[1:5, ::2]]
|
| 35 |
+
actual = _copy_tensors_to_cpu_batched(sources)
|
| 36 |
+
|
| 37 |
+
for source, copied in zip(expected, actual):
|
| 38 |
+
assert copied.is_pinned()
|
| 39 |
+
torch.testing.assert_close(copied, source, rtol=0, atol=0)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_batched_relational_conversion_matches_sequential_graph_build():
|
| 43 |
+
encoder = RelationalTargetEncoder(input_size=[768, 768], is_train=False)
|
| 44 |
+
|
| 45 |
+
# The first matrix includes an exact 0.5 tie in row 0. The C++ graph
|
| 46 |
+
# conversion selects the first qualifying column; staging must not alter it.
|
| 47 |
+
word_relations = [
|
| 48 |
+
torch.tensor(
|
| 49 |
+
[
|
| 50 |
+
[0.0, 0.5, 0.5, 0.0],
|
| 51 |
+
[0.0, 0.0, 0.0, 0.0],
|
| 52 |
+
[0.0, 0.0, 0.0, 0.9],
|
| 53 |
+
[0.0, 0.0, 0.0, 0.0],
|
| 54 |
+
],
|
| 55 |
+
dtype=torch.float32,
|
| 56 |
+
device="cuda",
|
| 57 |
+
),
|
| 58 |
+
torch.tensor(
|
| 59 |
+
[
|
| 60 |
+
[0.0, 0.8, 0.0],
|
| 61 |
+
[0.0, 0.0, 0.8],
|
| 62 |
+
[0.0, 0.0, 0.0],
|
| 63 |
+
],
|
| 64 |
+
dtype=torch.float32,
|
| 65 |
+
device="cuda",
|
| 66 |
+
),
|
| 67 |
+
torch.empty((0, 0), dtype=torch.float32, device="cuda"),
|
| 68 |
+
]
|
| 69 |
+
line_logits = [
|
| 70 |
+
torch.tensor(
|
| 71 |
+
[
|
| 72 |
+
[2.0, -torch.inf, 0.2, 3.0, 0.1],
|
| 73 |
+
[1.0, 0.3, -torch.inf, 0.2, 2.5],
|
| 74 |
+
[0.5, 2.0, 0.1, -torch.inf, 0.2],
|
| 75 |
+
[1.5, 0.2, 2.2, 0.1, -torch.inf],
|
| 76 |
+
],
|
| 77 |
+
dtype=torch.float32,
|
| 78 |
+
device="cuda",
|
| 79 |
+
),
|
| 80 |
+
torch.tensor(
|
| 81 |
+
[
|
| 82 |
+
[2.0, -torch.inf, 1.0, 0.2],
|
| 83 |
+
[0.5, 0.3, -torch.inf, 2.0],
|
| 84 |
+
[1.0, 2.0, 0.2, -torch.inf],
|
| 85 |
+
],
|
| 86 |
+
dtype=torch.float32,
|
| 87 |
+
device="cuda",
|
| 88 |
+
),
|
| 89 |
+
torch.empty((0, 1), dtype=torch.float32, device="cuda"),
|
| 90 |
+
]
|
| 91 |
+
line_uncertainty = [
|
| 92 |
+
torch.tensor(
|
| 93 |
+
[
|
| 94 |
+
[0.1, 0.0, 0.2, -0.1, 0.3],
|
| 95 |
+
[0.2, 0.1, 0.0, 0.3, -0.2],
|
| 96 |
+
[0.0, 0.2, 0.1, -0.1, 0.2],
|
| 97 |
+
[0.3, -0.1, 0.2, 0.1, 0.0],
|
| 98 |
+
],
|
| 99 |
+
dtype=torch.float32,
|
| 100 |
+
device="cuda",
|
| 101 |
+
),
|
| 102 |
+
torch.tensor(
|
| 103 |
+
[
|
| 104 |
+
[0.0, 0.1, -0.1, 0.2],
|
| 105 |
+
[0.2, 0.0, 0.1, -0.2],
|
| 106 |
+
[0.1, -0.1, 0.2, 0.0],
|
| 107 |
+
],
|
| 108 |
+
dtype=torch.float32,
|
| 109 |
+
device="cuda",
|
| 110 |
+
),
|
| 111 |
+
torch.empty((0, 1), dtype=torch.float32, device="cuda"),
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
region_counts = torch.tensor([4, 3, 0], dtype=torch.int64)
|
| 115 |
+
quads = torch.arange(7 * 4 * 2, dtype=torch.float32).reshape(7, 4, 2)
|
| 116 |
+
|
| 117 |
+
expected_graphs = []
|
| 118 |
+
for count, word_relation, line_relation, line_unc in zip(
|
| 119 |
+
region_counts.tolist(), word_relations, line_logits, line_uncertainty
|
| 120 |
+
):
|
| 121 |
+
if count == 0:
|
| 122 |
+
expected_graphs.append(())
|
| 123 |
+
continue
|
| 124 |
+
expected_graphs.append(
|
| 125 |
+
_canonical_graph(
|
| 126 |
+
encoder.dense_relations_to_graph(
|
| 127 |
+
word_relation.cpu(), line_relation, line_unc, is_gt=False
|
| 128 |
+
)
|
| 129 |
+
)
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
batch = encoder.convert_targets_to_labels(
|
| 133 |
+
{
|
| 134 |
+
"relations": word_relations,
|
| 135 |
+
"line_relations": line_logits,
|
| 136 |
+
"line_rel_var": line_uncertainty,
|
| 137 |
+
"region_counts": region_counts,
|
| 138 |
+
"quads": quads,
|
| 139 |
+
},
|
| 140 |
+
image_size=None,
|
| 141 |
+
is_gt=False,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
assert len(batch) == 3
|
| 145 |
+
assert [len(example) for example in batch] == [4, 3, 0]
|
| 146 |
+
assert [_canonical_graph(example.relation_graph) for example in batch] == expected_graphs
|
| 147 |
+
torch.testing.assert_close(
|
| 148 |
+
torch.stack([region.region.vertices for example in batch for region in example]),
|
| 149 |
+
quads,
|
| 150 |
+
rtol=0,
|
| 151 |
+
atol=0,
|
| 152 |
+
)
|