@geotechcli/core 0.4.76 → 0.4.78

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 (131) 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 +423 -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 +193 -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 +226 -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/report/project-workflow.js +3 -3
  107. package/dist/report/project-workflow.js.map +1 -1
  108. package/dist/signal/index.d.ts +95 -0
  109. package/dist/signal/index.d.ts.map +1 -0
  110. package/dist/signal/index.js +375 -0
  111. package/dist/signal/index.js.map +1 -0
  112. package/dist/verifier/findings.d.ts +1 -1
  113. package/dist/verifier/findings.d.ts.map +1 -1
  114. package/dist/verifier/findings.js +329 -0
  115. package/dist/verifier/findings.js.map +1 -1
  116. package/dist/vision/ocr.d.ts +2 -0
  117. package/dist/vision/ocr.d.ts.map +1 -1
  118. package/dist/vision/ocr.js +78 -2
  119. package/dist/vision/ocr.js.map +1 -1
  120. package/dist/vision/preprocess.d.ts +65 -0
  121. package/dist/vision/preprocess.d.ts.map +1 -1
  122. package/dist/vision/preprocess.js +620 -7
  123. package/dist/vision/preprocess.js.map +1 -1
  124. package/dist/workspace/project-workflow-executor.d.ts +1 -1
  125. package/dist/workspace/project-workflow-executor.d.ts.map +1 -1
  126. package/dist/workspace/project-workflow-executor.js +275 -5
  127. package/dist/workspace/project-workflow-executor.js.map +1 -1
  128. package/dist/workspace/project-workflow-router.d.ts.map +1 -1
  129. package/dist/workspace/project-workflow-router.js +63 -1
  130. package/dist/workspace/project-workflow-router.js.map +1 -1
  131. 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,533 @@ 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
