@repeato/native-cv 0.1.4 → 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 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
- * Generate linearly spaced values
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
- * Save debug image to file
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
- * Draw a rectangle on the debug image
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
- * Match using OpenCV Mat directly (internal use)
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
- * Get the best match
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
- * Convert pixel coordinates to percentage coordinates
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
- * Match only non-empty regions
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
- * Get probabilities for all pyramid levels
264
- * @returns Map of pyramid level to probability
265
- */
266
- getProbabilities(): Record<number, number>;
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
- /** Probabilities getter (alias for getProbabilities) */
269
- readonly probabilities: Record<number, number>;
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
- * Options for SIFT/ORB object detection
294
- */
295
- export interface FindObjectsOptions {
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
- * Feature-based object finder using ORB descriptors
306
- * Good for finding objects with distinctive features
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
- TemplateMatcherWorker: 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
- // Uses node-gyp-build to load prebuilt binaries
2
+ // Runs native operations in a bounded child-process pool for true parallelism in Electron 12
3
3
 
4
- const nativeCV = require('node-gyp-build')(__dirname);
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
- TemplateMatcher: nativeCV.TemplateMatcher,
9
- SiftObjectFinder: nativeCV.SiftObjectFinder,
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: nativeCV
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 = (name) => function () { throw new Error(errorMessage + `\nRequested native class: ${name}`) }
32
+ const makeMissing = name => function () { throw new Error(errorMessage + `\nRequested native export: ${name}`) }
33
33
 
34
34
  native = {
35
- TemplateMatcher: makeMissing('TemplateMatcher'),
36
- SiftObjectFinder: makeMissing('SiftObjectFinder')
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 both expected classes are present; if not, provide explicit error placeholders.
40
- const expected = ['TemplateMatcher', 'SiftObjectFinder']
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}'. Did you forget to call ${key}::Init in module.cc?`
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.1.4",
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"
@@ -46,7 +47,7 @@
46
47
  "prebuildify": "^6.0.1"
47
48
  },
48
49
  "engines": {
49
- "node": ">=16.0.0"
50
+ "node": ">=14.0.0"
50
51
  },
51
52
  "files": [
52
53
  "index.js",
@@ -54,6 +55,7 @@
54
55
  "native.js",
55
56
  "Vec3.js",
56
57
  "Rect.js",
58
+ "worker/",
57
59
  "prebuilds/",
58
60
  "LICENSE"
59
61
  ],
@@ -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
+ })