tryharness 0.7.22 → 0.7.25
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/README.md +48 -8
- package/assets/opencode-plugin.js +48 -10
- package/assets/pi-extension.ts +106 -26
- package/cli.mjs +1281 -289
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +604 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +177 -39
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +628 -0
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +281 -56
- package/lib/daemon.mjs +5590 -838
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +81 -0
- package/lib/environment.mjs +180 -0
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +288 -0
- package/lib/folders.mjs +223 -57
- package/lib/frames.mjs +333 -0
- package/lib/inference.mjs +663 -0
- package/lib/learn.mjs +182 -0
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +571 -0
- package/lib/mcp-stdio.mjs +912 -103
- package/lib/media.mjs +228 -0
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +190 -32
- package/lib/ocr.mjs +380 -0
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +77 -0
- package/lib/phones.mjs +398 -51
- package/lib/places.mjs +566 -102
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +82 -0
- package/lib/redact.mjs +730 -0
- package/lib/runner.mjs +347 -55
- package/lib/runtime-install.mjs +59 -17
- package/lib/screens.mjs +212 -0
- package/lib/sealed.mjs +160 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +173 -35
- package/lib/snippets.mjs +140 -0
- package/lib/speech.mjs +993 -0
- package/lib/store.mjs +1678 -301
- package/lib/swarm.mjs +598 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +401 -0
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +362 -0
- package/lib/worktrees.mjs +186 -38
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/lib/nudge.mjs
CHANGED
|
@@ -18,16 +18,50 @@ export function nudgeText(agent, tasks, unread = [], goals = [], swarm = "") {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function queueText(agent, tasks, unread, goals) {
|
|
21
|
-
const waiting = (tasks || []).filter(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const waiting = (tasks || []).filter(
|
|
22
|
+
(task) =>
|
|
23
|
+
task.status === "open" &&
|
|
24
|
+
(!task.target_agent || task.target_agent === agent)
|
|
25
|
+
);
|
|
26
|
+
const held = (tasks || []).filter(
|
|
27
|
+
(task) =>
|
|
28
|
+
task.claimed_by === agent &&
|
|
29
|
+
(task.status === "claimed" || task.status === "needs_input")
|
|
30
|
+
);
|
|
31
|
+
if (!waiting.length && !held.length && !unread.length && !goals.length)
|
|
32
|
+
return "";
|
|
33
|
+
const line = (task) =>
|
|
34
|
+
`- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.trust === "agent" && task.origin_chain?.length > 1 ? task.origin_chain.join(" → ") : task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}${task.status === "needs_input" ? " · your question was answered" : ""}${task.thread_with ? ` · shared with ${task.thread_with}` : ""}`;
|
|
25
35
|
const parts = ["Harness task board:"];
|
|
26
|
-
if (waiting.length)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
if (waiting.length)
|
|
37
|
+
parts.push(
|
|
38
|
+
`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`,
|
|
39
|
+
...waiting.slice(0, 5).map(line)
|
|
40
|
+
);
|
|
41
|
+
if (held.length)
|
|
42
|
+
parts.push(
|
|
43
|
+
`${held.length} you already claimed:`,
|
|
44
|
+
...held.slice(0, 5).map(line)
|
|
45
|
+
);
|
|
46
|
+
if (unread.length)
|
|
47
|
+
parts.push(
|
|
48
|
+
...unread
|
|
49
|
+
.slice(0, 5)
|
|
50
|
+
.map(
|
|
51
|
+
(row) =>
|
|
52
|
+
`- ${row.task_id}: ${row.count} new message${row.count === 1 ? "" : "s"} from ${row.sender} on "${row.objective}" (read_thread)`
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
if (goals.length)
|
|
56
|
+
parts.push(
|
|
57
|
+
`You are on ${goals.length} goal room${goals.length === 1 ? "" : "s"} (read_room to catch up, post_to_room to contribute):`,
|
|
58
|
+
...goals
|
|
59
|
+
.slice(0, 5)
|
|
60
|
+
.map((goal) => `- ${goal.id}: ${goal.objective} · ${goal.posts} posts`)
|
|
61
|
+
);
|
|
62
|
+
parts.push(
|
|
63
|
+
"Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."
|
|
64
|
+
);
|
|
31
65
|
return parts.join("\n");
|
|
32
66
|
}
|
|
33
67
|
|
|
@@ -35,17 +69,35 @@ function queueText(agent, tasks, unread, goals) {
|
|
|
35
69
|
export function queueDigest(agent, tasks, unread = [], goals = []) {
|
|
36
70
|
return [
|
|
37
71
|
...(tasks || [])
|
|
38
|
-
.filter(
|
|
72
|
+
.filter(
|
|
73
|
+
(task) =>
|
|
74
|
+
(task.status === "open" &&
|
|
75
|
+
(!task.target_agent || task.target_agent === agent)) ||
|
|
76
|
+
(task.claimed_by === agent &&
|
|
77
|
+
["claimed", "needs_input"].includes(task.status))
|
|
78
|
+
)
|
|
39
79
|
.map((task) => `${task.id}:${task.status}:${task.version}`),
|
|
40
80
|
...unread.map((row) => `${row.task_id}:unread:${row.count}`),
|
|
41
81
|
...goals.map((goal) => `${goal.id}:goal:${goal.posts}`),
|
|
42
|
-
]
|
|
82
|
+
]
|
|
83
|
+
.sort()
|
|
84
|
+
.join("|");
|
|
43
85
|
}
|
|
44
86
|
|
|
45
87
|
export async function boardQueue(agent) {
|
|
46
88
|
try {
|
|
47
|
-
const response = await daemonRequest(
|
|
48
|
-
|
|
89
|
+
const response = await daemonRequest(
|
|
90
|
+
{ command: "board.list", agent, unread: true, swarm: true },
|
|
91
|
+
1_500
|
|
92
|
+
);
|
|
93
|
+
return response?.ok
|
|
94
|
+
? {
|
|
95
|
+
tasks: response.tasks,
|
|
96
|
+
unread: response.unread || [],
|
|
97
|
+
goals: response.goals || [],
|
|
98
|
+
swarm: response.swarm || null,
|
|
99
|
+
}
|
|
100
|
+
: null;
|
|
49
101
|
} catch {
|
|
50
102
|
return null;
|
|
51
103
|
}
|
|
@@ -53,15 +105,40 @@ export async function boardQueue(agent) {
|
|
|
53
105
|
|
|
54
106
|
// What this agent was last told, in the Harness home: the queue's digest and the swarm's.
|
|
55
107
|
const toldFile = (agent, homeDir) => path.join(homeDir, `nudge-${agent}.json`);
|
|
56
|
-
function lastTold(agent, homeDir) {
|
|
57
|
-
|
|
108
|
+
function lastTold(agent, homeDir) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(fs.readFileSync(toldFile(agent, homeDir), "utf8"));
|
|
111
|
+
} catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function noteTold(agent, homeDir, told) {
|
|
116
|
+
try {
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
toldFile(agent, homeDir),
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
...lastTold(agent, homeDir),
|
|
121
|
+
...told,
|
|
122
|
+
at: new Date().toISOString(),
|
|
123
|
+
}),
|
|
124
|
+
{ mode: 0o600 }
|
|
125
|
+
);
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
58
128
|
|
|
59
129
|
/** What a session is told when it starts: its queue and the whole swarm. */
|
|
60
130
|
export async function boardNudge(agent, homeDir = paths.home) {
|
|
61
131
|
const queue = await boardQueue(agent);
|
|
62
132
|
if (!queue) return "";
|
|
63
|
-
if (queue.swarm?.digest)
|
|
64
|
-
|
|
133
|
+
if (queue.swarm?.digest)
|
|
134
|
+
noteTold(agent, homeDir, { swarm: queue.swarm.digest });
|
|
135
|
+
return nudgeText(
|
|
136
|
+
agent,
|
|
137
|
+
queue.tasks,
|
|
138
|
+
queue.unread,
|
|
139
|
+
queue.goals,
|
|
140
|
+
queue.swarm?.text || ""
|
|
141
|
+
);
|
|
65
142
|
}
|
|
66
143
|
|
|
67
144
|
// Only what changed since this agent last saw its queue: a hook that runs on every prompt
|
|
@@ -76,40 +153,90 @@ export async function boardNudgeIfChanged(agent, homeDir = paths.home) {
|
|
|
76
153
|
const queueMoved = digest !== (last.digest ?? null);
|
|
77
154
|
const swarmMoved = Boolean(swarm?.digest) && swarm.digest !== last.swarm;
|
|
78
155
|
if (!queueMoved && !swarmMoved) return "";
|
|
79
|
-
noteTold(agent, homeDir, {
|
|
80
|
-
|
|
156
|
+
noteTold(agent, homeDir, {
|
|
157
|
+
digest,
|
|
158
|
+
...(swarm?.digest ? { swarm: swarm.digest } : {}),
|
|
159
|
+
});
|
|
160
|
+
const told = !queueMoved
|
|
161
|
+
? ""
|
|
162
|
+
: digest
|
|
163
|
+
? queueText(agent, tasks, unread, goals)
|
|
164
|
+
: last.digest
|
|
165
|
+
? "Harness task board: nothing waits for you now."
|
|
166
|
+
: "";
|
|
81
167
|
return [told, swarmMoved ? swarm.text : ""].filter(Boolean).join("\n\n");
|
|
82
168
|
}
|
|
83
169
|
|
|
84
170
|
// The prompt hook also tells the daemon the person typed in this session: the agent process is the hook's parent, or
|
|
85
171
|
// its grandparent when the hook runs through a shell.
|
|
86
|
-
export const PS =
|
|
172
|
+
export const PS =
|
|
173
|
+
["/bin/ps", "/usr/bin/ps"].find((file) => fs.existsSync(file)) || "ps";
|
|
87
174
|
|
|
88
175
|
function agentPids() {
|
|
89
176
|
const pids = [process.ppid];
|
|
90
|
-
try {
|
|
177
|
+
try {
|
|
178
|
+
const up = Number(
|
|
179
|
+
spawnSync(PS, ["-o", "ppid=", "-p", String(process.ppid)], {
|
|
180
|
+
encoding: "utf8",
|
|
181
|
+
}).stdout.trim()
|
|
182
|
+
);
|
|
183
|
+
if (up > 1) pids.push(up);
|
|
184
|
+
} catch {}
|
|
91
185
|
return pids;
|
|
92
186
|
}
|
|
93
187
|
|
|
94
188
|
// Where the session runs, as its own environment says (the agent's, which the hook shares): places.mjs reads it.
|
|
95
|
-
const placeOf = (input) => ({
|
|
189
|
+
const placeOf = (input) => ({
|
|
190
|
+
sessionId: input?.session_id,
|
|
191
|
+
folder: input?.cwd || process.cwd(),
|
|
192
|
+
env: placeEnv(process.env),
|
|
193
|
+
});
|
|
96
194
|
|
|
97
195
|
// An extension or plugin running inside the agent passes its own process ids.
|
|
98
196
|
export async function notePrompt(agent, input = null, pids = agentPids()) {
|
|
99
|
-
try {
|
|
197
|
+
try {
|
|
198
|
+
await daemonRequest(
|
|
199
|
+
{
|
|
200
|
+
command: "board.prompted",
|
|
201
|
+
agent,
|
|
202
|
+
pids,
|
|
203
|
+
...(input?.session_id ? placeOf(input) : {}),
|
|
204
|
+
},
|
|
205
|
+
1_500
|
|
206
|
+
);
|
|
207
|
+
} catch {}
|
|
100
208
|
}
|
|
101
209
|
|
|
102
210
|
/** A session starting says where it runs. */
|
|
103
211
|
export async function notePlace(agent, input, pids = agentPids()) {
|
|
104
212
|
if (!input?.session_id) return;
|
|
105
|
-
try {
|
|
213
|
+
try {
|
|
214
|
+
await daemonRequest(
|
|
215
|
+
{ command: "board.live.place", agent, pids, ...placeOf(input) },
|
|
216
|
+
1_500
|
|
217
|
+
);
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A turn ended with nothing more to do: the session waits for the person, and the buddy can say so. */
|
|
222
|
+
export async function noteTurn(agent, input, pids = agentPids()) {
|
|
223
|
+
if (!input?.session_id) return;
|
|
224
|
+
try {
|
|
225
|
+
await daemonRequest(
|
|
226
|
+
{ command: "board.live.turn", agent, pids, ...placeOf(input) },
|
|
227
|
+
1_500
|
|
228
|
+
);
|
|
229
|
+
} catch {}
|
|
106
230
|
}
|
|
107
231
|
|
|
108
232
|
// What the person sent this session from Harness (daemon board.live.say), taken once.
|
|
109
233
|
export async function takeMessages(agent, sessionId) {
|
|
110
234
|
if (!sessionId) return [];
|
|
111
235
|
try {
|
|
112
|
-
const response = await daemonRequest(
|
|
236
|
+
const response = await daemonRequest(
|
|
237
|
+
{ command: "board.live.take", agent, sessionId },
|
|
238
|
+
1_500
|
|
239
|
+
);
|
|
113
240
|
return response?.ok ? response.messages || [] : [];
|
|
114
241
|
} catch {
|
|
115
242
|
return [];
|
|
@@ -117,7 +244,11 @@ export async function takeMessages(agent, sessionId) {
|
|
|
117
244
|
}
|
|
118
245
|
|
|
119
246
|
export function messagesText(messages) {
|
|
120
|
-
return messages
|
|
247
|
+
return messages
|
|
248
|
+
.map(
|
|
249
|
+
(message) => `The person sent you this from Harness:\n\n${message.text}`
|
|
250
|
+
)
|
|
251
|
+
.join("\n\n");
|
|
121
252
|
}
|
|
122
253
|
|
|
123
254
|
// The JSON the agent hands every hook on stdin (session_id among it); null when there is none.
|
|
@@ -125,10 +256,23 @@ async function hookInput() {
|
|
|
125
256
|
if (process.stdin.isTTY) return null;
|
|
126
257
|
let text = "";
|
|
127
258
|
process.stdin.setEncoding("utf8");
|
|
128
|
-
const read = new Promise((resolve) => {
|
|
129
|
-
|
|
259
|
+
const read = new Promise((resolve) => {
|
|
260
|
+
process.stdin.on("data", (chunk) => {
|
|
261
|
+
text += chunk;
|
|
262
|
+
});
|
|
263
|
+
process.stdin.on("end", resolve);
|
|
264
|
+
process.stdin.on("error", resolve);
|
|
265
|
+
});
|
|
266
|
+
await Promise.race([
|
|
267
|
+
read,
|
|
268
|
+
new Promise((resolve) => setTimeout(resolve, 1_000).unref()),
|
|
269
|
+
]);
|
|
130
270
|
process.stdin.destroy();
|
|
131
|
-
try {
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse(text);
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
132
276
|
}
|
|
133
277
|
|
|
134
278
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
@@ -139,12 +283,26 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
139
283
|
if (process.argv.includes("--stop")) {
|
|
140
284
|
// The Stop hook: a message waiting for this session keeps the agent going with it.
|
|
141
285
|
const messages = await takeMessages(agent, sessionId);
|
|
142
|
-
if (messages.length)
|
|
286
|
+
if (messages.length)
|
|
287
|
+
process.stdout.write(
|
|
288
|
+
JSON.stringify({ decision: "block", reason: messagesText(messages) }) +
|
|
289
|
+
"\n"
|
|
290
|
+
);
|
|
291
|
+
else await noteTurn(agent, input);
|
|
143
292
|
} else {
|
|
144
293
|
if (process.argv.includes("--if-changed")) await notePrompt(agent, input);
|
|
145
294
|
else await notePlace(agent, input);
|
|
146
|
-
const messages = process.argv.includes("--if-changed")
|
|
147
|
-
|
|
295
|
+
const messages = process.argv.includes("--if-changed")
|
|
296
|
+
? await takeMessages(agent, sessionId)
|
|
297
|
+
: [];
|
|
298
|
+
const text = [
|
|
299
|
+
messages.length ? messagesText(messages) : "",
|
|
300
|
+
process.argv.includes("--if-changed")
|
|
301
|
+
? await boardNudgeIfChanged(agent)
|
|
302
|
+
: await boardNudge(agent),
|
|
303
|
+
]
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
.join("\n\n");
|
|
148
306
|
if (text) process.stdout.write(text + "\n");
|
|
149
307
|
}
|
|
150
308
|
}
|
package/lib/ocr.mjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import pngjs from "pngjs";
|
|
4
|
+
import jpeg from "jpeg-js";
|
|
5
|
+
|
|
6
|
+
// On-device OCR for images that leave the computer (redact.mjs), with the browser's PP-OCR pair (localmodels.mjs "ocr")
|
|
7
|
+
// ported to plain Node. The detector marks text as a probability map; each connected region of it becomes a box (DB
|
|
8
|
+
// post-processing on axis-aligned regions, the shape screen text has), widened back to the text's full extent. Each
|
|
9
|
+
// box is then read alone at the image's full resolution (per box, as the browser does: batching crops of different
|
|
10
|
+
// widths changed reads), and every character keeps the stretch of the box it was read from, so part of a line can be
|
|
11
|
+
// covered.
|
|
12
|
+
|
|
13
|
+
// Larger images are refused rather than decoded (a 5 MB PNG can hold far more pixels than any screen).
|
|
14
|
+
export const MAX_PIXELS = 40e6;
|
|
15
|
+
// The detector's own settings (its inference.yml: thresh 0.2, box_thresh 0.45, unclip_ratio 1.4) at a long side of
|
|
16
|
+
// 960 to 2,048 pixels: a retina screenshot is shrunk to 2,048 across, a small image is enlarged.
|
|
17
|
+
const DET = {
|
|
18
|
+
min: 960,
|
|
19
|
+
max: 2_048,
|
|
20
|
+
thresh: 0.2,
|
|
21
|
+
boxThresh: 0.45,
|
|
22
|
+
unclip: 1.4,
|
|
23
|
+
mean: [0.485, 0.456, 0.406],
|
|
24
|
+
std: [0.229, 0.224, 0.225],
|
|
25
|
+
};
|
|
26
|
+
const REC = { height: 48, mean: [0.5, 0.5, 0.5], std: [0.5, 0.5, 0.5] };
|
|
27
|
+
// Boxes read per scheduler job, so a long image does not hold the ONNX lane from the firewall's checks.
|
|
28
|
+
const REC_PER_JOB = 24;
|
|
29
|
+
|
|
30
|
+
export const imageKind = (bytes) =>
|
|
31
|
+
bytes?.length > 8 &&
|
|
32
|
+
bytes[0] === 0x89 &&
|
|
33
|
+
bytes[1] === 0x50 &&
|
|
34
|
+
bytes[2] === 0x4e &&
|
|
35
|
+
bytes[3] === 0x47
|
|
36
|
+
? "png"
|
|
37
|
+
: bytes?.length > 3 &&
|
|
38
|
+
bytes[0] === 0xff &&
|
|
39
|
+
bytes[1] === 0xd8 &&
|
|
40
|
+
bytes[2] === 0xff
|
|
41
|
+
? "jpeg"
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
/** { width, height, data: RGBA } from PNG or JPEG bytes; null for any other format. Throws on a broken or oversized image. */
|
|
45
|
+
export function decodeImage(bytes) {
|
|
46
|
+
const buffer = Buffer.from(
|
|
47
|
+
bytes.buffer ?? bytes,
|
|
48
|
+
bytes.byteOffset ?? 0,
|
|
49
|
+
bytes.byteLength ?? bytes.length
|
|
50
|
+
);
|
|
51
|
+
const kind = imageKind(buffer);
|
|
52
|
+
if (kind === "png") {
|
|
53
|
+
if (buffer.readUInt32BE(16) * buffer.readUInt32BE(20) > MAX_PIXELS)
|
|
54
|
+
throw new Error("the image is too large to check");
|
|
55
|
+
const { width, height, data } = pngjs.PNG.sync.read(buffer);
|
|
56
|
+
return { width, height, data };
|
|
57
|
+
}
|
|
58
|
+
if (kind === "jpeg")
|
|
59
|
+
return jpeg.decode(buffer, {
|
|
60
|
+
useTArray: true,
|
|
61
|
+
formatAsRGBA: true,
|
|
62
|
+
maxResolutionInMP: MAX_PIXELS / 1e6,
|
|
63
|
+
maxMemoryUsageInMB: 1_024,
|
|
64
|
+
});
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The recogniser's characters, from the character_dict list in its inference.yml. */
|
|
69
|
+
export function characterList(yml) {
|
|
70
|
+
const lines = yml.split(/\r?\n/);
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const line of lines.slice(
|
|
73
|
+
lines.findIndex((each) => /^\s*character_dict:\s*$/.test(each)) + 1
|
|
74
|
+
)) {
|
|
75
|
+
const item = line.match(/^\s*- (.*)$/);
|
|
76
|
+
if (!item) break;
|
|
77
|
+
const value = item[1];
|
|
78
|
+
out.push(
|
|
79
|
+
value.startsWith("'")
|
|
80
|
+
? value.slice(1, -1).replace(/''/g, "'")
|
|
81
|
+
: value.startsWith('"')
|
|
82
|
+
? JSON.parse(value)
|
|
83
|
+
: value
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!out.length)
|
|
87
|
+
throw new Error("the recogniser's character list is missing");
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Per axis, the source pixels and weights behind each output pixel: their average when shrinking, bilinear when growing.
|
|
92
|
+
function taps(start, length, count, limit) {
|
|
93
|
+
const scale = length / count;
|
|
94
|
+
const at = (p) => Math.min(limit - 1, Math.max(0, p));
|
|
95
|
+
const out = new Array(count);
|
|
96
|
+
for (let i = 0; i < count; i += 1) {
|
|
97
|
+
const list = [];
|
|
98
|
+
if (scale > 1) {
|
|
99
|
+
const a = start + i * scale,
|
|
100
|
+
b = a + scale;
|
|
101
|
+
for (let p = Math.floor(a); p < b; p += 1) {
|
|
102
|
+
const w = Math.min(b, p + 1) - Math.max(a, p);
|
|
103
|
+
if (w > 0) list.push(at(p), w / scale);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const c = start + (i + 0.5) * scale - 0.5,
|
|
107
|
+
p = Math.floor(c),
|
|
108
|
+
f = c - p;
|
|
109
|
+
list.push(at(p), 1 - f, at(p + 1), f);
|
|
110
|
+
}
|
|
111
|
+
out[i] = list;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A region of the image resampled to width x height as a normalized BGR tensor (PaddleOCR's models read OpenCV's BGR); transparency is laid on white. */
|
|
117
|
+
function tensorOf(image, [x0, y0, x1, y1], width, height, { mean, std }) {
|
|
118
|
+
const xs = taps(x0, x1 - x0, width, image.width),
|
|
119
|
+
ys = taps(y0, y1 - y0, height, image.height);
|
|
120
|
+
const plane = width * height,
|
|
121
|
+
out = new Float32Array(3 * plane),
|
|
122
|
+
data = image.data,
|
|
123
|
+
stride = image.width * 4;
|
|
124
|
+
for (let j = 0; j < height; j += 1) {
|
|
125
|
+
const ty = ys[j];
|
|
126
|
+
for (let i = 0; i < width; i += 1) {
|
|
127
|
+
const tx = xs[i];
|
|
128
|
+
let r = 0,
|
|
129
|
+
g = 0,
|
|
130
|
+
b = 0;
|
|
131
|
+
for (let v = 0; v < ty.length; v += 2) {
|
|
132
|
+
const row = ty[v] * stride,
|
|
133
|
+
wy = ty[v + 1];
|
|
134
|
+
for (let u = 0; u < tx.length; u += 2) {
|
|
135
|
+
const p = row + tx[u] * 4,
|
|
136
|
+
w = wy * tx[u + 1],
|
|
137
|
+
a = data[p + 3] / 255,
|
|
138
|
+
white = 255 * (1 - a);
|
|
139
|
+
r += (data[p] * a + white) * w;
|
|
140
|
+
g += (data[p + 1] * a + white) * w;
|
|
141
|
+
b += (data[p + 2] * a + white) * w;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const k = j * width + i;
|
|
145
|
+
out[k] = (b / 255 - mean[0]) / std[0];
|
|
146
|
+
out[plane + k] = (g / 255 - mean[1]) / std[1];
|
|
147
|
+
out[2 * plane + k] = (r / 255 - mean[2]) / std[2];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// DB post-processing: the regions above the threshold whose mean probability passes the box threshold, each grown by
|
|
154
|
+
// area x unclip / perimeter on every side (the detector is trained on text shrunk by that much).
|
|
155
|
+
function regions(map, width, height) {
|
|
156
|
+
const seen = new Uint8Array(width * height),
|
|
157
|
+
stack = new Int32Array(width * height),
|
|
158
|
+
boxes = [];
|
|
159
|
+
for (let start = 0; start < map.length; start += 1) {
|
|
160
|
+
if (seen[start] || map[start] <= DET.thresh) continue;
|
|
161
|
+
seen[start] = 1;
|
|
162
|
+
let top = 0,
|
|
163
|
+
x0 = width,
|
|
164
|
+
y0 = height,
|
|
165
|
+
x1 = -1,
|
|
166
|
+
y1 = -1,
|
|
167
|
+
sum = 0,
|
|
168
|
+
count = 0;
|
|
169
|
+
stack[top++] = start;
|
|
170
|
+
const visit = (q) => {
|
|
171
|
+
if (!seen[q] && map[q] > DET.thresh) {
|
|
172
|
+
seen[q] = 1;
|
|
173
|
+
stack[top++] = q;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
while (top) {
|
|
177
|
+
const p = stack[--top],
|
|
178
|
+
x = p % width,
|
|
179
|
+
y = (p - x) / width;
|
|
180
|
+
sum += map[p];
|
|
181
|
+
count += 1;
|
|
182
|
+
if (x < x0) x0 = x;
|
|
183
|
+
if (x > x1) x1 = x;
|
|
184
|
+
if (y < y0) y0 = y;
|
|
185
|
+
if (y > y1) y1 = y;
|
|
186
|
+
if (x > 0) visit(p - 1);
|
|
187
|
+
if (x < width - 1) visit(p + 1);
|
|
188
|
+
if (y > 0) visit(p - width);
|
|
189
|
+
if (y < height - 1) visit(p + width);
|
|
190
|
+
}
|
|
191
|
+
const w = x1 - x0 + 1,
|
|
192
|
+
h = y1 - y0 + 1;
|
|
193
|
+
if (Math.min(w, h) < 3 || sum / count < DET.boxThresh) continue;
|
|
194
|
+
const d = (w * h * DET.unclip) / (2 * (w + h));
|
|
195
|
+
boxes.push([x0 - d, y0 - d, x1 + 1 + d, y1 + 1 + d]);
|
|
196
|
+
}
|
|
197
|
+
return boxes;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The detector's memory grows with its input (about 1 KB a pixel: 1.6 GB for a 2048x1280 screenshot, measured
|
|
201
|
+
// October 14), and ONNX Runtime keeps memory for every input shape it has run. So the detector reads full-width bands
|
|
202
|
+
// WINDOW rows tall, always the same shape (rows past the image and columns up to a multiple of 512 repeat its edge), and
|
|
203
|
+
// the probability map is stitched from rows at least MARGIN from a band's edge. Lines are never cut: regions are found
|
|
204
|
+
// on the whole map. On the image-redaction eval's screens this found what one pass did, and the OCR models' memory
|
|
205
|
+
// levelled off at 544 MB instead of growing past 2 GB (160-row bands; 256 rows: 766 MB and about 10% faster).
|
|
206
|
+
const BAND = 160,
|
|
207
|
+
MARGIN = 32,
|
|
208
|
+
WINDOW = BAND + 2 * MARGIN;
|
|
209
|
+
|
|
210
|
+
async function detect(image, { ort, graph }) {
|
|
211
|
+
const long = Math.max(image.width, image.height);
|
|
212
|
+
const ratio = Math.min(DET.max, Math.max(DET.min, long)) / long;
|
|
213
|
+
const width = Math.max(32, Math.round((image.width * ratio) / 32) * 32),
|
|
214
|
+
height = Math.max(32, Math.round((image.height * ratio) / 32) * 32);
|
|
215
|
+
const columns = Math.ceil(width / 512) * 512;
|
|
216
|
+
const session = await graph("det/inference.onnx");
|
|
217
|
+
const sx = image.width / width,
|
|
218
|
+
sy = image.height / height;
|
|
219
|
+
const map = new Float32Array(width * height);
|
|
220
|
+
for (let top = 0; top < height; top += BAND) {
|
|
221
|
+
const a = Math.max(0, Math.min(top - MARGIN, height - WINDOW)),
|
|
222
|
+
rows = Math.min(WINDOW, height - a);
|
|
223
|
+
const pixels = tensorOf(
|
|
224
|
+
image,
|
|
225
|
+
[0, a * sy, image.width, (a + rows) * sy],
|
|
226
|
+
width,
|
|
227
|
+
rows,
|
|
228
|
+
DET
|
|
229
|
+
),
|
|
230
|
+
input = new Float32Array(3 * WINDOW * columns);
|
|
231
|
+
// Padding repeats the edge pixels: a flat block beside the image made the detector see text in empty space.
|
|
232
|
+
for (let c = 0; c < 3; c += 1)
|
|
233
|
+
for (let y = 0; y < WINDOW; y += 1) {
|
|
234
|
+
const from = (c * rows + Math.min(y, rows - 1)) * width,
|
|
235
|
+
to = (c * WINDOW + y) * columns;
|
|
236
|
+
input.set(pixels.subarray(from, from + width), to);
|
|
237
|
+
input.fill(pixels[from + width - 1], to + width, to + columns);
|
|
238
|
+
}
|
|
239
|
+
const band = (
|
|
240
|
+
await session.run({
|
|
241
|
+
[session.inputNames[0]]: new ort.Tensor("float32", input, [
|
|
242
|
+
1,
|
|
243
|
+
3,
|
|
244
|
+
WINDOW,
|
|
245
|
+
columns,
|
|
246
|
+
]),
|
|
247
|
+
})
|
|
248
|
+
)[session.outputNames[0]].data;
|
|
249
|
+
for (let y = top; y < Math.min(height, top + BAND); y += 1)
|
|
250
|
+
map.set(
|
|
251
|
+
band.subarray((y - a) * columns, (y - a) * columns + width),
|
|
252
|
+
y * width
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return regions(map, width, height)
|
|
256
|
+
.map(([x0, y0, x1, y1]) => [
|
|
257
|
+
Math.max(0, x0 * sx),
|
|
258
|
+
Math.max(0, y0 * sy),
|
|
259
|
+
Math.min(image.width, x1 * sx),
|
|
260
|
+
Math.min(image.height, y1 * sy),
|
|
261
|
+
])
|
|
262
|
+
.filter(([x0, y0, x1, y1]) => x1 - x0 >= 4 && y1 - y0 >= 4);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ONNX Runtime keeps memory for every input shape it has run (400 crop widths: 108 MB grew to 513 MB, October 14), so
|
|
266
|
+
// crops are padded on the right to a few widths, with 0 after normalization as PaddleOCR's own batches pad. Over this
|
|
267
|
+
// eval's screens that changed 16 of 845 lines, by spacing or a dash, none of a planted value.
|
|
268
|
+
const bucket = (width) =>
|
|
269
|
+
width <= 512 ? Math.ceil(width / 32) * 32 : Math.ceil(width / 64) * 64;
|
|
270
|
+
|
|
271
|
+
// CTC greedy decoding that keeps, for each character, the output steps it was read over (each step is a fixed slice of
|
|
272
|
+
// the crop's width).
|
|
273
|
+
async function recognise(image, box, characters, { ort, graph }) {
|
|
274
|
+
const session = await graph("rec/inference.onnx");
|
|
275
|
+
const [x0, y0, x1, y1] = box;
|
|
276
|
+
const width = Math.max(8, Math.round((REC.height * (x1 - x0)) / (y1 - y0)));
|
|
277
|
+
const padded = bucket(width),
|
|
278
|
+
crop = tensorOf(image, box, width, REC.height, REC),
|
|
279
|
+
tensor = new Float32Array(3 * REC.height * padded);
|
|
280
|
+
for (let row = 0; row < 3 * REC.height; row += 1)
|
|
281
|
+
tensor.set(crop.subarray(row * width, (row + 1) * width), row * padded);
|
|
282
|
+
const input = new ort.Tensor("float32", tensor, [1, 3, REC.height, padded]);
|
|
283
|
+
const out = (await session.run({ [session.inputNames[0]]: input }))[
|
|
284
|
+
session.outputNames[0]
|
|
285
|
+
];
|
|
286
|
+
const [, all, classes] = out.dims,
|
|
287
|
+
data = out.data;
|
|
288
|
+
const steps = Math.min(all, Math.ceil((all * width) / padded));
|
|
289
|
+
const space = classes === characters.length + 2 ? classes - 1 : -1;
|
|
290
|
+
const pixels = ((x1 - x0) / width) * (padded / all);
|
|
291
|
+
const read = [];
|
|
292
|
+
let total = 0,
|
|
293
|
+
previous = 0;
|
|
294
|
+
for (let t = 0; t < steps; t += 1) {
|
|
295
|
+
let best = 0,
|
|
296
|
+
p = data[t * classes];
|
|
297
|
+
for (let c = 1; c < classes; c += 1)
|
|
298
|
+
if (data[t * classes + c] > p) {
|
|
299
|
+
p = data[t * classes + c];
|
|
300
|
+
best = c;
|
|
301
|
+
}
|
|
302
|
+
if (best && best === previous)
|
|
303
|
+
read[read.length - 1].to = x0 + (t + 1) * pixels;
|
|
304
|
+
else if (best) {
|
|
305
|
+
read.push({
|
|
306
|
+
ch: best === space ? " " : (characters[best - 1] ?? ""),
|
|
307
|
+
from: x0 + t * pixels,
|
|
308
|
+
to: x0 + (t + 1) * pixels,
|
|
309
|
+
});
|
|
310
|
+
total += p;
|
|
311
|
+
}
|
|
312
|
+
previous = best;
|
|
313
|
+
}
|
|
314
|
+
// One entry per UTF-16 unit, so chars lines up with the text's indexes.
|
|
315
|
+
const text = read.map((each) => each.ch).join("");
|
|
316
|
+
const chars = read.flatMap(({ ch, from, to }) =>
|
|
317
|
+
Array.from({ length: ch.length }, () => [Math.floor(from), Math.ceil(to)])
|
|
318
|
+
);
|
|
319
|
+
const lead = text.length - text.trimStart().length,
|
|
320
|
+
kept = text.trim().length;
|
|
321
|
+
return {
|
|
322
|
+
text: text.trim(),
|
|
323
|
+
box: [Math.floor(x0), Math.floor(y0), Math.ceil(x1), Math.ceil(y1)],
|
|
324
|
+
score: read.length ? total / read.length : 0,
|
|
325
|
+
chars: chars.slice(lead, lead + kept),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Top to bottom, left to right within a row (rows: boxes whose tops are within a quarter of their combined height).
|
|
330
|
+
const readingOrder = (lines) =>
|
|
331
|
+
lines.sort((a, b) =>
|
|
332
|
+
Math.abs(a.box[1] - b.box[1]) <
|
|
333
|
+
(a.box[3] - a.box[1] + b.box[3] - b.box[1]) / 4
|
|
334
|
+
? a.box[0] - b.box[0]
|
|
335
|
+
: a.box[1] - b.box[1]
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
export function createOcr({ inference }) {
|
|
339
|
+
let characters = null;
|
|
340
|
+
/**
|
|
341
|
+
* The text in an image: { width, height, lines: [{ text, box: [x0, y0, x1, y1], score, chars: [[x0, x1]] }], ms }, boxes
|
|
342
|
+
* in the image's pixels. Takes PNG or JPEG bytes or a decoded { width, height, data: RGBA }.
|
|
343
|
+
*/
|
|
344
|
+
const read = async (input, { lane = "handoff" } = {}) => {
|
|
345
|
+
const started = performance.now();
|
|
346
|
+
const image = input?.data && input.width ? input : decodeImage(input);
|
|
347
|
+
if (!image) throw new Error("only PNG and JPEG images can be read");
|
|
348
|
+
const boxes = await inference.onnx("ocr", (model) => detect(image, model), {
|
|
349
|
+
lane,
|
|
350
|
+
});
|
|
351
|
+
const lines = [];
|
|
352
|
+
for (let i = 0; i < boxes.length; i += REC_PER_JOB) {
|
|
353
|
+
lines.push(
|
|
354
|
+
...(await inference.onnx(
|
|
355
|
+
"ocr",
|
|
356
|
+
async (model) => {
|
|
357
|
+
characters ||= characterList(
|
|
358
|
+
fs.readFileSync(
|
|
359
|
+
path.join(model.dir, "rec", "inference.yml"),
|
|
360
|
+
"utf8"
|
|
361
|
+
)
|
|
362
|
+
);
|
|
363
|
+
const read = [];
|
|
364
|
+
for (const box of boxes.slice(i, i + REC_PER_JOB))
|
|
365
|
+
read.push(await recognise(image, box, characters, model));
|
|
366
|
+
return read;
|
|
367
|
+
},
|
|
368
|
+
{ lane }
|
|
369
|
+
))
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
width: image.width,
|
|
374
|
+
height: image.height,
|
|
375
|
+
lines: readingOrder(lines.filter((line) => line.text)),
|
|
376
|
+
ms: Math.round(performance.now() - started),
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
return { read };
|
|
380
|
+
}
|