+ return normalized === 'none' ? 'none' : 'ocr-optimized';
68
+ }
69
+ const DEFAULT_DESKEW = {
70
+ method: 'projection-profile',
71
+ angleDeg: 0,
72
+ confidence: 0,
73
+ applied: false,
74
+ };
75
+ async function readRawPreprocessImage(sharp, buffer) {
76
+ const { data, info } = await sharp(buffer, { pages: 1 })
77
+ .grayscale()
78
+ .raw()
79
+ .toBuffer({ resolveWithObject: true });
80
+ const width = Number(info.width);
81
+ const height = Number(info.height);
82
+ const channels = Number(info.channels) || 1;
83
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 32 || height < 32) {
84
+ return null;
85
+ }
86
+ const darkThreshold = 238;
87
+ const rowDarkCounts = new Array(height).fill(0);
88
+ const colDarkCounts = new Array(width).fill(0);
89
+ let minX = width;
90
+ let minY = height;
91
+ let maxX = -1;
92
+ let maxY = -1;
93
+ let darkCount = 0;
94
+ for (let y = 0; y < height; y += 1) {
95
+ for (let x = 0; x < width; x += 1) {
96
+ const offset = (y * width + x) * channels;
97
+ const value = data[offset] ?? 255;
98
+ if (value < darkThreshold) {
99
+ rowDarkCounts[y] += 1;
100
+ colDarkCounts[x] += 1;
101
+ darkCount += 1;
102
+ if (x < minX)
103
+ minX = x;
104
+ if (x > maxX)
105
+ maxX = x;
106
+ if (y < minY)
107
+ minY = y;
108
+ if (y > maxY)
109
+ maxY = y;
110
+ }
111
+ }
112
+ }
113
+ return {
114
+ data,
115
+ width,
116
+ height,
117
+ channels,
118
+ rowDarkCounts,
119
+ colDarkCounts,
120
+ darkCount,
121
+ minX,
122
+ minY,
123
+ maxX,
124
+ maxY,
125
+ };
126
+ }
127
+ async function estimateDeskewCorrection(sharp, buffer) {
128
+ try {
129
+ const raw = await readRawPreprocessImage(sharp, buffer);
130
+ if (!raw || raw.darkCount < 80 || raw.maxX <= raw.minX || raw.maxY <= raw.minY) {
131
+ return DEFAULT_DESKEW;
132
+ }
133
+ const points = [];
134
+ const targetSamples = 75000;
135
+ const stride = Math.max(1, Math.ceil(Math.sqrt((raw.width * raw.height) / targetSamples)));
136
+ for (let y = raw.minY; y <= raw.maxY; y += stride) {
137
+ for (let x = raw.minX; x <= raw.maxX; x += stride) {
138
+ const value = raw.data[(y * raw.width + x) * raw.channels] ?? 255;
139
+ if (value < 210) {
140
+ points.push([x - raw.width / 2, y - raw.height / 2]);
141
+ }
142
+ }
143
+ }
144
+ if (points.length < 80) {
145
+ return DEFAULT_DESKEW;
146
+ }
147
+ let bestAngle = 0;
148
+ let bestScore = -Infinity;
149
+ let secondBestScore = -Infinity;
150
+ for (let angle = -4; angle <= 4.0001; angle += 0.25) {
151
+ const radians = (angle * Math.PI) / 180;
152
+ const sin = Math.sin(radians);
153
+ const cos = Math.cos(radians);
154
+ const rowBins = new Map();
155
+ const colBins = new Map();
156
+ for (const [x, y] of points) {
157
+ const rotatedX = x * cos - y * sin;
158
+ const rotatedY = x * sin + y * cos;
159
+ const rowBin = Math.round(rotatedY / 2);
160
+ const colBin = Math.round(rotatedX / 2);
161
+ rowBins.set(rowBin, (rowBins.get(rowBin) ?? 0) + 1);
162
+ colBins.set(colBin, (colBins.get(colBin) ?? 0) + 1);
163
+ }
164
+ let score = 0;
165
+ for (const count of rowBins.values()) {
166
+ score += count * count;
167
+ }
168
+ for (const count of colBins.values()) {
169
+ score += count * count;
170
+ }
171
+ if (score > bestScore) {
172
+ secondBestScore = bestScore;
173
+ bestScore = score;
174
+ bestAngle = angle;
175
+ }
176
+ else if (score > secondBestScore) {
177
+ secondBestScore = score;
178
+ }
179
+ }
180
+ const confidence = bestScore > 0
181
+ ? clampRatio((bestScore - Math.max(0, secondBestScore)) / bestScore * 8)
182
+ : 0;
183
+ const applied = Math.abs(bestAngle) >= 0.35 && (confidence >= 0.01 || Math.abs(bestAngle) >= 1);
184
+ return {
185
+ method: 'projection-profile',
186
+ angleDeg: roundAngle(applied ? bestAngle : 0),
187
+ confidence: roundRatio(confidence),
188
+ applied,
189
+ };
190
+ }
191
+ catch {
192
+ return DEFAULT_DESKEW;
193
+ }
194
+ }
195
+ async function detectPreprocessingRegions(sharp, buffer) {
196
+ const regions = [{
197
+ id: 'normalized-full-page',
198
+ source: 'preprocessing',
199
+ label: 'normalized full page',
200
+ bbox2d: [0, 0, 1, 1],
201
+ coverageRatio: 1,
202
+ }];
203
+ try {
204
+ const raw = await readRawPreprocessImage(sharp, buffer);
205
+ if (!raw || raw.darkCount === 0 || raw.maxX <= raw.minX || raw.maxY <= raw.minY) {
206
+ return scorePreprocessRegions(raw, regions);
207
+ }
208
+ const contentRegion = buildRegionFromPixels('content-bounding-box', 'detected content bounding box', raw.minX, raw.minY, raw.maxX, raw.maxY, raw.width, raw.height);
209
+ if (contentRegion.coverageRatio != null && contentRegion.coverageRatio < 0.96) {
210
+ regions.push(contentRegion);
211
+ }
212
+ const panelRegions = buildTableLogPanelCandidates(raw);
213
+ for (const region of panelRegions) {
214
+ if (!regions.some((existing) => regionOverlap(existing, region) > 0.88)) {
215
+ regions.push(region);
216
+ }
217
+ }
218
+ return scorePreprocessRegions(raw, regions);
219
+ }
220
+ catch {
221
+ return regions;
222
+ }
223
+ }
224
+ function buildTableLogPanelCandidates(raw) {
225
+ const rowThreshold = Math.max(24, Math.round(raw.width * 0.07));
226
+ const colThreshold = Math.max(24, Math.round(raw.height * 0.055));
227
+ const heavyRows = raw.rowDarkCounts
228
+ .map((count, index) => ({ count, index }))
229
+ .filter((row) => row.count >= rowThreshold)
230
+ .map((row) => row.index);
231
+ const heavyCols = raw.colDarkCounts
232
+ .map((count, index) => ({ count, index }))
233
+ .filter((col) => col.count >= colThreshold)
234
+ .map((col) => col.index);
235
+ if (heavyRows.length < 4 || heavyCols.length < 3) {
236
+ return [];
237
+ }
238
+ const rowSpan = mergeLineClusters(groupContiguous(heavyRows, Math.max(3, Math.round(raw.height * 0.004))), Math.round(raw.height * 0.18))
239
+ .reduce((span, group) => {
240
+ if (!span)
241
+ return { ...group };
242
+ return {
243
+ start: Math.min(span.start, group.start),
244
+ end: Math.max(span.end, group.end),
245
+ count: span.count + group.count,
246
+ };
247
+ }, null);
248
+ const colClusters = mergeLineClusters(groupContiguous(heavyCols, Math.max(3, Math.round(raw.width * 0.004))), Math.round(raw.width * 0.14))
249
+ .filter((cluster) => cluster.count >= 3 || cluster.end - cluster.start >= raw.width * 0.08);
250
+ if (!rowSpan || colClusters.length === 0) {
251
+ return [];
252
+ }
253
+ 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);
254
+ const candidates = [full];
255
+ for (const [index, cluster] of colClusters.entries()) {
256
+ const width = cluster.end - cluster.start;
257
+ const height = rowSpan.end - rowSpan.start;
258
+ const coverage = (width * height) / Math.max(1, raw.width * raw.height);
259
+ if (coverage < 0.025 || coverage > 0.98) {
260
+ continue;
261
+ }
262
+ const tall = height / Math.max(1, width) >= 1.35;
263
+ 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));
264
+ }
265
+ const deduped = [];
266
+ for (const candidate of candidates) {
267
+ const coverage = candidate.coverageRatio ?? 0;
268
+ if (coverage < 0.025 || coverage > 0.98) {
269
+ continue;
270
+ }
271
+ if (!deduped.some((existing) => regionOverlap(existing, candidate) > 0.88)) {
272
+ deduped.push(candidate);
273
+ }
274
+ }
275
+ return deduped.slice(0, 5);
276
+ }
277
+ function scorePreprocessRegions(raw, regions) {
278
+ if (!raw) {
279
+ return regions;
280
+ }
281
+ return regions.map((region) => ({
282
+ ...region,
283
+ quality: scoreRegionQuality(raw, region),
284
+ }));
285
+ }
286
+ async function attachRegionAssets(sharp, buffer, regions) {
287
+ const metadata = await readImageMetadata(sharp, buffer, 'image/png');
288
+ const width = metadata.width ?? 0;
289
+ const height = metadata.height ?? 0;
290
+ if (width < 16 || height < 16) {
291
+ return regions;
292
+ }
293
+ const cropped = await Promise.all(regions.map(async (region) => {
294
+ if (!region.bbox2d || region.id === 'normalized-full-page') {
295
+ return region;
296
+ }
297
+ const left = Math.max(0, Math.floor(region.bbox2d[0] * width));
298
+ const top = Math.max(0, Math.floor(region.bbox2d[1] * height));
299
+ const right = Math.min(width, Math.ceil(region.bbox2d[2] * width));
300
+ const bottom = Math.min(height, Math.ceil(region.bbox2d[3] * height));
301
+ const cropWidth = Math.max(0, right - left);
302
+ const cropHeight = Math.max(0, bottom - top);
303
+ if (cropWidth < 16 || cropHeight < 16) {
304
+ return region;
305
+ }
306
+ try {
307
+ const crop = await sharp(buffer, { pages: 1 })
308
+ .extract({
309
+ left,
310
+ top,
311
+ width: cropWidth,
312
+ height: cropHeight,
313
+ })
314
+ .resize({
315
+ width: 1400,
316
+ height: 1400,
317
+ fit: 'inside',
318
+ withoutEnlargement: true,
319
+ })
320
+ .grayscale()
321
+ .normalize()
322
+ .sharpen()
323
+ .png()
324
+ .toBuffer();
325
+ const cropMetadata = await readImageMetadata(sharp, crop, 'image/png');
326
+ return {
327
+ ...region,
328
+ asset: {
329
+ mimeType: 'image/png',
330
+ byteLength: crop.length,
331
+ sha256: createHash('sha256').update(crop).digest('hex'),
332
+ ...(cropMetadata.width != null ? { width: cropMetadata.width } : {}),
333
+ ...(cropMetadata.height != null ? { height: cropMetadata.height } : {}),
334
+ normalized: true,
335
+ dataBase64: crop.toString('base64'),
336
+ },
337
+ };
338
+ }
339
+ catch {
340
+ return region;
341
+ }
342
+ }));
343
+ return cropped;
344
+ }
345
+ async function scoreImagePreprocessQuality(sharp, buffer, regions, deskew) {
346
+ try {
347
+ const raw = await readRawPreprocessImage(sharp, buffer);
348
+ if (!raw) {
349
+ return {
350
+ score: 0,
351
+ contentCoverageRatio: 0,
352
+ darkPixelRatio: 0,
353
+ regionCoverageRatio: 0,
354
+ regionCount: 0,
355
+ cropAssetCount: 0,
356
+ deskew,
357
+ warnings: ['image-quality-unavailable'],
358
+ };
359
+ }
360
+ const pageArea = Math.max(1, raw.width * raw.height);
361
+ const darkPixelRatio = raw.darkCount / pageArea;
362
+ const contentCoverageRatio = raw.maxX > raw.minX && raw.maxY > raw.minY
363
+ ? ((raw.maxX - raw.minX + 1) * (raw.maxY - raw.minY + 1)) / pageArea
364
+ : 0;
365
+ const cropRegions = regions.filter((region) => region.id !== 'normalized-full-page' && region.id !== 'original-full-page');
366
+ const regionCoverageRatio = Math.min(1, cropRegions.reduce((sum, region) => sum + (region.coverageRatio ?? 0), 0));
367
+ const cropAssetCount = cropRegions.filter((region) => region.asset).length;
368
+ const warnings = [
369
+ darkPixelRatio < 0.002 ? 'very-low-content-density' : null,
370
+ contentCoverageRatio < 0.03 ? 'low-page-content-coverage' : null,
371
+ contentCoverageRatio > 0.97 ? 'content-nearly-full-page' : null,
372
+ cropRegions.length === 0 ? 'no-log-or-table-crops-detected' : null,
373
+ cropRegions.some((region) => (region.quality?.score ?? 1) < 0.45) ? 'low-quality-region-crop' : null,
374
+ ].filter((value) => value != null);
375
+ let score = 0.45;
376
+ score += Math.min(0.2, darkPixelRatio * 3);
377
+ score += contentCoverageRatio >= 0.03 && contentCoverageRatio <= 0.97 ? 0.12 : 0;
378
+ score += Math.min(0.12, regionCoverageRatio * 0.45);
379
+ score += Math.min(0.08, cropRegions.length * 0.02);
380
+ score += cropAssetCount > 0 ? 0.08 : 0;
381
+ score += deskew.applied ? 0.04 : 0;
382
+ score -= Math.min(0.18, warnings.length * 0.045);
383
+ return {
384
+ score: roundRatio(score),
385
+ contentCoverageRatio: roundRatio(contentCoverageRatio),
386
+ darkPixelRatio: roundRatio(darkPixelRatio),
387
+ regionCoverageRatio: roundRatio(regionCoverageRatio),
388
+ regionCount: cropRegions.length,
389
+ cropAssetCount,
390
+ deskew,
391
+ warnings,
392
+ };
393
+ }
394
+ catch {
395
+ return {
396
+ score: 0,
397
+ contentCoverageRatio: 0,
398
+ darkPixelRatio: 0,
399
+ regionCoverageRatio: 0,
400
+ regionCount: 0,
401
+ cropAssetCount: 0,
402
+ deskew,
403
+ warnings: ['image-quality-scoring-failed'],
404
+ };
405
+ }
406
+ }
407
+ function buildRegionFromPixels(id, label, minX, minY, maxX, maxY, width, height, padding = 4) {
408
+ const left = clampRatio((minX - padding) / width);
409
+ const top = clampRatio((minY - padding) / height);
410
+ const right = clampRatio((maxX + 1 + padding) / width);
411
+ const bottom = clampRatio((maxY + 1 + padding) / height);
412
+ const area = Math.max(0, right - left) * Math.max(0, bottom - top);
413
+ return {
414
+ id,
415
+ source: 'preprocessing',
416
+ label,
417
+ bbox2d: [roundRatio(left), roundRatio(top), roundRatio(right), roundRatio(bottom)],
418
+ coverageRatio: roundRatio(area),
419
+ };
420
+ }
421
+ function scoreRegionQuality(raw, region) {
422
+ const bbox = region.bbox2d ?? [0, 0, 1, 1];
423
+ const left = Math.max(0, Math.floor(bbox[0] * raw.width));
424
+ const top = Math.max(0, Math.floor(bbox[1] * raw.height));
425
+ const right = Math.min(raw.width, Math.ceil(bbox[2] * raw.width));
426
+ const bottom = Math.min(raw.height, Math.ceil(bbox[3] * raw.height));
427
+ const width = Math.max(0, right - left);
428
+ const height = Math.max(0, bottom - top);
429
+ const area = Math.max(1, width * height);
430
+ let dark = 0;
431
+ let rowHits = 0;
432
+ let colHits = 0;
433
+ for (let y = top; y < bottom; y += 1) {
434
+ let rowDark = 0;
435
+ for (let x = left; x < right; x += 1) {
436
+ const value = raw.data[(y * raw.width + x) * raw.channels] ?? 255;
437
+ if (value < 238) {
438
+ dark += 1;
439
+ rowDark += 1;
440
+ }
441
+ }
442
+ if (width > 0 && rowDark / width >= 0.18) {
443
+ rowHits += 1;
444
+ }
445
+ }
446
+ for (let x = left; x < right; x += 1) {
447
+ let colDark = 0;
448
+ for (let y = top; y < bottom; y += 1) {
449
+ const value = raw.data[(y * raw.width + x) * raw.channels] ?? 255;
450
+ if (value < 238) {
451
+ colDark += 1;
452
+ }
453
+ }
454
+ if (height > 0 && colDark / height >= 0.12) {
455
+ colHits += 1;
456
+ }
457
+ }
458
+ const darkPixelRatio = dark / area;
459
+ const lineDensity = Math.min(1, (rowHits + colHits) / Math.max(1, width + height));
460
+ const coverageRatio = Math.max(0, Math.min(1, region.coverageRatio ?? area / Math.max(1, raw.width * raw.height)));
461
+ const warnings = [
462
+ darkPixelRatio < 0.002 ? 'low-region-content-density' : null,
463
+ coverageRatio < 0.02 ? 'small-region-coverage' : null,
464
+ coverageRatio > 0.98 ? 'region-covers-full-page' : null,
465
+ region.id.includes('table') || region.id.includes('log')
466
+ ? lineDensity < 0.004 ? 'weak-table-line-structure' : null
467
+ : null,
468
+ ].filter((value) => value != null);
469
+ let score = 0.35;
470
+ score += Math.min(0.25, darkPixelRatio * 4);
471
+ score += Math.min(0.25, lineDensity * 35);
472
+ score += coverageRatio >= 0.02 && coverageRatio <= 0.95 ? 0.12 : 0;
473
+ score -= Math.min(0.16, warnings.length * 0.04);
474
+ return {
475
+ score: roundRatio(score),
476
+ darkPixelRatio: roundRatio(darkPixelRatio),
477
+ lineDensity: roundRatio(lineDensity),
478
+ coverageRatio: roundRatio(coverageRatio),
479
+ warnings,
480
+ };
481
+ }
482
+ function groupContiguous(values, maxGap = 1) {
483
+ if (values.length === 0) {
484
+ return [];
485
+ }
486
+ const sorted = [...new Set(values)].sort((left, right) => left - right);
487
+ const groups = [];
488
+ let start = sorted[0];
489
+ let end = start;
490
+ let count = 1;
491
+ for (const value of sorted.slice(1)) {
492
+ if (value - end <= maxGap + 1) {
493
+ end = value;
494
+ count += 1;
495
+ continue;
496
+ }
497
+ groups.push({ start, end, count });
498
+ start = value;
499
+ end = value;
500
+ count = 1;
501
+ }
502
+ groups.push({ start, end, count });
503
+ return groups;
504
+ }
505
+ function mergeLineClusters(groups, maxGap) {
506
+ if (groups.length === 0) {
507
+ return [];
508
+ }
509
+ const merged = [];
510
+ let current = { ...groups[0] };
511
+ for (const group of groups.slice(1)) {
512
+ if (group.start - current.end <= maxGap) {
513
+ current = {
514
+ start: current.start,
515
+ end: group.end,
516
+ count: current.count + group.count,
517
+ };
518
+ continue;
519
+ }
520
+ merged.push(current);
521
+ current = { ...group };
522
+ }
523
+ merged.push(current);
524
+ return merged;
525
+ }
526
+ function regionOverlap(left, right) {
527
+ if (!left.bbox2d || !right.bbox2d) {
528
+ return 0;
529
+ }
530
+ const x1 = Math.max(left.bbox2d[0], right.bbox2d[0]);
531
+ const y1 = Math.max(left.bbox2d[1], right.bbox2d[1]);
532
+ const x2 = Math.min(left.bbox2d[2], right.bbox2d[2]);
533
+ const y2 = Math.min(left.bbox2d[3], right.bbox2d[3]);
534
+ const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
535
+ const leftArea = Math.max(0, left.bbox2d[2] - left.bbox2d[0]) * Math.max(0, left.bbox2d[3] - left.bbox2d[1]);
536
+ const rightArea = Math.max(0, right.bbox2d[2] - right.bbox2d[0]) * Math.max(0, right.bbox2d[3] - right.bbox2d[1]);
537
+ const union = leftArea + rightArea - intersection;
538
+ return union > 0 ? intersection / union : 0;
539
+ }
540
+ function clampRatio(value) {
541
+ return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
542
+ }
543
+ function roundRatio(value) {
544
+ return Math.round(value * 1000) / 1000;
545
+ }
546
+ function roundAngle(value) {
547
+ return Math.round(value * 100) / 100;
548
+ }
19
549
  async function loadPdfRendererModule() {
20
550
  if (!cachedPdfRendererUrl) {
21
551
  const pdfjsPackagePath = require.resolve('pdfjs-dist/package.json');
@@ -56,28 +586,67 @@ export async function encodeRawRasterToPng(pixels, details) {
56
586
  .png()
57
587
  .toBuffer();
58
588
  }
59
- export async function preprocessVisionImageBuffer(buffer, mimeType, policy = 'ocr-optimized') {
589
+ export async function preprocessVisionImageBuffer(buffer, mimeType, policy = resolveVisionImagePreprocessPolicy()) {
60
590
  if (policy === 'none') {
591
+ const input = await readImageMetadata(null, buffer, mimeType);
592
+ const metadata = buildPreprocessingMetadata({
593
+ policy,
594
+ transformed: false,
595
+ input,
596
+ output: input,
597
+ operations: [],
598
+ warnings: [],
599
+ });
61
600
  return {
62
601
  buffer: Buffer.from(buffer),
63
602
  mimeType,
64
603
  transformed: false,
65
604
  warnings: [],
605
+ preprocessing: metadata,
66
606
  };
67
607
  }
68
608
  const sharp = await loadSharp();
609
+ const input = await readImageMetadata(sharp, buffer, mimeType);
69
610
  if (!sharp) {
611
+ const warnings = ['Image preprocessing skipped because sharp is unavailable in this runtime.'];
70
612
  return {
71
613
  buffer: Buffer.from(buffer),
72
614
  mimeType,
73
615
  transformed: false,
74
- warnings: ['Image preprocessing skipped because sharp is unavailable in this runtime.'],
616
+ warnings,
617
+ preprocessing: buildPreprocessingMetadata({
618
+ policy,
619
+ transformed: false,
620
+ input,
621
+ output: input,
622
+ operations: [],
623
+ warnings,
624
+ }),
75
625
  };
76
626
  }
77
627
  try {
78
- const output = await sharp(Buffer.from(buffer), { pages: 1 })
628
+ let working = await sharp(Buffer.from(buffer), { pages: 1 })
79
629
  .rotate()
80
630
  .flatten({ background: '#ffffff' })
631
+ .png()
632
+ .toBuffer();
633
+ const operations = [
634
+ 'auto-orient',
635
+ 'flatten-white-background',
636
+ ];
637
+ const deskew = await estimateDeskewCorrection(sharp, working);
638
+ if (deskew.applied) {
639
+ working = await sharp(working, { pages: 1 })
640
+ .rotate(deskew.angleDeg, { background: '#ffffff' })
641
+ .flatten({ background: '#ffffff' })
642
+ .png()
643
+ .toBuffer();
644
+ operations.push(`deskew-angle-${deskew.angleDeg.toFixed(2)}deg`);
645
+ }
646
+ else {
647
+ operations.push('deskew-not-applied');
648
+ }
649
+ const output = await sharp(working, { pages: 1 })
81
650
  .trim({
82
651
  background: '#ffffff',
83
652
  threshold: 10,
@@ -93,21 +662,64 @@ export async function preprocessVisionImageBuffer(buffer, mimeType, policy = 'oc
93
662
  .sharpen()
94
663
  .png()
95
664
  .toBuffer();
665
+ const outputMetadata = await readImageMetadata(sharp, output, 'image/png');
666
+ operations.push('trim-white-margins-threshold-10', 'resize-inside-1800-no-enlarge', 'grayscale', 'normalize-contrast', 'sharpen', 'encode-png');
667
+ let regions = await detectPreprocessingRegions(sharp, output);
668
+ if (regions.some((region) => region.id === 'content-bounding-box')) {
669
+ operations.push('detect-content-bounding-box');
670
+ }
671
+ if (regions.some((region) => region.id.startsWith('table-log-panel-candidate'))) {
672
+ operations.push('detect-table-log-panel-candidate');
673
+ }
674
+ if (regions.some((region) => region.id.startsWith('borehole-log-panel-candidate'))) {
675
+ operations.push('detect-borehole-log-panel-candidate');
676
+ }
677
+ if (regions.filter((region) => region.id.includes('panel-candidate')).length > 1) {
678
+ operations.push('detect-multiple-table-log-candidates');
679
+ }
680
+ regions = await attachRegionAssets(sharp, output, regions);
681
+ if (regions.some((region) => region.asset)) {
682
+ operations.push('persist-region-crop-assets');
683
+ operations.push('normalize-region-crop-assets');
684
+ }
685
+ if (regions.some((region) => region.quality)) {
686
+ operations.push('score-preprocessing-regions');
687
+ }
688
+ const quality = await scoreImagePreprocessQuality(sharp, output, regions, deskew);
96
689
  return {
97
690
  buffer: output,
98
691
  mimeType: 'image/png',
99
692
  transformed: true,
100
693
  warnings: [],
694
+ preprocessing: buildPreprocessingMetadata({
695
+ policy,
696
+ transformed: true,
697
+ input,
698
+ output: outputMetadata,
699
+ operations,
700
+ regions,
701
+ quality,
702
+ warnings: [],
703
+ }),
101
704
  };
102
705
  }
103
706
  catch (error) {
707
+ const warnings = [
708
+ `Image preprocessing skipped: ${error instanceof Error ? error.message : String(error)}`,
709
+ ];
104
710
  return {
105
711
  buffer: Buffer.from(buffer),
106
712
  mimeType,
107
713
  transformed: false,
108
- warnings: [
109
- `Image preprocessing skipped: ${error instanceof Error ? error.message : String(error)}`,
110
- ],
714
+ warnings,
715
+ preprocessing: buildPreprocessingMetadata({
716
+ policy,
717
+ transformed: false,
718
+ input,
719
+ output: input,
720
+ operations: [],
721
+ warnings,
722
+ }),
111
723
  };
112
724
  }
113
725
  }
@@ -137,13 +749,14 @@ export async function renderPdfPageToImageBuffer(buffer, pageNumber = 1, options
137
749
  viewport,
138
750
  }).promise;
139
751
  const renderedBuffer = canvas.toBuffer('image/png');
140
- const preprocessed = await preprocessVisionImageBuffer(renderedBuffer, 'image/png', options?.preprocessPolicy ?? 'ocr-optimized');
752
+ const preprocessed = await preprocessVisionImageBuffer(renderedBuffer, 'image/png', options?.preprocessPolicy ?? resolveVisionImagePreprocessPolicy());
141
753
  return {
142
754
  buffer: preprocessed.buffer,
143
755
  mimeType: 'image/png',
144
756
  width: canvas.width,
145
757
  height: canvas.height,
146
758
  warnings: preprocessed.warnings,
759
+ preprocessing: preprocessed.preprocessing,
147
760
  };
148
761
  }
149
762
  finally {