@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.
- package/dist/agents/data-tools.js +38 -1
- package/dist/agents/data-tools.js.map +1 -1
- package/dist/agents/fem-artifact-guards.d.ts +14 -0
- package/dist/agents/fem-artifact-guards.d.ts.map +1 -0
- package/dist/agents/fem-artifact-guards.js +53 -0
- package/dist/agents/fem-artifact-guards.js.map +1 -0
- package/dist/agents/fem-tools.js +86 -1
- package/dist/agents/fem-tools.js.map +1 -1
- package/dist/agents/filesystem-tools.js +13 -0
- package/dist/agents/filesystem-tools.js.map +1 -1
- package/dist/agents/provider-operating-contract.d.ts +3 -3
- package/dist/agents/provider-operating-contract.d.ts.map +1 -1
- package/dist/agents/provider-operating-contract.js +10 -46
- package/dist/agents/provider-operating-contract.js.map +1 -1
- package/dist/agents/runtime-bootstrap.d.ts +1 -0
- package/dist/agents/runtime-bootstrap.d.ts.map +1 -1
- package/dist/agents/runtime-bootstrap.js +1 -0
- package/dist/agents/runtime-bootstrap.js.map +1 -1
- package/dist/agents/safety.d.ts.map +1 -1
- package/dist/agents/safety.js +33 -0
- package/dist/agents/safety.js.map +1 -1
- package/dist/agents/signal-tools.d.ts +2 -0
- package/dist/agents/signal-tools.d.ts.map +1 -0
- package/dist/agents/signal-tools.js +96 -0
- package/dist/agents/signal-tools.js.map +1 -0
- package/dist/agents/swarm-planner.js +1 -1
- package/dist/agents/swarm-planner.js.map +1 -1
- package/dist/agents/swarm.d.ts +1 -0
- package/dist/agents/swarm.d.ts.map +1 -1
- package/dist/agents/swarm.js +202 -31
- package/dist/agents/swarm.js.map +1 -1
- package/dist/fem/ground-model-draft.d.ts +14 -0
- package/dist/fem/ground-model-draft.d.ts.map +1 -1
- package/dist/fem/ground-model-draft.js +86 -21
- package/dist/fem/ground-model-draft.js.map +1 -1
- package/dist/fem/index.d.ts +1 -1
- package/dist/fem/index.d.ts.map +1 -1
- package/dist/fem/index.js +1 -1
- package/dist/fem/index.js.map +1 -1
- package/dist/fem/routing.d.ts +15 -1
- package/dist/fem/routing.d.ts.map +1 -1
- package/dist/fem/routing.js +192 -14
- package/dist/fem/routing.js.map +1 -1
- package/dist/fem/validation.d.ts.map +1 -1
- package/dist/fem/validation.js +715 -30
- package/dist/fem/validation.js.map +1 -1
- package/dist/fem/webgl.d.ts.map +1 -1
- package/dist/fem/webgl.js +24 -8
- package/dist/fem/webgl.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ingest/document-evidence-packet.d.ts +603 -45
- package/dist/ingest/document-evidence-packet.d.ts.map +1 -1
- package/dist/ingest/document-evidence-packet.js +145 -5
- package/dist/ingest/document-evidence-packet.js.map +1 -1
- package/dist/ingest/geotech-benchmark-corpus.d.ts +108 -0
- package/dist/ingest/geotech-benchmark-corpus.d.ts.map +1 -0
- package/dist/ingest/geotech-benchmark-corpus.js +423 -0
- package/dist/ingest/geotech-benchmark-corpus.js.map +1 -0
- package/dist/ingest/geotech-document-benchmark.d.ts +133 -0
- package/dist/ingest/geotech-document-benchmark.d.ts.map +1 -1
- package/dist/ingest/geotech-document-benchmark.js +370 -2
- package/dist/ingest/geotech-document-benchmark.js.map +1 -1
- package/dist/ingest/geotech-document.d.ts +3 -0
- package/dist/ingest/geotech-document.d.ts.map +1 -1
- package/dist/ingest/geotech-document.js +7 -0
- package/dist/ingest/geotech-document.js.map +1 -1
- package/dist/ingest/index.d.ts +2 -1
- package/dist/ingest/index.d.ts.map +1 -1
- package/dist/ingest/index.js +1 -0
- package/dist/ingest/index.js.map +1 -1
- package/dist/ingest/job-store.d.ts.map +1 -1
- package/dist/ingest/job-store.js +193 -0
- package/dist/ingest/job-store.js.map +1 -1
- package/dist/ingest/job-worker.d.ts.map +1 -1
- package/dist/ingest/job-worker.js +5 -0
- package/dist/ingest/job-worker.js.map +1 -1
- package/dist/ingest/page-evidence-cache.d.ts +6 -2
- package/dist/ingest/page-evidence-cache.d.ts.map +1 -1
- package/dist/ingest/page-evidence-cache.js +226 -4
- package/dist/ingest/page-evidence-cache.js.map +1 -1
- package/dist/ingest/pdf.d.ts.map +1 -1
- package/dist/ingest/pdf.js +2 -2
- package/dist/ingest/pdf.js.map +1 -1
- package/dist/ingest/review-store.d.ts +3 -0
- package/dist/ingest/review-store.d.ts.map +1 -1
- package/dist/ingest/review-store.js +28 -0
- package/dist/ingest/review-store.js.map +1 -1
- package/dist/llm/capabilities.d.ts +6 -1
- package/dist/llm/capabilities.d.ts.map +1 -1
- package/dist/llm/capabilities.js +66 -0
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/index.d.ts +2 -2
- package/dist/llm/index.d.ts.map +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/types.d.ts +20 -0
- package/dist/llm/types.d.ts.map +1 -1
- package/dist/llm/types.js.map +1 -1
- package/dist/meta/metadata.json +1 -1
- package/dist/report/ingest-dossier.d.ts.map +1 -1
- package/dist/report/ingest-dossier.js +13 -1
- package/dist/report/ingest-dossier.js.map +1 -1
- package/dist/report/project-workflow.js +3 -3
- package/dist/report/project-workflow.js.map +1 -1
- package/dist/signal/index.d.ts +95 -0
- package/dist/signal/index.d.ts.map +1 -0
- package/dist/signal/index.js +375 -0
- package/dist/signal/index.js.map +1 -0
- package/dist/verifier/findings.d.ts +1 -1
- package/dist/verifier/findings.d.ts.map +1 -1
- package/dist/verifier/findings.js +329 -0
- package/dist/verifier/findings.js.map +1 -1
- package/dist/vision/ocr.d.ts +2 -0
- package/dist/vision/ocr.d.ts.map +1 -1
- package/dist/vision/ocr.js +78 -2
- package/dist/vision/ocr.js.map +1 -1
- package/dist/vision/preprocess.d.ts +65 -0
- package/dist/vision/preprocess.d.ts.map +1 -1
- package/dist/vision/preprocess.js +620 -7
- package/dist/vision/preprocess.js.map +1 -1
- package/dist/workspace/project-workflow-executor.d.ts +1 -1
- package/dist/workspace/project-workflow-executor.d.ts.map +1 -1
- package/dist/workspace/project-workflow-executor.js +275 -5
- package/dist/workspace/project-workflow-executor.js.map +1 -1
- package/dist/workspace/project-workflow-router.d.ts.map +1 -1
- package/dist/workspace/project-workflow-router.js +63 -1
- package/dist/workspace/project-workflow-router.js.map +1 -1
- 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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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 {
|