@geotechcli/core 0.4.77 → 0.4.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/agents/data-tools.js +38 -1
  2. package/dist/agents/data-tools.js.map +1 -1
  3. package/dist/agents/fem-artifact-guards.d.ts +14 -0
  4. package/dist/agents/fem-artifact-guards.d.ts.map +1 -0
  5. package/dist/agents/fem-artifact-guards.js +53 -0
  6. package/dist/agents/fem-artifact-guards.js.map +1 -0
  7. package/dist/agents/fem-tools.js +86 -1
  8. package/dist/agents/fem-tools.js.map +1 -1
  9. package/dist/agents/filesystem-tools.js +13 -0
  10. package/dist/agents/filesystem-tools.js.map +1 -1
  11. package/dist/agents/provider-operating-contract.d.ts +3 -3
  12. package/dist/agents/provider-operating-contract.d.ts.map +1 -1
  13. package/dist/agents/provider-operating-contract.js +10 -46
  14. package/dist/agents/provider-operating-contract.js.map +1 -1
  15. package/dist/agents/runtime-bootstrap.d.ts +1 -0
  16. package/dist/agents/runtime-bootstrap.d.ts.map +1 -1
  17. package/dist/agents/runtime-bootstrap.js +1 -0
  18. package/dist/agents/runtime-bootstrap.js.map +1 -1
  19. package/dist/agents/safety.d.ts.map +1 -1
  20. package/dist/agents/safety.js +33 -0
  21. package/dist/agents/safety.js.map +1 -1
  22. package/dist/agents/signal-tools.d.ts +2 -0
  23. package/dist/agents/signal-tools.d.ts.map +1 -0
  24. package/dist/agents/signal-tools.js +96 -0
  25. package/dist/agents/signal-tools.js.map +1 -0
  26. package/dist/agents/swarm-planner.js +1 -1
  27. package/dist/agents/swarm-planner.js.map +1 -1
  28. package/dist/agents/swarm.d.ts +1 -0
  29. package/dist/agents/swarm.d.ts.map +1 -1
  30. package/dist/agents/swarm.js +202 -31
  31. package/dist/agents/swarm.js.map +1 -1
  32. package/dist/fem/ground-model-draft.d.ts +14 -0
  33. package/dist/fem/ground-model-draft.d.ts.map +1 -1
  34. package/dist/fem/ground-model-draft.js +86 -21
  35. package/dist/fem/ground-model-draft.js.map +1 -1
  36. package/dist/fem/index.d.ts +1 -1
  37. package/dist/fem/index.d.ts.map +1 -1
  38. package/dist/fem/index.js +1 -1
  39. package/dist/fem/index.js.map +1 -1
  40. package/dist/fem/routing.d.ts +15 -1
  41. package/dist/fem/routing.d.ts.map +1 -1
  42. package/dist/fem/routing.js +192 -14
  43. package/dist/fem/routing.js.map +1 -1
  44. package/dist/fem/validation.d.ts.map +1 -1
  45. package/dist/fem/validation.js +715 -30
  46. package/dist/fem/validation.js.map +1 -1
  47. package/dist/fem/webgl.d.ts.map +1 -1
  48. package/dist/fem/webgl.js +24 -8
  49. package/dist/fem/webgl.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/ingest/document-evidence-packet.d.ts +603 -45
  55. package/dist/ingest/document-evidence-packet.d.ts.map +1 -1
  56. package/dist/ingest/document-evidence-packet.js +145 -5
  57. package/dist/ingest/document-evidence-packet.js.map +1 -1
  58. package/dist/ingest/geotech-benchmark-corpus.d.ts +108 -0
  59. package/dist/ingest/geotech-benchmark-corpus.d.ts.map +1 -0
  60. package/dist/ingest/geotech-benchmark-corpus.js +437 -0
  61. package/dist/ingest/geotech-benchmark-corpus.js.map +1 -0
  62. package/dist/ingest/geotech-document-benchmark.d.ts +133 -0
  63. package/dist/ingest/geotech-document-benchmark.d.ts.map +1 -1
  64. package/dist/ingest/geotech-document-benchmark.js +370 -2
  65. package/dist/ingest/geotech-document-benchmark.js.map +1 -1
  66. package/dist/ingest/geotech-document.d.ts +3 -0
  67. package/dist/ingest/geotech-document.d.ts.map +1 -1
  68. package/dist/ingest/geotech-document.js +7 -0
  69. package/dist/ingest/geotech-document.js.map +1 -1
  70. package/dist/ingest/index.d.ts +2 -1
  71. package/dist/ingest/index.d.ts.map +1 -1
  72. package/dist/ingest/index.js +1 -0
  73. package/dist/ingest/index.js.map +1 -1
  74. package/dist/ingest/job-store.d.ts.map +1 -1
  75. package/dist/ingest/job-store.js +196 -0
  76. package/dist/ingest/job-store.js.map +1 -1
  77. package/dist/ingest/job-worker.d.ts.map +1 -1
  78. package/dist/ingest/job-worker.js +5 -0
  79. package/dist/ingest/job-worker.js.map +1 -1
  80. package/dist/ingest/page-evidence-cache.d.ts +6 -2
  81. package/dist/ingest/page-evidence-cache.d.ts.map +1 -1
  82. package/dist/ingest/page-evidence-cache.js +229 -4
  83. package/dist/ingest/page-evidence-cache.js.map +1 -1
  84. package/dist/ingest/pdf.d.ts.map +1 -1
  85. package/dist/ingest/pdf.js +2 -2
  86. package/dist/ingest/pdf.js.map +1 -1
  87. package/dist/ingest/review-store.d.ts +3 -0
  88. package/dist/ingest/review-store.d.ts.map +1 -1
  89. package/dist/ingest/review-store.js +28 -0
  90. package/dist/ingest/review-store.js.map +1 -1
  91. package/dist/llm/capabilities.d.ts +6 -1
  92. package/dist/llm/capabilities.d.ts.map +1 -1
  93. package/dist/llm/capabilities.js +66 -0
  94. package/dist/llm/capabilities.js.map +1 -1
  95. package/dist/llm/index.d.ts +2 -2
  96. package/dist/llm/index.d.ts.map +1 -1
  97. package/dist/llm/index.js +1 -1
  98. package/dist/llm/index.js.map +1 -1
  99. package/dist/llm/types.d.ts +20 -0
  100. package/dist/llm/types.d.ts.map +1 -1
  101. package/dist/llm/types.js.map +1 -1
  102. package/dist/meta/metadata.json +1 -1
  103. package/dist/report/ingest-dossier.d.ts.map +1 -1
  104. package/dist/report/ingest-dossier.js +13 -1
  105. package/dist/report/ingest-dossier.js.map +1 -1
  106. package/dist/signal/index.d.ts +95 -0
  107. package/dist/signal/index.d.ts.map +1 -0
  108. package/dist/signal/index.js +375 -0
  109. package/dist/signal/index.js.map +1 -0
  110. package/dist/verifier/findings.d.ts +1 -1
  111. package/dist/verifier/findings.d.ts.map +1 -1
  112. package/dist/verifier/findings.js +329 -0
  113. package/dist/verifier/findings.js.map +1 -1
  114. package/dist/vision/ocr.d.ts +2 -0
  115. package/dist/vision/ocr.d.ts.map +1 -1
  116. package/dist/vision/ocr.js +148 -2
  117. package/dist/vision/ocr.js.map +1 -1
  118. package/dist/vision/preprocess.d.ts +66 -1
  119. package/dist/vision/preprocess.d.ts.map +1 -1
  120. package/dist/vision/preprocess.js +728 -9
  121. package/dist/vision/preprocess.js.map +1 -1
  122. package/dist/workspace/project-workflow-executor.d.ts +1 -1
  123. package/dist/workspace/project-workflow-executor.d.ts.map +1 -1
  124. package/dist/workspace/project-workflow-executor.js +216 -4
  125. package/dist/workspace/project-workflow-executor.js.map +1 -1
  126. package/dist/workspace/project-workflow-router.d.ts.map +1 -1
  127. package/dist/workspace/project-workflow-router.js +41 -1
  128. package/dist/workspace/project-workflow-router.js.map +1 -1
  129. package/package.json +1 -1
