@repeato/native-cv 0.1.6 → 0.2.0
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 +10 -1
- package/index.d.ts +45 -278
- package/index.js +382 -9
- package/native.js +10 -6
- package/package.json +3 -2
- package/prebuilds/darwin-arm64/@repeato+native-cv.node +0 -0
- package/prebuilds/win32-x64/@repeato+native-cv.node +0 -0
- package/worker/native-cv-process-worker.js +100 -0
- package/worker/native-cv-worker.js +35 -0
- package/prebuilds/darwin-x64/@repeato+native-cv.node +0 -0
package/README.md
CHANGED
|
@@ -2,4 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Native OpenCV-based computer vision module for Node.js with static linking for Electron distribution.
|
|
4
4
|
|
|
5
|
-
Used in Repeato-CLI and Repeato-Studio. Learn more at https://www.repeato.app
|
|
5
|
+
Used in Repeato-CLI and Repeato-Studio. Learn more at https://www.repeato.app
|
|
6
|
+
|
|
7
|
+
## Electron + Webpack
|
|
8
|
+
|
|
9
|
+
If you bundle your renderer with webpack, native modules must be resolvable from disk at runtime.
|
|
10
|
+
|
|
11
|
+
- Ensure `@repeato/native-cv` is treated as an external (do not bundle it).
|
|
12
|
+
- Ensure `.node` binaries are unpacked from asar (e.g. `asarUnpack: ["**/*.node"]`).
|
|
13
|
+
|
|
14
|
+
If these are misconfigured, you may see `node-gyp-build` errors like “No native build was found … loaded from: …electron.asar/renderer”.
|
package/index.d.ts
CHANGED
|
@@ -48,301 +48,68 @@ export class Vec3 {
|
|
|
48
48
|
toString(): string;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
* Match result from template matching
|
|
53
|
-
*/
|
|
54
|
-
export interface MatchResult {
|
|
55
|
-
/** X coordinate of the match (top-left corner) */
|
|
56
|
-
x: number;
|
|
57
|
-
/** Y coordinate of the match (top-left corner) */
|
|
58
|
-
y: number;
|
|
59
|
-
/** Width of the matched region */
|
|
60
|
-
width: number;
|
|
61
|
-
/** Height of the matched region */
|
|
62
|
-
height: number;
|
|
63
|
-
/** Match probability/confidence (0-1) */
|
|
64
|
-
probability: number;
|
|
65
|
-
/** Scale factor if scale-invariant matching was used */
|
|
66
|
-
scale?: number;
|
|
67
|
-
/** Pyramid level where match was found */
|
|
68
|
-
pyrLevel?: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Options for template matching
|
|
73
|
-
*/
|
|
74
|
-
export interface MatchOptions {
|
|
75
|
-
/** Minimum number of matches required */
|
|
76
|
-
minMatches?: number;
|
|
77
|
-
/** Optimal number of matches to find */
|
|
78
|
-
optimalMatches?: number;
|
|
79
|
-
/** Timeout in milliseconds */
|
|
80
|
-
timeout?: number;
|
|
81
|
-
/** Whether to use scale-invariant matching */
|
|
82
|
-
scaleInvariant?: boolean;
|
|
83
|
-
/** Accuracy level (0-1) */
|
|
84
|
-
accuracy?: number;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Native template matcher using OpenCV
|
|
89
|
-
* Provides fast, accurate template matching with pyramid-based search
|
|
90
|
-
*/
|
|
91
|
-
export class TemplateMatcher {
|
|
92
|
-
constructor();
|
|
93
|
-
|
|
94
|
-
/** Set test case ID for debugging */
|
|
95
|
-
setID(id: string): void;
|
|
96
|
-
|
|
97
|
-
/** Enable/disable debug mode */
|
|
98
|
-
setDebug(debug: boolean): void;
|
|
99
|
-
|
|
100
|
-
/** Set title for debug output */
|
|
101
|
-
setTitle(title: string): void;
|
|
102
|
-
|
|
103
|
-
/** Get the current title */
|
|
104
|
-
getTitle(): string;
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Set the template image to search for
|
|
108
|
-
* @param imageBuffer Buffer containing the image data (PNG, JPEG, etc.)
|
|
109
|
-
*/
|
|
110
|
-
setTemplateImage(imageBuffer: Buffer): void;
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Set a scale factor for the template
|
|
114
|
-
* @param scale Scale factor (1.0 = original size)
|
|
115
|
-
*/
|
|
116
|
-
setScale(scale: number): void;
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Set the image to search in
|
|
120
|
-
* @param imageBuffer Buffer containing the image data (PNG, JPEG, etc.)
|
|
121
|
-
*/
|
|
122
|
-
setImage(imageBuffer: Buffer): void;
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Create image pyramids for multi-scale matching
|
|
126
|
-
* @param levels Number of pyramid levels
|
|
127
|
-
*/
|
|
128
|
-
createPyramid(levels: number): void;
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Find matches of the template in the image
|
|
132
|
-
* @param minMatches Minimum number of matches to find
|
|
133
|
-
* @param optimalMatches Optimal number of matches
|
|
134
|
-
* @param timeout Timeout in milliseconds
|
|
135
|
-
* @returns Array of match results
|
|
136
|
-
*/
|
|
137
|
-
findMatch(minMatches?: number, optimalMatches?: number, timeout?: number): MatchResult[];
|
|
138
|
-
|
|
139
|
-
/** Get the aspect ratio of the template */
|
|
140
|
-
getAspectRatio(): number;
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Perform scale-invariant template matching
|
|
144
|
-
* @param minMatches Minimum matches required
|
|
145
|
-
* @param optimalMatches Optimal number of matches
|
|
146
|
-
* @param timeout Timeout in milliseconds
|
|
147
|
-
* @returns Array of match results
|
|
148
|
-
*/
|
|
149
|
-
matchScaleInvariant(minMatches?: number, optimalMatches?: number, timeout?: number): MatchResult[];
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Match using image pyramid
|
|
153
|
-
* @param minMatches Minimum matches required
|
|
154
|
-
* @param optimalMatches Optimal number of matches
|
|
155
|
-
* @param timeout Timeout in milliseconds
|
|
156
|
-
* @param resolutionFixScale Resolution fix scale factor
|
|
157
|
-
* @param scaleInvariant Whether to use scale-invariant matching
|
|
158
|
-
* @param accuracy Accuracy level (0-1)
|
|
159
|
-
* @returns Array of match results
|
|
160
|
-
*/
|
|
161
|
-
matchPyramid(
|
|
162
|
-
minMatches?: number,
|
|
163
|
-
optimalMatches?: number,
|
|
164
|
-
timeout?: number,
|
|
165
|
-
resolutionFixScale?: number,
|
|
166
|
-
scaleInvariant?: boolean,
|
|
167
|
-
accuracy?: number
|
|
168
|
-
): MatchResult[];
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Add matches to existing results without duplicates
|
|
172
|
-
* @param allMatches Existing matches
|
|
173
|
-
* @param moreMatches New matches to add
|
|
174
|
-
* @returns Combined array without duplicates
|
|
175
|
-
*/
|
|
176
|
-
addMatchesWithoutDuplicates(allMatches: MatchResult[], moreMatches: MatchResult[]): MatchResult[];
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Check if two matches are similar (overlapping)
|
|
180
|
-
* @param match1 First match
|
|
181
|
-
* @param match2 Second match
|
|
182
|
-
* @returns True if matches are similar
|
|
183
|
-
*/
|
|
184
|
-
similar(match1: MatchResult, match2: MatchResult): boolean;
|
|
185
51
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* @param start Start value
|
|
189
|
-
* @param end End value
|
|
190
|
-
* @param count Number of values
|
|
191
|
-
* @returns Array of linearly spaced values
|
|
192
|
-
*/
|
|
193
|
-
linspace(start: number, end: number, count: number): number[];
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Verify subpixel matches at full resolution
|
|
197
|
-
*/
|
|
198
|
-
checkSubpixelMatchesAtFullResolution(
|
|
199
|
-
matches: MatchResult[],
|
|
200
|
-
pyrLevel: number,
|
|
201
|
-
resolutionFixScale: number,
|
|
202
|
-
scaleInvariant: boolean,
|
|
203
|
-
accuracy: number
|
|
204
|
-
): MatchResult[];
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Match at full resolution
|
|
208
|
-
*/
|
|
209
|
-
matchFullRes(
|
|
210
|
-
minMatches?: number,
|
|
211
|
-
optimalMatches?: number,
|
|
212
|
-
timeout?: number
|
|
213
|
-
): MatchResult[];
|
|
214
|
-
|
|
215
|
-
/** Show debug image (if debug mode enabled) */
|
|
216
|
-
showDebugImage(): void;
|
|
52
|
+
/** Direct access to the native module */
|
|
53
|
+
export const native: NativeModule;
|
|
217
54
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
* @param filePath Path to save the image
|
|
221
|
-
*/
|
|
222
|
-
saveDebugImage(filePath: string): void;
|
|
55
|
+
/** Calculate sum of per-channel standard deviations for an image buffer. */
|
|
56
|
+
export function getMeanStdDev(imageBuffer: Buffer): Promise<number>;
|
|
223
57
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
* @param x X coordinate
|
|
227
|
-
* @param y Y coordinate
|
|
228
|
-
* @param width Width
|
|
229
|
-
* @param height Height
|
|
230
|
-
*/
|
|
231
|
-
drawDebugRect(x: number, y: number, width: number, height: number): void;
|
|
58
|
+
/** Decode an image buffer and return its size. */
|
|
59
|
+
export function getImageSize(imageBuffer: Buffer): Promise<{ width: number; height: number }>;
|
|
232
60
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
*/
|
|
236
|
-
matchCvImage(
|
|
237
|
-
image: Buffer,
|
|
238
|
-
template: Buffer,
|
|
239
|
-
pyrLevel: number,
|
|
240
|
-
minMatches: number,
|
|
241
|
-
searchRegion?: { x: number; y: number; width: number; height: number }
|
|
242
|
-
): MatchResult[];
|
|
61
|
+
/** Calculate bounding box of differences between two images. */
|
|
62
|
+
export function calcImageDiffBoundingBox(options: CalcImageDiffBoundingBoxOptions): Promise<DiffBoundingBox | null>;
|
|
243
63
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
* @returns Best match result or null if no match found
|
|
247
|
-
*/
|
|
248
|
-
getMatch(): MatchResult | null;
|
|
64
|
+
/** Calculate difference percentage between two images. */
|
|
65
|
+
export function calcImageDiffPercentage(options: CalcImageDiffPercentageOptions): Promise<number>;
|
|
249
66
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
* @param match Match with pixel coordinates
|
|
253
|
-
* @returns Match with percentage coordinates (0-1)
|
|
254
|
-
*/
|
|
255
|
-
toPercentageCoordinates(match: MatchResult): MatchResult;
|
|
67
|
+
/** Thread-safe template matching using per-call native instances. */
|
|
68
|
+
export function findObjectViaTemplate(options: FindObjectViaTemplateOptions): Promise<MatchResult[]>;
|
|
256
69
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
*/
|
|
260
|
-
matchNonEmpty(minMatches?: number): MatchResult[];
|
|
70
|
+
/** Thread-safe SIFT/ORB matching using per-call native instances. */
|
|
71
|
+
export function findObjectViaSift(options: FindObjectViaSiftOptions): Promise<ObjectResult[]>;
|
|
261
72
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
73
|
+
export interface FindObjectViaTemplateOptions {
|
|
74
|
+
templateBuffer: Buffer;
|
|
75
|
+
frameBuffer: Buffer;
|
|
76
|
+
minMatches: number;
|
|
77
|
+
optimalMatches: number;
|
|
78
|
+
timeout: number;
|
|
79
|
+
resolutionFixScale?: number;
|
|
80
|
+
scaleInvariant?: boolean;
|
|
81
|
+
accuracy?: number;
|
|
82
|
+
roi?: { x: number; y: number; width: number; height: number };
|
|
83
|
+
debug?: boolean;
|
|
84
|
+
}
|
|
267
85
|
|
|
268
|
-
|
|
269
|
-
|
|
86
|
+
export interface FindObjectViaSiftOptions {
|
|
87
|
+
templateBuffer: Buffer;
|
|
88
|
+
frameBuffer: Buffer;
|
|
89
|
+
minMatches: number;
|
|
90
|
+
optimalMatches: number;
|
|
91
|
+
timeout: number;
|
|
92
|
+
kMinMatches?: number;
|
|
93
|
+
goodMatchTh?: number;
|
|
94
|
+
clusterSearchRadius?: number;
|
|
95
|
+
roi?: { x: number; y: number; width: number; height: number };
|
|
96
|
+
debug?: boolean;
|
|
270
97
|
}
|
|
271
98
|
|
|
272
|
-
|
|
273
|
-
* Object detection result from SIFT/ORB matching
|
|
274
|
-
*/
|
|
275
|
-
export interface ObjectResult {
|
|
276
|
-
/** X coordinate of the detected object */
|
|
99
|
+
export interface DiffBoundingBox {
|
|
277
100
|
x: number;
|
|
278
|
-
/** Y coordinate of the detected object */
|
|
279
101
|
y: number;
|
|
280
|
-
/** Width of the detected object */
|
|
281
102
|
width: number;
|
|
282
|
-
/** Height of the detected object */
|
|
283
103
|
height: number;
|
|
284
|
-
/** Detection confidence/probability */
|
|
285
|
-
probability: number;
|
|
286
|
-
/** Number of feature matches in this cluster */
|
|
287
|
-
matchCount: number;
|
|
288
|
-
/** Cluster index */
|
|
289
|
-
clusterIndex: number;
|
|
290
104
|
}
|
|
291
105
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
/** Minimum number of feature matches required */
|
|
297
|
-
minMatches?: number;
|
|
298
|
-
/** Good matches threshold (lower = stricter) */
|
|
299
|
-
goodMatchesThreshold?: number;
|
|
300
|
-
/** Cluster search radius in pixels */
|
|
301
|
-
clusterSearchRadius?: number;
|
|
106
|
+
export interface CalcImageDiffBoundingBoxOptions {
|
|
107
|
+
bufferA: Buffer;
|
|
108
|
+
bufferB: Buffer;
|
|
109
|
+
region?: { x: number; y: number; width: number; height: number } | null;
|
|
302
110
|
}
|
|
303
111
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
*/
|
|
308
|
-
export class SiftObjectFinder {
|
|
309
|
-
constructor(options?: FindObjectsOptions);
|
|
310
|
-
|
|
311
|
-
/** Set test case ID for debugging */
|
|
312
|
-
setId(id: string): void;
|
|
313
|
-
|
|
314
|
-
/** Set title for debug output */
|
|
315
|
-
setTitle(title: string): void;
|
|
316
|
-
|
|
317
|
-
/** Enable/disable debug mode */
|
|
318
|
-
setDebug(debug: boolean): void;
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Set the template image to search for
|
|
322
|
-
* @param imageBuffer Buffer containing the image data
|
|
323
|
-
*/
|
|
324
|
-
setTemplateImage(imageBuffer: Buffer): void;
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Set the image to search in
|
|
328
|
-
* @param imageBuffer Buffer containing the image data
|
|
329
|
-
*/
|
|
330
|
-
setImage(imageBuffer: Buffer): void;
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Find objects matching the template
|
|
334
|
-
* @returns Array of detected objects
|
|
335
|
-
*/
|
|
336
|
-
findObjects(): ObjectResult[];
|
|
112
|
+
export interface CalcImageDiffPercentageOptions {
|
|
113
|
+
bufferA: Buffer;
|
|
114
|
+
bufferB: Buffer;
|
|
337
115
|
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Native OpenCV module (low-level access)
|
|
341
|
-
*/
|
|
342
|
-
export interface NativeModule {
|
|
343
|
-
TemplateMatcher: typeof TemplateMatcher;
|
|
344
|
-
SiftObjectFinder: typeof SiftObjectFinder;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** Direct access to the native module */
|
|
348
|
-
export const native: NativeModule;
|
package/index.js
CHANGED
|
@@ -1,17 +1,390 @@
|
|
|
1
1
|
// Main entry point for repeato-native-cv module
|
|
2
|
-
//
|
|
2
|
+
// Runs native operations in a bounded child-process pool for true parallelism in Electron 12
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const { fork } = require('child_process')
|
|
7
|
+
const nativeCV = require('node-gyp-build')(__dirname)
|
|
8
|
+
|
|
9
|
+
const WorkerScriptPath = path.join(__dirname, 'worker', 'native-cv-process-worker.js')
|
|
10
|
+
const MaxConcurrencyFallback = 4
|
|
11
|
+
const ParsedConcurrency = Number.parseInt(process.env.NATIVE_CV_CONCURRENCY ?? '', 10)
|
|
12
|
+
const HardwareConcurrency = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length
|
|
13
|
+
const DefaultConcurrency = Math.max(1, Math.min(MaxConcurrencyFallback, HardwareConcurrency))
|
|
14
|
+
const NativeCvConcurrency = Math.max(1, Number.isFinite(ParsedConcurrency) ? ParsedConcurrency : DefaultConcurrency)
|
|
15
|
+
|
|
16
|
+
const toBuffer = (value, label) => {
|
|
17
|
+
if (Buffer.isBuffer(value)) {
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (ArrayBuffer.isView(value)) {
|
|
22
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (value instanceof ArrayBuffer) {
|
|
26
|
+
return Buffer.from(value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (value && typeof value === 'object' && value.type === 'Buffer' && value.data != null) {
|
|
30
|
+
return Buffer.from(value.data)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
throw new Error(`${label} must be a Buffer`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {{
|
|
38
|
+
* id: number,
|
|
39
|
+
* method: string,
|
|
40
|
+
* args: any[],
|
|
41
|
+
* resolve: (value: any) => void,
|
|
42
|
+
* reject: (reason?: any) => void
|
|
43
|
+
* }} NativeTask
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {{
|
|
48
|
+
* child: import('child_process').ChildProcess,
|
|
49
|
+
* busy: boolean,
|
|
50
|
+
* currentTaskId: number | null
|
|
51
|
+
* }} WorkerState
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
class NativeCvProcessPool {
|
|
55
|
+
/**
|
|
56
|
+
* @param {number} size
|
|
57
|
+
*/
|
|
58
|
+
constructor(size) {
|
|
59
|
+
this.size = Math.max(1, size)
|
|
60
|
+
/** @type {WorkerState[]} */
|
|
61
|
+
this.workers = []
|
|
62
|
+
/** @type {NativeTask[]} */
|
|
63
|
+
this.queue = []
|
|
64
|
+
/** @type {Map<number, NativeTask>} */
|
|
65
|
+
this.inFlightTasks = new Map()
|
|
66
|
+
this.taskCounter = 0
|
|
67
|
+
this.isShuttingDown = false
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < this.size; index += 1) {
|
|
70
|
+
this.workers.push(this.createWorkerState())
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} method
|
|
76
|
+
* @param {any[]} args
|
|
77
|
+
*/
|
|
78
|
+
execute(method, args) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
if (this.isShuttingDown) {
|
|
81
|
+
reject(new Error('Native CV worker pool is shutting down'))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
this.taskCounter += 1
|
|
85
|
+
this.queue.push({ id: this.taskCounter, method, args, resolve, reject })
|
|
86
|
+
this.processQueue()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
createWorkerState() {
|
|
91
|
+
const child = fork(WorkerScriptPath, [], {
|
|
92
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc']
|
|
93
|
+
})
|
|
94
|
+
/** @type {WorkerState} */
|
|
95
|
+
const state = { child, busy: false, currentTaskId: null }
|
|
96
|
+
|
|
97
|
+
child.on('message', message => {
|
|
98
|
+
const { id, result, error } = message || {}
|
|
99
|
+
if (typeof id !== 'number') {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const task = this.inFlightTasks.get(id)
|
|
103
|
+
if (!task) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.inFlightTasks.delete(id)
|
|
108
|
+
state.busy = false
|
|
109
|
+
state.currentTaskId = null
|
|
110
|
+
|
|
111
|
+
if (error) {
|
|
112
|
+
const workerError = new Error(error.message || 'Native worker execution failed')
|
|
113
|
+
if (error.stack) {
|
|
114
|
+
workerError.stack = error.stack
|
|
115
|
+
}
|
|
116
|
+
if (error.name) {
|
|
117
|
+
workerError.name = error.name
|
|
118
|
+
}
|
|
119
|
+
task.reject(workerError)
|
|
120
|
+
} else {
|
|
121
|
+
task.resolve(result)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.processQueue()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
child.on('error', err => {
|
|
128
|
+
this.failWorkerTask(state, err)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
child.on('exit', code => {
|
|
132
|
+
if (code !== 0) {
|
|
133
|
+
this.failWorkerTask(state, new Error(`Native CV process stopped with exit code ${code}`))
|
|
134
|
+
}
|
|
135
|
+
if (!this.isShuttingDown) {
|
|
136
|
+
this.replaceWorker(state)
|
|
137
|
+
}
|
|
138
|
+
this.processQueue()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return state
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {WorkerState} state
|
|
146
|
+
* @param {Error} err
|
|
147
|
+
*/
|
|
148
|
+
failWorkerTask(state, err) {
|
|
149
|
+
if (state.currentTaskId == null) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const task = this.inFlightTasks.get(state.currentTaskId)
|
|
153
|
+
if (!task) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.inFlightTasks.delete(task.id)
|
|
158
|
+
state.busy = false
|
|
159
|
+
state.currentTaskId = null
|
|
160
|
+
task.reject(err)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {WorkerState} previousState
|
|
165
|
+
*/
|
|
166
|
+
replaceWorker(previousState) {
|
|
167
|
+
const workerIndex = this.workers.indexOf(previousState)
|
|
168
|
+
if (workerIndex < 0) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
this.workers[workerIndex] = this.createWorkerState()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
processQueue() {
|
|
175
|
+
if (this.isShuttingDown) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (this.queue.length === 0) {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const workerState of this.workers) {
|
|
184
|
+
if (workerState.busy || this.queue.length === 0) {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const nextTask = this.queue.shift()
|
|
189
|
+
if (!nextTask) {
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
workerState.busy = true
|
|
194
|
+
workerState.currentTaskId = nextTask.id
|
|
195
|
+
this.inFlightTasks.set(nextTask.id, nextTask)
|
|
196
|
+
|
|
197
|
+
workerState.child.send({
|
|
198
|
+
id: nextTask.id,
|
|
199
|
+
method: nextTask.method,
|
|
200
|
+
args: nextTask.args
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async shutdown() {
|
|
206
|
+
this.isShuttingDown = true
|
|
207
|
+
|
|
208
|
+
while (this.queue.length > 0) {
|
|
209
|
+
const task = this.queue.shift()
|
|
210
|
+
if (task) {
|
|
211
|
+
task.reject(new Error('Native CV worker pool shutdown'))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const terminatePromises = this.workers.map(async workerState => {
|
|
216
|
+
try {
|
|
217
|
+
workerState.child.kill('SIGTERM')
|
|
218
|
+
} catch (_) {
|
|
219
|
+
// ignore terminate errors during shutdown
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
await Promise.all(terminatePromises)
|
|
224
|
+
this.workers = []
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const runInProcessAsync = (method, args) =>
|
|
229
|
+
new Promise((resolve, reject) => {
|
|
230
|
+
setImmediate(() => {
|
|
231
|
+
try {
|
|
232
|
+
const fn = nativeCV[method]
|
|
233
|
+
if (typeof fn !== 'function') {
|
|
234
|
+
throw new Error(`Unknown native method: ${method}`)
|
|
235
|
+
}
|
|
236
|
+
resolve(fn(...args))
|
|
237
|
+
} catch (error) {
|
|
238
|
+
reject(error)
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
let nativeCvPool = null
|
|
244
|
+
let processPoolUnavailableReason = null
|
|
245
|
+
|
|
246
|
+
const initProcessPool = () => {
|
|
247
|
+
try {
|
|
248
|
+
nativeCvPool = new NativeCvProcessPool(NativeCvConcurrency)
|
|
249
|
+
} catch (error) {
|
|
250
|
+
processPoolUnavailableReason = error?.message || 'process pool initialization failed'
|
|
251
|
+
nativeCvPool = null
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
initProcessPool()
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {string} method
|
|
259
|
+
* @param {any[]} args
|
|
260
|
+
*/
|
|
261
|
+
const runNativeAsync = async (method, args) => nativeCvPool.execute(method, args)
|
|
262
|
+
const runNative = async (method, args) => {
|
|
263
|
+
if (nativeCvPool) {
|
|
264
|
+
return runNativeAsync(method, args)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (processPoolUnavailableReason && process.env.NODE_ENV !== 'production') {
|
|
268
|
+
console.warn('[native-cv] process pool unavailable, falling back to in-process async:', processPoolUnavailableReason)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return runInProcessAsync(method, args)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const getMeanStdDev = async imageBuffer => runNative('getMeanStdDev', [toBuffer(imageBuffer, 'imageBuffer')])
|
|
275
|
+
|
|
276
|
+
const getImageSize = async imageBuffer => runNative('getImageSize', [toBuffer(imageBuffer, 'imageBuffer')])
|
|
277
|
+
|
|
278
|
+
const assertOptionsObject = (options, functionName) => {
|
|
279
|
+
if (!options || typeof options !== 'object' || Buffer.isBuffer(options)) {
|
|
280
|
+
throw new Error(`${functionName} expects a single options object.`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const calcImageDiffBoundingBox = async options => {
|
|
285
|
+
assertOptionsObject(options, 'calcImageDiffBoundingBox')
|
|
286
|
+
const { bufferA, bufferB, region } = options
|
|
287
|
+
return runNative('calcImageDiffBoundingBox', [{
|
|
288
|
+
bufferA: toBuffer(bufferA, 'bufferA'),
|
|
289
|
+
bufferB: toBuffer(bufferB, 'bufferB'),
|
|
290
|
+
region
|
|
291
|
+
}])
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const calcImageDiffPercentage = async options => {
|
|
295
|
+
assertOptionsObject(options, 'calcImageDiffPercentage')
|
|
296
|
+
const { bufferA, bufferB } = options
|
|
297
|
+
return runNative('calcImageDiffPercentage', [{
|
|
298
|
+
bufferA: toBuffer(bufferA, 'bufferA'),
|
|
299
|
+
bufferB: toBuffer(bufferB, 'bufferB')
|
|
300
|
+
}])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const findObjectViaTemplate = async options => {
|
|
304
|
+
assertOptionsObject(options, 'findObjectViaTemplate')
|
|
305
|
+
const {
|
|
306
|
+
templateBuffer,
|
|
307
|
+
frameBuffer,
|
|
308
|
+
minMatches,
|
|
309
|
+
optimalMatches,
|
|
310
|
+
timeout,
|
|
311
|
+
resolutionFixScale = 1,
|
|
312
|
+
scaleInvariant = false,
|
|
313
|
+
accuracy = 1,
|
|
314
|
+
roi,
|
|
315
|
+
debug = false
|
|
316
|
+
} = options
|
|
317
|
+
return runNative('findObjectViaTemplate', [{
|
|
318
|
+
templateBuffer: toBuffer(templateBuffer, 'templateBuffer'),
|
|
319
|
+
frameBuffer: toBuffer(frameBuffer, 'frameBuffer'),
|
|
320
|
+
minMatches,
|
|
321
|
+
optimalMatches,
|
|
322
|
+
timeout,
|
|
323
|
+
resolutionFixScale,
|
|
324
|
+
scaleInvariant,
|
|
325
|
+
accuracy,
|
|
326
|
+
roi,
|
|
327
|
+
debug
|
|
328
|
+
}])
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const findObjectViaSift = async options => {
|
|
332
|
+
assertOptionsObject(options, 'findObjectViaSift')
|
|
333
|
+
const {
|
|
334
|
+
templateBuffer,
|
|
335
|
+
frameBuffer,
|
|
336
|
+
minMatches,
|
|
337
|
+
optimalMatches,
|
|
338
|
+
timeout,
|
|
339
|
+
kMinMatches = 10,
|
|
340
|
+
goodMatchTh = 0.75,
|
|
341
|
+
clusterSearchRadius = 50,
|
|
342
|
+
roi,
|
|
343
|
+
debug = false
|
|
344
|
+
} = options
|
|
345
|
+
return runNative('findObjectViaSift', [{
|
|
346
|
+
templateBuffer: toBuffer(templateBuffer, 'templateBuffer'),
|
|
347
|
+
frameBuffer: toBuffer(frameBuffer, 'frameBuffer'),
|
|
348
|
+
minMatches,
|
|
349
|
+
optimalMatches,
|
|
350
|
+
timeout,
|
|
351
|
+
kMinMatches,
|
|
352
|
+
goodMatchTh,
|
|
353
|
+
clusterSearchRadius,
|
|
354
|
+
roi,
|
|
355
|
+
debug
|
|
356
|
+
}])
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const shutdown = async () => {
|
|
360
|
+
if (nativeCvPool) {
|
|
361
|
+
await nativeCvPool.shutdown()
|
|
362
|
+
nativeCvPool = null
|
|
363
|
+
}
|
|
364
|
+
}
|
|
5
365
|
|
|
6
366
|
// Export the native functions directly
|
|
7
367
|
module.exports = {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
368
|
+
getMeanStdDev,
|
|
369
|
+
getImageSize,
|
|
370
|
+
calcImageDiffBoundingBox,
|
|
371
|
+
calcImageDiffPercentage,
|
|
372
|
+
findObjectViaTemplate,
|
|
373
|
+
findObjectViaSift,
|
|
374
|
+
shutdown,
|
|
375
|
+
|
|
11
376
|
// JavaScript wrapper classes
|
|
12
|
-
Rect: require('./Rect.js'),
|
|
377
|
+
Rect: require('./Rect.js'),
|
|
13
378
|
Vec3: require('./Vec3.js'),
|
|
14
|
-
|
|
15
|
-
// Native module for direct access
|
|
16
|
-
native:
|
|
379
|
+
|
|
380
|
+
// Native module for direct access (no class access)
|
|
381
|
+
native: {
|
|
382
|
+
getMeanStdDev,
|
|
383
|
+
getImageSize,
|
|
384
|
+
calcImageDiffBoundingBox,
|
|
385
|
+
calcImageDiffPercentage,
|
|
386
|
+
findObjectViaTemplate,
|
|
387
|
+
findObjectViaSift,
|
|
388
|
+
shutdown
|
|
389
|
+
}
|
|
17
390
|
};
|
package/native.js
CHANGED
|
@@ -29,18 +29,22 @@ if (!native) {
|
|
|
29
29
|
console.error('[native-cv] Last error:', lastError?.message)
|
|
30
30
|
|
|
31
31
|
const errorMessage = `Native OpenCV module not found. Build it first:\n cd repeato-native-cv && npm run build\nTried paths:\n${tryPaths.map(p=>` - ${p}`).join('\n')}\nLast error: ${lastError?.message}`
|
|
32
|
-
const makeMissing =
|
|
32
|
+
const makeMissing = name => function () { throw new Error(errorMessage + `\nRequested native export: ${name}`) }
|
|
33
33
|
|
|
34
34
|
native = {
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
getMeanStdDev: makeMissing('getMeanStdDev'),
|
|
36
|
+
getImageSize: makeMissing('getImageSize'),
|
|
37
|
+
calcImageDiffBoundingBox: makeMissing('calcImageDiffBoundingBox'),
|
|
38
|
+
calcImageDiffPercentage: makeMissing('calcImageDiffPercentage'),
|
|
39
|
+
findObjectViaTemplate: makeMissing('findObjectViaTemplate'),
|
|
40
|
+
findObjectViaSift: makeMissing('findObjectViaSift')
|
|
37
41
|
}
|
|
38
42
|
} else {
|
|
39
|
-
// Ensure
|
|
40
|
-
const expected = ['
|
|
43
|
+
// Ensure expected helper exports are present; if not, provide explicit error placeholders.
|
|
44
|
+
const expected = ['getMeanStdDev', 'getImageSize', 'calcImageDiffBoundingBox', 'calcImageDiffPercentage', 'findObjectViaTemplate', 'findObjectViaSift']
|
|
41
45
|
for (const key of expected) {
|
|
42
46
|
if (!Object.prototype.hasOwnProperty.call(native, key)) {
|
|
43
|
-
const msg = `Native build missing expected export '${key}'
|
|
47
|
+
const msg = `Native build missing expected export '${key}'.`
|
|
44
48
|
console.warn('[native-cv] ' + msg)
|
|
45
49
|
native[key] = () => { throw new Error(msg) }
|
|
46
50
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@repeato/native-cv",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Native OpenCV-based template matching module for Node.js, replacing opencv4nodejs-m1.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"clean": "rm -rf build/ deps/ prebuilds/ && npm install",
|
|
17
17
|
"rebuild": "npm run clean && npm run build:static",
|
|
18
18
|
"test": "node test-runner.js",
|
|
19
|
+
"test:concurrency": "node test-concurrency.js",
|
|
19
20
|
"test:debug": "node test-runner.js --debug",
|
|
20
21
|
"test:verbose": "node test-runner.js --verbose",
|
|
21
22
|
"test:scale-invariant": "node test-runner.js --only-scale-invariant"
|
|
@@ -36,7 +37,6 @@
|
|
|
36
37
|
"url": "git+https://github.com/repeato-qa/repeato-native-cv.git"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"@repeato/native-cv": "^0.1.4",
|
|
40
40
|
"node-addon-api": "^7.0.0",
|
|
41
41
|
"node-gyp-build": "^4.8.4"
|
|
42
42
|
},
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"native.js",
|
|
56
56
|
"Vec3.js",
|
|
57
57
|
"Rect.js",
|
|
58
|
+
"worker/",
|
|
58
59
|
"prebuilds/",
|
|
59
60
|
"LICENSE"
|
|
60
61
|
],
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
|
|
3
|
+
const NativeCv = require('node-gyp-build')(path.join(__dirname, '..'))
|
|
4
|
+
|
|
5
|
+
let isIpcConnected = true
|
|
6
|
+
|
|
7
|
+
process.on('disconnect', () => {
|
|
8
|
+
isIpcConnected = false
|
|
9
|
+
process.exit(0)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
process.on('error', error => {
|
|
13
|
+
if (error?.code === 'EPIPE') {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
console.error('[native-cv-process-worker] process error:', error?.message || error)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const reviveBuffers = value => {
|
|
20
|
+
if (!value) {
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (Buffer.isBuffer(value)) {
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (ArrayBuffer.isView(value)) {
|
|
29
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (value instanceof ArrayBuffer) {
|
|
33
|
+
return Buffer.from(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
return value.map(reviveBuffers)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof value === 'object') {
|
|
41
|
+
if (value.type === 'Buffer' && value.data != null) {
|
|
42
|
+
return Buffer.from(value.data)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const output = {}
|
|
46
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
47
|
+
output[key] = reviveBuffers(entry)
|
|
48
|
+
}
|
|
49
|
+
return output
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
process.on('message', message => {
|
|
56
|
+
if (!isIpcConnected) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { id, method, args } = message || {}
|
|
61
|
+
|
|
62
|
+
if (typeof id !== 'number') {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const fn = NativeCv[method]
|
|
68
|
+
if (typeof fn !== 'function') {
|
|
69
|
+
throw new Error(`Unknown native method: ${method}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const normalizedArgs = Array.isArray(args) ? args.map(reviveBuffers) : []
|
|
73
|
+
const result = fn(...normalizedArgs)
|
|
74
|
+
if (isIpcConnected && process.connected) {
|
|
75
|
+
process.send?.({ id, result }, error => {
|
|
76
|
+
if (error && error.code !== 'EPIPE') {
|
|
77
|
+
console.error('[native-cv-process-worker] send error:', error.message)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (isIpcConnected && process.connected) {
|
|
83
|
+
process.send?.(
|
|
84
|
+
{
|
|
85
|
+
id,
|
|
86
|
+
error: {
|
|
87
|
+
message: error?.message || 'Unknown native process worker error',
|
|
88
|
+
stack: error?.stack,
|
|
89
|
+
name: error?.name
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
sendError => {
|
|
93
|
+
if (sendError && sendError.code !== 'EPIPE') {
|
|
94
|
+
console.error('[native-cv-process-worker] send error:', sendError.message)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { parentPort } = require('worker_threads')
|
|
3
|
+
|
|
4
|
+
const NativeCv = require('node-gyp-build')(path.join(__dirname, '..'))
|
|
5
|
+
|
|
6
|
+
if (!parentPort) {
|
|
7
|
+
throw new Error('native-cv-worker must run in a worker thread')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
parentPort.on('message', message => {
|
|
11
|
+
const { id, method, args } = message || {}
|
|
12
|
+
|
|
13
|
+
if (typeof id !== 'number') {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const fn = NativeCv[method]
|
|
19
|
+
if (typeof fn !== 'function') {
|
|
20
|
+
throw new Error(`Unknown native method: ${method}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const result = fn(...(Array.isArray(args) ? args : []))
|
|
24
|
+
parentPort.postMessage({ id, result })
|
|
25
|
+
} catch (error) {
|
|
26
|
+
parentPort.postMessage({
|
|
27
|
+
id,
|
|
28
|
+
error: {
|
|
29
|
+
message: error?.message || 'Unknown native worker error',
|
|
30
|
+
stack: error?.stack,
|
|
31
|
+
name: error?.name
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
})
|
|
Binary file
|