@@ -1,6 +1,9 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { createRequire } from 'node:module';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
5
+ export const VISION_IMAGE_PREPROCESS_METADATA_SCHEMA_VERSION = 1;
6
+ export const VISION_IMAGE_PREPROCESS_PIPELINE_VERSION = 'vision-image-preprocess-v2';
4
7
  const require = createRequire(import.meta.url);
5
8
  let cachedPdfRendererModule = null;
6
9
  let cachedCanvasFactory = null;
@@ -16,6 +19,625 @@ async function loadSharp() {
16
19
  return null;
17
20
  }
18
21
  }
22
+ async function readImageMetadata(sharp, buffer, mimeType) {
23
+ const base = {
24
+ mimeType,
25
+ byteLength: Buffer.byteLength(Buffer.from(buffer)),
26
+ };
27
+ if (!sharp) {
28
+ return base;
29
+ }
30
+ try {
31
+ const metadata = await sharp(Buffer.from(buffer), { pages: 1 }).metadata();
32
+ return {
33
+ ...base,
34
+ ...(Number.isFinite(metadata.width) ? { width: metadata.width } : {}),
35
+ ...(Number.isFinite(metadata.height) ? { height: metadata.height } : {}),
36
+ };
37
+ }
38
+ catch {
39
+ return base;
40
+ }
41
+ }
42
+ function buildPreprocessingMetadata(input) {
43
+ const regions = input.regions?.length
44
+ ? input.regions
45
+ : [{
46
+ id: input.transformed ? 'normalized-full-page' : 'original-full-page',
47
+ source: 'preprocessing',
48
+ label: input.transformed ? 'normalized full page' : 'original full page',
49
+ bbox2d: [0, 0, 1, 1],
50
+ coverageRatio: 1,
51
+ }];
52
+ return {
53
+ schemaVersion: VISION_IMAGE_PREPROCESS_METADATA_SCHEMA_VERSION,
54
+ pipelineVersion: VISION_IMAGE_PREPROCESS_PIPELINE_VERSION,
55
+ policy: input.policy,
56
+ transformed: input.transformed,
57
+ input: input.input,
58
+ output: input.output,
59
+ operations: [...new Set(input.operations.map((operation) => operation.trim()).filter(Boolean))],
60
+ regions,
61
+ ...(input.quality ? { quality: input.quality } : {}),
62
+ warnings: [...new Set(input.warnings.map((warning) => warning.trim()).filter(Boolean))],
63
+ };
64
+ }
65
+ export function resolveVisionImagePreprocessPolicy(value = process.env.GEOTECHCLI_PREPROCESSING_MODE) {
66
+ const normalized = String(value ?? '').trim().toLowerCase();
67
+ if (normalized === 'none') {
68
+ return 'none';
69
+ }
70
+ if (normalized === 'region-v2' || normalized === 'region_v2' || normalized === 'regions-v2') {
71
+ return 'region-v2';
72
+ }
73
+ return 'ocr-optimized';
74
+ }
75
+ const DEFAULT_DESKEW = {
76
+ method: 'projection-profile',
77
+ angleDeg: 0,
78
+ confidence: 0,
79
+ applied: false,
80
+ };
81
+ async function readRawPreprocessImage(sharp, buffer) {
82
+ const { data, info } = await sharp(buffer, { pages: 1 })
83
+ .grayscale()
84
+ .raw()
85
+ .toBuffer({ resolveWithObject: true });
86
+ const width = Number(info.width);
87
+ const height = Number(info.height);
88
+ const channels = Number(info.channels) || 1;
89
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 32 || height < 32) {
90
+ return null;
91
+ }
92
+ const darkThreshold = 238;
93
+ const rowDarkCounts = new Array(height).fill(0);
94
+ const colDarkCounts = new Array(width).fill(0);
95
+ let minX = width;
96
+ let minY = height;
97
+ let maxX = -1;
98
+ let maxY = -1;
99
+ let darkCount = 0;
100
+ for (let y = 0; y < height; y += 1) {
101
+ for (let x = 0; x < width; x += 1) {
102
+ const offset = (y * width + x) * channels;
103
+ const value = data[offset] ?? 255;
104
+ if (value < darkThreshold) {
105
+ rowDarkCounts[y] += 1;
106
+ colDarkCounts[x] += 1;
107
+ darkCount += 1;
108
+ if (x < minX)
109
+ minX = x;
110
+ if (x > maxX)
111
+ maxX = x;
112
+ if (y < minY)
113
+ minY = y;
114
+ if (y > maxY)
115
+ maxY = y;
116
+ }
117
+ }
118
+ }
119
+ return {
120
+ data,
121
+ width,
122
+ height,
123
+ channels,
124
+ rowDarkCounts,
125
+ colDarkCounts,
126
+ darkCount,
127
+ minX,
128
+ minY,
129
+ maxX,
130
+ maxY,
131
+ };
132
+ }
133
+ async function estimateDeskewCorrection(sharp, buffer, options = {}) {
134
+ try {
135
+ const raw = await readRawPreprocessImage(sharp, buffer);
136
+ if (!raw || raw.darkCount < 80 || raw.maxX <= raw.minX || raw.maxY <= raw.minY) {
137
+ return DEFAULT_DESKEW;
138
+ }
139
+ const points = [];
140
+ const targetSamples = 75000;
141
+ const stride = Math.max(1, Math.ceil(Math.sqrt((raw.width * raw.height) / targetSamples)));
142
+ for (let y = raw.minY; y <= raw.maxY; y += stride) {
143
+ for (let x = raw.minX; x <= raw.maxX; x += stride) {
144
+ const value = raw.data[(y * raw.width + x) * raw.channels] ?? 255;
145
+ if (value < 210) {
146
+ points.push([x - raw.width / 2, y - raw.height / 2]);
147
+ }
148
+ }
149
+ }
150
+ if (points.length < 80) {
151
+ return DEFAULT_DESKEW;
152
+ }
153
+ let bestAngle = 0;
154
+ let bestScore = -Infinity;
155
+ let secondBestScore = -Infinity;
156
+ const scoreAngle = (angle) => {
157
+ const radians = (angle * Math.PI) / 180;
158
+ const sin = Math.sin(radians);
159
+ const cos = Math.cos(radians);
160
+ const rowBins = new Map();
161
+ const colBins = new Map();
162
+ for (const [x, y] of points) {
163
+ const rotatedX = x * cos - y * sin;
164
+ const rotatedY = x * sin + y * cos;
165
+ const rowBin = Math.round(rotatedY / 2);
166
+ const colBin = Math.round(rotatedX / 2);
167
+ rowBins.set(rowBin, (rowBins.get(rowBin) ?? 0) + 1);
168
+ colBins.set(colBin, (colBins.get(colBin) ?? 0) + 1);
169
+ }
170
+ let score = 0;
171
+ for (const count of rowBins.values()) {
172
+ score += count * count;
173
+ }
174
+ for (const count of colBins.values()) {
175
+ score += count * count;
176
+ }
177
+ return score;
178
+ };
179
+ for (let angle = -4; angle <= 4.0001; angle += 0.25) {
180
+ const score = scoreAngle(angle);
181
+ if (score > bestScore) {
182
+ secondBestScore = bestScore;
183
+ bestScore = score;
184
+ bestAngle = angle;
185
+ }
186
+ else if (score > secondBestScore) {
187
+ secondBestScore = score;
188
+ }
189
+ }
190
+ if (options.finePass) {
191
+ const coarseBestAngle = bestAngle;
192
+ for (let angle = coarseBestAngle - 0.3; angle <= coarseBestAngle + 0.3001; angle += 0.05) {
193
+ const rounded = Math.round(angle * 100) / 100;
194
+ const score = scoreAngle(rounded);
195
+ if (score > bestScore) {
196
+ secondBestScore = bestScore;
197
+ bestScore = score;
198
+ bestAngle = rounded;
199
+ }
200
+ else if (score > secondBestScore) {
201
+ secondBestScore = score;
202
+ }
203
+ }
204
+ }
205
+ const confidence = bestScore > 0
206
+ ? clampRatio((bestScore - Math.max(0, secondBestScore)) / bestScore * 8)
207
+ : 0;
208
+ const applied = Math.abs(bestAngle) >= 0.35 && (confidence >= 0.01 || Math.abs(bestAngle) >= 1);
209
+ return {
210
+ method: options.finePass ? 'projection-profile-fine' : 'projection-profile',
211
+ angleDeg: roundAngle(applied ? bestAngle : 0),
212
+ confidence: roundRatio(confidence),
213
+ applied,
214
+ };
215
+ }
216
+ catch {
217
+ return DEFAULT_DESKEW;
218
+ }
219
+ }
220
+ async function detectPreprocessingRegions(sharp, buffer, policy = 'ocr-optimized') {
221
+ const regions = [{
222
+ id: 'normalized-full-page',
223
+ source: 'preprocessing',
224
+ label: 'normalized full page',
225
+ bbox2d: [0, 0, 1, 1],
226
+ coverageRatio: 1,
227
+ }];
228
+ try {
229
+ const raw = await readRawPreprocessImage(sharp, buffer);
230
+ if (!raw || raw.darkCount === 0 || raw.maxX <= raw.minX || raw.maxY <= raw.minY) {
231
+ return scorePreprocessRegions(raw, regions);
232
+ }
233
+ const contentRegion = buildRegionFromPixels('content-bounding-box', 'detected content bounding box', raw.minX, raw.minY, raw.maxX, raw.maxY, raw.width, raw.height);
234
+ if (contentRegion.coverageRatio != null && contentRegion.coverageRatio < 0.96) {
235
+ regions.push(contentRegion);
236
+ }
237
+ const panelRegions = buildTableLogPanelCandidates(raw, { regionV2: policy === 'region-v2' });
238
+ for (const region of panelRegions) {
239
+ if (!regions.some((existing) => existing.id !== 'normalized-full-page'
240
+ && existing.id !== 'original-full-page'
241
+ && regionOverlap(existing, region) > 0.88)) {
242
+ regions.push(region);
243
+ }
244
+ }
245
+ return scorePreprocessRegions(raw, regions);
246
+ }
247
+ catch {
248
+ return regions;
249
+ }
250
+ }
251
+ function buildTableLogPanelCandidates(raw, options = {}) {
252
+ const rowThreshold = Math.max(24, Math.round(raw.width * 0.07));
253
+ const colThreshold = Math.max(24, Math.round(raw.height * 0.055));
254
+ const heavyRows = raw.rowDarkCounts
255
+ .map((count, index) => ({ count, index }))
256
+ .filter((row) => row.count >= rowThreshold)
257
+ .map((row) => row.index);
258
+ const heavyCols = raw.colDarkCounts
259
+ .map((count, index) => ({ count, index }))
260
+ .filter((col) => col.count >= colThreshold)
261
+ .map((col) => col.index);
262
+ if (heavyRows.length < 4 || heavyCols.length < 3) {
263
+ return [];
264
+ }
265
+ const rowSpan = mergeLineClusters(groupContiguous(heavyRows, Math.max(3, Math.round(raw.height * 0.004))), Math.round(raw.height * 0.18))
266
+ .reduce((span, group) => {
267
+ if (!span)
268
+ return { ...group };
269
+ return {
270
+ start: Math.min(span.start, group.start),
271
+ end: Math.max(span.end, group.end),
272
+ count: span.count + group.count,
273
+ };
274
+ }, null);
275
+ const colClusters = mergeLineClusters(groupContiguous(heavyCols, Math.max(3, Math.round(raw.width * 0.004))), Math.round(raw.width * 0.14))
276
+ .filter((cluster) => cluster.count >= 3 || cluster.end - cluster.start >= raw.width * 0.08);
277
+ if (!rowSpan || colClusters.length === 0) {
278
+ return [];
279
+ }
280
+ const full = buildRegionFromPixels('table-log-panel-candidate', 'detected table/log panel candidate', Math.min(...heavyCols), Math.min(...heavyRows), Math.max(...heavyCols), Math.max(...heavyRows), raw.width, raw.height, 8);
281
+ const candidates = [full];
282
+ for (const [index, cluster] of colClusters.entries()) {
283
+ const width = cluster.end - cluster.start;
284
+ const height = rowSpan.end - rowSpan.start;
285
+ const coverage = (width * height) / Math.max(1, raw.width * raw.height);
286
+ if (coverage < 0.025 || coverage > 0.98) {
287
+ continue;
288
+ }
289
+ const tall = height / Math.max(1, width) >= 1.35;
290
+ candidates.push(buildRegionFromPixels(tall ? `borehole-log-panel-candidate-${index + 1}` : `table-log-panel-candidate-${index + 2}`, tall ? 'detected borehole/log strip candidate' : 'detected table/log panel candidate', cluster.start, rowSpan.start, cluster.end, rowSpan.end, raw.width, raw.height, 10));
291
+ }
292
+ const regionV2Candidates = options.regionV2 ? buildRegionV2PanelCandidates(raw, rowSpan) : [];
293
+ const candidatePool = options.regionV2
294
+ ? [...regionV2Candidates, full, ...candidates.slice(1)]
295
+ : candidates;
296
+ const deduped = [];
297
+ for (const candidate of candidatePool) {
298
+ const coverage = candidate.coverageRatio ?? 0;
299
+ const maxCoverage = options.regionV2 && candidate.id === 'region-v2-table-panel-overview' ? 1 : 0.98;
300
+ if (coverage < 0.025 || coverage > maxCoverage) {
301
+ continue;
302
+ }
303
+ if (!deduped.some((existing) => regionOverlap(existing, candidate) > 0.88)) {
304
+ deduped.push(candidate);
305
+ }
306
+ }
307
+ return deduped.slice(0, options.regionV2 ? 8 : 5);
308
+ }
309
+ function buildRegionV2PanelCandidates(raw, rowSpan) {
310
+ const activeColThreshold = Math.max(8, Math.round(raw.height * 0.012));
311
+ const activeCols = raw.colDarkCounts
312
+ .map((count, index) => ({ count, index }))
313
+ .filter((col) => col.count >= activeColThreshold)
314
+ .map((col) => col.index);
315
+ const colClusters = mergeLineClusters(groupContiguous(activeCols, Math.max(4, Math.round(raw.width * 0.01))), Math.max(12, Math.round(raw.width * 0.035))).filter((cluster) => {
316
+ const width = cluster.end - cluster.start + 1;
317
+ return width >= raw.width * 0.055 && width <= raw.width * 0.58;
318
+ });
319
+ const candidates = [];
320
+ const broadCoverage = ((raw.maxX - raw.minX + 1) * (rowSpan.end - rowSpan.start + 1)) / Math.max(1, raw.width * raw.height);
321
+ if (broadCoverage >= 0.02) {
322
+ candidates.push(buildRegionFromPixels('region-v2-table-panel-overview', 'region-v2 table panel crop', raw.minX, rowSpan.start, raw.maxX, rowSpan.end, raw.width, raw.height, 14));
323
+ }
324
+ for (const [index, cluster] of colClusters.entries()) {
325
+ const localBounds = boundsForColumnRange(raw, cluster.start, cluster.end, rowSpan);
326
+ if (!localBounds) {
327
+ continue;
328
+ }
329
+ const width = localBounds.maxX - localBounds.minX + 1;
330
+ const height = localBounds.maxY - localBounds.minY + 1;
331
+ const coverage = (width * height) / Math.max(1, raw.width * raw.height);
332
+ if (coverage < 0.02 || coverage > 0.92) {
333
+ continue;
334
+ }
335
+ const tall = height / Math.max(1, width) >= 1.22;
336
+ const label = tall
337
+ ? 'region-v2 borehole/log strip crop'
338
+ : 'region-v2 table panel crop';
339
+ candidates.push(buildRegionFromPixels(tall ? `region-v2-borehole-log-strip-${index + 1}` : `region-v2-table-panel-${index + 1}`, label, localBounds.minX, localBounds.minY, localBounds.maxX, localBounds.maxY, raw.width, raw.height, 14));
340
+ }
341
+ return candidates;
342
+ }
343
+ function boundsForColumnRange(raw, minCol, maxCol, rowSpan) {
344
+ let minX = raw.width;
345
+ let minY = raw.height;
346
+ let maxX = -1;
347
+ let maxY = -1;
348
+ const left = Math.max(0, minCol);
349
+ const right = Math.min(raw.width - 1, maxCol);
350
+ const top = Math.max(0, rowSpan.start);
351
+ const bottom = Math.min(raw.height - 1, rowSpan.end);
352
+ for (let y = top; y <= bottom; y += 1) {
353
+ for (let x = left; x <= right; x += 1) {
354
+ const value = raw.data[(y * raw.width + x) * raw.channels] ?? 255;
355
+ if (value < 238) {
356
+ if (x < minX)
357
+ minX = x;
358
+ if (x > maxX)
359
+ maxX = x;
360
+ if (y < minY)
361
+ minY = y;
362
+ if (y > maxY)
363
+ maxY = y;
364
+ }
365
+ }
366
+ }
367
+ return maxX > minX && maxY > minY ? { minX, minY, maxX, maxY } : null;
368
+ }
369
+ function scorePreprocessRegions(raw, regions) {
370
+ if (!raw) {
371
+ return regions;
372
+ }
373
+ return regions.map((region) => ({
374
+ ...region,
375
+ quality: scoreRegionQuality(raw, region),
376
+ }));
377
+ }
378
+ async function attachRegionAssets(sharp, buffer, regions) {
379
+ const metadata = await readImageMetadata(sharp, buffer, 'image/png');
380
+ const width = metadata.width ?? 0;
381
+ const height = metadata.height ?? 0;
382
+ if (width < 16 || height < 16) {
383
+ return regions;
384
+ }
385
+ const cropped = await Promise.all(regions.map(async (region) => {
386
+ if (!region.bbox2d || region.id === 'normalized-full-page') {
387
+ return region;
388
+ }
389
+ const left = Math.max(0, Math.floor(region.bbox2d[0] * width));
390
+ const top = Math.max(0, Math.floor(region.bbox2d[1] * height));
391
+ const right = Math.min(width, Math.ceil(region.bbox2d[2] * width));
392
+ const bottom = Math.min(height, Math.ceil(region.bbox2d[3] * height));
393
+ const cropWidth = Math.max(0, right - left);
394
+ const cropHeight = Math.max(0, bottom - top);
395
+ if (cropWidth < 16 || cropHeight < 16) {
396
+ return region;
397
+ }
398
+ try {
399
+ const crop = await sharp(buffer, { pages: 1 })
400
+ .extract({
401
+ left,
402
+ top,
403
+ width: cropWidth,
404
+ height: cropHeight,
405
+ })
406
+ .resize({
407
+ width: 1400,
408
+ height: 1400,
409
+ fit: 'inside',
410
+ withoutEnlargement: true,
411
+ })
412
+ .grayscale()
413
+ .normalize()
414
+ .sharpen()
415
+ .png()
416
+ .toBuffer();
417
+ const cropMetadata = await readImageMetadata(sharp, crop, 'image/png');
418
+ return {
419
+ ...region,
420
+ asset: {
421
+ mimeType: 'image/png',
422
+ byteLength: crop.length,
423
+ sha256: createHash('sha256').update(crop).digest('hex'),
424
+ ...(cropMetadata.width != null ? { width: cropMetadata.width } : {}),
425
+ ...(cropMetadata.height != null ? { height: cropMetadata.height } : {}),
426
+ normalized: true,
427
+ dataBase64: crop.toString('base64'),
428
+ },
429
+ };
430
+ }
431
+ catch {
432
+ return region;
433
+ }
434
+ }));
435
+ return cropped;
436
+ }
437
+ async function scoreImagePreprocessQuality(sharp, buffer, regions, deskew) {
438
+ try {
439
+ const raw = await readRawPreprocessImage(sharp, buffer);
440
+ if (!raw) {
441
+ return {
442
+ score: 0,
443
+ contentCoverageRatio: 0,
444
+ darkPixelRatio: 0,
445
+ regionCoverageRatio: 0,
446
+ regionCount: 0,
447
+ cropAssetCount: 0,
448
+ deskew,
449
+ warnings: ['image-quality-unavailable'],
450
+ };
451
+ }
452
+ const pageArea = Math.max(1, raw.width * raw.height);
453
+ const darkPixelRatio = raw.darkCount / pageArea;
454
+ const contentCoverageRatio = raw.maxX > raw.minX && raw.maxY > raw.minY
455
+ ? ((raw.maxX - raw.minX + 1) * (raw.maxY - raw.minY + 1)) / pageArea
456
+ : 0;
457
+ const cropRegions = regions.filter((region) => region.id !== 'normalized-full-page' && region.id !== 'original-full-page');
458
+ const regionCoverageRatio = Math.min(1, cropRegions.reduce((sum, region) => sum + (region.coverageRatio ?? 0), 0));
459
+ const cropAssetCount = cropRegions.filter((region) => region.asset).length;
460
+ const warnings = [
461
+ darkPixelRatio < 0.002 ? 'very-low-content-density' : null,
462
+ contentCoverageRatio < 0.03 ? 'low-page-content-coverage' : null,
463
+ contentCoverageRatio > 0.97 ? 'content-nearly-full-page' : null,
464
+ cropRegions.length === 0 ? 'no-log-or-table-crops-detected' : null,
465
+ cropRegions.some((region) => (region.quality?.score ?? 1) < 0.45) ? 'low-quality-region-crop' : null,
466
+ ].filter((value) => value != null);
467
+ let score = 0.45;
468
+ score += Math.min(0.2, darkPixelRatio * 3);
469
+ score += contentCoverageRatio >= 0.03 && contentCoverageRatio <= 0.97 ? 0.12 : 0;
470
+ score += Math.min(0.12, regionCoverageRatio * 0.45);
471
+ score += Math.min(0.08, cropRegions.length * 0.02);
472
+ score += cropAssetCount > 0 ? 0.08 : 0;
473
+ score += deskew.applied ? 0.04 : 0;
474
+ score -= Math.min(0.18, warnings.length * 0.045);
475
+ return {
476
+ score: roundRatio(score),
477
+ contentCoverageRatio: roundRatio(contentCoverageRatio),
478
+ darkPixelRatio: roundRatio(darkPixelRatio),
479
+ regionCoverageRatio: roundRatio(regionCoverageRatio),
480
+ regionCount: cropRegions.length,
481
+ cropAssetCount,
482
+ deskew,
483
+ warnings,
484
+ };
485
+ }
486
+ catch {
487
+ return {
488
+ score: 0,
489
+ contentCoverageRatio: 0,
490
+ darkPixelRatio: 0,
491
+ regionCoverageRatio: 0,
492
+ regionCount: 0,
493
+ cropAssetCount: 0,
494
+ deskew,
495
+ warnings: ['image-quality-scoring-failed'],
496
+ };
497
+ }
498
+ }
499
+ function buildRegionFromPixels(id, label, minX, minY, maxX, maxY, width, height, padding = 4) {
500
+ const left = clampRatio((minX - padding) / width);
501
+ const top = clampRatio((minY - padding) / height);
502
+ const right = clampRatio((maxX + 1 + padding) / width);
503
+ const bottom = clampRatio((maxY + 1 + padding) / height);
504
+ const area = Math.max(0, right - left) * Math.max(0, bottom - top);
505
+ return {
506
+ id,
507
+ source: 'preprocessing',
508
+ label,
509
+ bbox2d: [roundRatio(left), roundRatio(top), roundRatio(right), roundRatio(bottom)],
510
+ coverageRatio: roundRatio(area),
511
+ };
512
+ }
513
+ function scoreRegionQuality(raw, region) {
514
+ const bbox = region.bbox2d ?? [0, 0, 1, 1];
515
+ const left = Math.max(0, Math.floor(bbox[0] * raw.width));
516
+ const top = Math.max(0, Math.floor(bbox[1] * raw.height));
517
+ const right = Math.min(raw.width, Math.ceil(bbox[2] * raw.width));
518
+ const bottom = Math.min(raw.height, Math.ceil(bbox[3] * raw.height));
519
+ const width = Math.max(0, right - left);
520
+ const height = Math.max(0, bottom - top);
521
+ const area = Math.max(1, width * height);
522
+ let dark = 0;
523
+ let rowHits = 0;
524
+ let colHits = 0;
525
+ for (let y = top; y < bottom; y += 1) {
526
+ let rowDark = 0;
527
+ for (let x = left; x < right; x += 1) {
528
+ const value = raw.data[(y * raw.width + x) * raw.channels] ?? 255;
529
+ if (value < 238) {
530
+ dark += 1;
531
+ rowDark += 1;
532
+ }
533
+ }
534
+ if (width > 0 && rowDark / width >= 0.18) {
535
+ rowHits += 1;
536
+ }
537
+ }
538
+ for (let x = left; x < right; x += 1) {
539
+ let colDark = 0;
540
+ for (let y = top; y < bottom; y += 1) {
541
+ const value = raw.data[(y * raw.width + x) * raw.channels] ?? 255;
542
+ if (value < 238) {
543
+ colDark += 1;
544
+ }
545
+ }
546
+ if (height > 0 && colDark / height >= 0.12) {
547
+ colHits += 1;
548
+ }
549
+ }
550
+ const darkPixelRatio = dark / area;
551
+ const lineDensity = Math.min(1, (rowHits + colHits) / Math.max(1, width + height));
552
+ const coverageRatio = Math.max(0, Math.min(1, region.coverageRatio ?? area / Math.max(1, raw.width * raw.height)));
553
+ const warnings = [
554
+ darkPixelRatio < 0.002 ? 'low-region-content-density' : null,
555
+ coverageRatio < 0.02 ? 'small-region-coverage' : null,
556
+ coverageRatio > 0.98 ? 'region-covers-full-page' : null,
557
+ region.id.includes('table') || region.id.includes('log')
558
+ ? lineDensity < 0.004 ? 'weak-table-line-structure' : null
559
+ : null,
560
+ ].filter((value) => value != null);
561
+ let score = 0.35;
562
+ score += Math.min(0.25, darkPixelRatio * 4);
563
+ score += Math.min(0.25, lineDensity * 35);
564
+ score += coverageRatio >= 0.02 && coverageRatio <= 0.95 ? 0.12 : 0;
565
+ score -= Math.min(0.16, warnings.length * 0.04);
566
+ return {
567
+ score: roundRatio(score),
568
+ darkPixelRatio: roundRatio(darkPixelRatio),
569
+ lineDensity: roundRatio(lineDensity),
570
+ coverageRatio: roundRatio(coverageRatio),
571
+ warnings,
572
+ };
573
+ }
574
+ function groupContiguous(values, maxGap = 1) {
575
+ if (values.length === 0) {
576
+ return [];
577
+ }
578
+ const sorted = [...new Set(values)].sort((left, right) => left - right);
579
+ const groups = [];
580
+ let start = sorted[0];
581
+ let end = start;
582
+ let count = 1;
583
+ for (const value of sorted.slice(1)) {
584
+ if (value - end <= maxGap + 1) {
585
+ end = value;
586
+ count += 1;
587
+ continue;
588
+ }
589
+ groups.push({ start, end, count });
590
+ start = value;
591
+ end = value;
592
+ count = 1;
593
+ }
594
+ groups.push({ start, end, count });
595
+ return groups;
596
+ }
597
+ function mergeLineClusters(groups, maxGap) {
598
+ if (groups.length === 0) {
599
+ return [];
600
+ }
601
+ const merged = [];
602
+ let current = { ...groups[0] };
603
+ for (const group of groups.slice(1)) {
604
+ if (group.start - current.end <= maxGap) {
605
+ current = {
606
+ start: current.start,
607
+ end: group.end,
608
+ count: current.count + group.count,
609
+ };
610
+ continue;
611
+ }
612
+ merged.push(current);
613
+ current = { ...group };
614
+ }
615
+ merged.push(current);
616
+ return merged;
617
+ }
618
+ function regionOverlap(left, right) {
619
+ if (!left.bbox2d || !right.bbox2d) {
620
+ return 0;
621
+ }
622
+ const x1 = Math.max(left.bbox2d[0], right.bbox2d[0]);
623
+ const y1 = Math.max(left.bbox2d[1], right.bbox2d[1]);
624
+ const x2 = Math.min(left.bbox2d[2], right.bbox2d[2]);
625
+ const y2 = Math.min(left.bbox2d[3], right.bbox2d[3]);
626
+ const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
627
+ const leftArea = Math.max(0, left.bbox2d[2] - left.bbox2d[0]) * Math.max(0, left.bbox2d[3] - left.bbox2d[1]);
628
+ const rightArea = Math.max(0, right.bbox2d[2] - right.bbox2d[0]) * Math.max(0, right.bbox2d[3] - right.bbox2d[1]);
629
+ const union = leftArea + rightArea - intersection;
630
+ return union > 0 ? intersection / union : 0;
631
+ }
632
+ function clampRatio(value) {
633
+ return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
634
+ }
635
+ function roundRatio(value) {
636
+ return Math.round(value * 1000) / 1000;
637
+ }
638
+ function roundAngle(value) {
639
+ return Math.round(value * 100) / 100;
640
+ }
19
641
  async function loadPdfRendererModule() {
20
642
  if (!cachedPdfRendererUrl) {
21
643
  const pdfjsPackagePath = require.resolve('pdfjs-dist/package.json');
@@ -56,35 +678,82 @@ export async function encodeRawRasterToPng(pixels, details) {
56
678
  .png()
57
679
  .toBuffer();
58
680
  }
59
- export async function preprocessVisionImageBuffer(buffer, mimeType, policy = 'ocr-optimized') {
681
+ export async function preprocessVisionImageBuffer(buffer, mimeType, policy = resolveVisionImagePreprocessPolicy()) {
60
682
  if (policy === 'none') {
683
+ const input = await readImageMetadata(null, buffer, mimeType);
684
+ const metadata = buildPreprocessingMetadata({
685
+ policy,
686
+ transformed: false,
687
+ input,
688
+ output: input,
689
+ operations: [],
690
+ warnings: [],
691
+ });
61
692
  return {
62
693
  buffer: Buffer.from(buffer),
63
694
  mimeType,
64
695
  transformed: false,
65
696
  warnings: [],
697
+ preprocessing: metadata,
66
698
  };
67
699
  }
68
700
  const sharp = await loadSharp();
701
+ const input = await readImageMetadata(sharp, buffer, mimeType);
69
702
  if (!sharp) {
703
+ const warnings = ['Image preprocessing skipped because sharp is unavailable in this runtime.'];
70
704
  return {
71
705
  buffer: Buffer.from(buffer),
72
706
  mimeType,
73
707
  transformed: false,
74
- warnings: ['Image preprocessing skipped because sharp is unavailable in this runtime.'],
708
+ warnings,
709
+ preprocessing: buildPreprocessingMetadata({
710
+ policy,
711
+ transformed: false,
712
+ input,
713
+ output: input,
714
+ operations: [],
715
+ warnings,
716
+ }),
75
717
  };
76
718
  }
77
719
  try {
78
- const output = await sharp(Buffer.from(buffer), { pages: 1 })
720
+ let working = await sharp(Buffer.from(buffer), { pages: 1 })
79
721
  .rotate()
80
722
  .flatten({ background: '#ffffff' })
723
+ .png()
724
+ .toBuffer();
725
+ const operations = [
726
+ 'auto-orient',
727
+ 'flatten-white-background',
728
+ ];
729
+ const regionV2 = policy === 'region-v2';
730
+ const deskew = await estimateDeskewCorrection(sharp, working, { finePass: regionV2 });
731
+ if (deskew.applied) {
732
+ working = await sharp(working, { pages: 1 })
733
+ .rotate(deskew.angleDeg, { background: '#ffffff' })
734
+ .flatten({ background: '#ffffff' })
735
+ .png()
736
+ .toBuffer();
737
+ operations.push(`deskew-angle-${deskew.angleDeg.toFixed(2)}deg`);
738
+ if (regionV2) {
739
+ operations.push('projection-profile-fine-deskew');
740
+ }
741
+ }
742
+ else {
743
+ operations.push('deskew-not-applied');
744
+ if (regionV2) {
745
+ operations.push('projection-profile-fine-deskew-not-applied');
746
+ }
747
+ }
748
+ const normalizedMaxDimension = regionV2 ? 2200 : 1800;
749
+ const output = await sharp(working, { pages: 1 })
81
750
  .trim({
82
751
  background: '#ffffff',
83
752
  threshold: 10,
84
753
  })
85
754
  .resize({
86
- width: 1800,
87
- height: 1800,
755
+ width: normalizedMaxDimension,
756
+ height: normalizedMaxDimension,
88
757
  fit: 'inside',
89
758
  withoutEnlargement: true,
90
759
  })
@@ -93,21 +762,70 @@ export async function preprocessVisionImageBuffer(buffer, mimeType, policy = 'oc
93
762
  .sharpen()
94
763
  .png()
95
764
  .toBuffer();
765
+ const outputMetadata = await readImageMetadata(sharp, output, 'image/png');
766
+ operations.push('trim-white-margins-threshold-10', `resize-inside-${normalizedMaxDimension}-no-enlarge`, 'grayscale', 'normalize-contrast', 'sharpen', 'encode-png');
767
+ let regions = await detectPreprocessingRegions(sharp, output, policy);
768
+ if (regions.some((region) => region.id === 'content-bounding-box')) {
769
+ operations.push('detect-content-bounding-box');
770
+ }
771
+ if (regions.some((region) => region.id.startsWith('table-log-panel-candidate'))) {
772
+ operations.push('detect-table-log-panel-candidate');
773
+ }
774
+ if (regions.some((region) => region.id.startsWith('borehole-log-panel-candidate'))) {
775
+ operations.push('detect-borehole-log-panel-candidate');
776
+ }
777
+ if (regions.some((region) => region.id.startsWith('region-v2-table-panel'))) {
778
+ operations.push('detect-region-v2-table-panel');
779
+ }
780
+ if (regions.some((region) => region.id.startsWith('region-v2-borehole-log-strip'))) {
781
+ operations.push('detect-region-v2-borehole-log-strip');
782
+ }
783
+ if (regions.filter((region) => region.id.includes('panel-candidate')).length > 1) {
784
+ operations.push('detect-multiple-table-log-candidates');
785
+ }
786
+ regions = await attachRegionAssets(sharp, output, regions);
787
+ if (regions.some((region) => region.asset)) {
788
+ operations.push('persist-region-crop-assets');
789
+ operations.push('normalize-region-crop-assets');
790
+ }
791
+ if (regions.some((region) => region.quality)) {
792
+ operations.push('score-preprocessing-regions');
793
+ }
794
+ const quality = await scoreImagePreprocessQuality(sharp, output, regions, deskew);
96
795
  return {
97
796
  buffer: output,
98
797
  mimeType: 'image/png',
99
798
  transformed: true,
100
799
  warnings: [],
800
+ preprocessing: buildPreprocessingMetadata({
801
+ policy,
802
+ transformed: true,
803
+ input,
804
+ output: outputMetadata,
805
+ operations,
806
+ regions,
807
+ quality,
808
+ warnings: [],
809
+ }),
101
810
  };
102
811
  }
103
812
  catch (error) {
813
+ const warnings = [
814
+ `Image preprocessing skipped: ${error instanceof Error ? error.message : String(error)}`,
815
+ ];
104
816
  return {
105
817
  buffer: Buffer.from(buffer),
106
818
  mimeType,
107
819
  transformed: false,
108
- warnings: [
109
- `Image preprocessing skipped: ${error instanceof Error ? error.message : String(error)}`,
110
- ],
820
+ warnings,
821
+ preprocessing: buildPreprocessingMetadata({
822
+ policy,
823
+ transformed: false,
824
+ input,
825
+ output: input,
826
+ operations: [],
827
+ warnings,
828
+ }),
111
829
  };
112
830
  }
113
831
  }
@@ -137,13 +855,14 @@ export async function renderPdfPageToImageBuffer(buffer, pageNumber = 1, options
137
855
  viewport,
138
856
  }).promise;
139
857
  const renderedBuffer = canvas.toBuffer('image/png');
140
- const preprocessed = await preprocessVisionImageBuffer(renderedBuffer, 'image/png', options?.preprocessPolicy ?? 'ocr-optimized');
858
+ const preprocessed = await preprocessVisionImageBuffer(renderedBuffer, 'image/png', options?.preprocessPolicy ?? resolveVisionImagePreprocessPolicy());
141
859
  return {
142
860
  buffer: preprocessed.buffer,
143
861
  mimeType: 'image/png',
144
862
  width: canvas.width,
145
863
  height: canvas.height,
146
864
  warnings: preprocessed.warnings,
865
+ preprocessing: preprocessed.preprocessing,
147
866
  };
148
867
  }
149
868
  finally {