diff --git a/CHANGELOG.md b/CHANGELOG.md index c194aaf..32f39ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [2.1.0] - 26-09-2024 + +- (feat): remove opencv and replace it with pure javascript functions to improve loading performance + ## [2.0.1] - 06-09-2024 - (fix): skip pre-processing step if opencv did not load diff --git a/package.json b/package.json index 9b0e662..1867b9f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "author": "Anyline", "name": "@anyline/anyline-guidance-sdk", - "version": "2.0.1", + "version": "2.1.0-beta-1", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", diff --git a/src/camera/init.ts b/src/camera/init.ts index cea81a5..e198012 100644 --- a/src/camera/init.ts +++ b/src/camera/init.ts @@ -3,7 +3,6 @@ import injectCSS from '../lib/injectCSS'; import HostManager from '../modules/HostManager'; import initRouter from '../lib/initRouter'; import { type Config } from '../modules/ConfigManager/ConfigManager'; -import OpenCVManager from '../modules/OpenCVManager'; import DocumentScrollController from '../modules/DocumentScrollController'; import CallbackHandler from '../modules/CallbackHandler'; @@ -51,10 +50,7 @@ function init(config: Config, callbacks: Callbacks): void { onPreProcessingChecksFailed ); } - - const opencvManager = OpenCVManager.getInstance(); - opencvManager.loadOpenCV(); - + const documentScrollController = DocumentScrollController.getInstance(); documentScrollController.disableScroll(); diff --git a/src/lib/preProcessImage.ts b/src/lib/preProcessImage.ts index 15513be..f71db2f 100644 --- a/src/lib/preProcessImage.ts +++ b/src/lib/preProcessImage.ts @@ -1,18 +1,422 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -const BLUR_THRESHOLD = 30; +// Inspired by https://medium.com/revolut/canvas-based-javascript-blur-detection-b92ab1075acf + +const MIN_EDGE_INTENSITY = 20; +const BLUR_BEFORE_EDGE_DETECTION_MIN_WIDTH = 360; // pixels +const BLUR_BEFORE_EDGE_DETECTION_DIAMETER = 5.0; // pixels + +const NUMBER_EDGES_THRESHOLD = 4000; +const AVERAGE_EDGES_WIDTH_THRESHOLD = 5; +const AVERAGE_EDGES_WIDTH_PERC_THRESHOLD = 0.6; + const CONTRAST_THRESHOLD = 30; -const CANNY_LOWER_THRESHOLD = 50; -const CANNY_UPPER_THRESHOLD = 300; const defaultMetrics = { isBlurDetected: false, - isEdgeDetected: true, isContrastLow: false, }; +interface ImageData { + width: number; + height: number; + data: Uint8ClampedArray; + colorSpace?: string; +} + +interface BlurredData { + width: number; + height: number; + num_edges: number; + avg_edge_width: number; + avg_edge_width_perc: number; +} + +interface ImageFilter { + convertToGrayscale: (imageData: ImageData) => Uint8ClampedArray; + computeContrast: (data: Uint8ClampedArray) => number; + gaussianBlur: (pixels: ImageData, diameter: number) => ImageData; + identity: (pixels: ImageData) => ImageData; + createImageData: (width: number, height: number) => ImageData; + separableConvolve: ( + pixels: ImageData, + horizontalWeights: Float32Array, + vertWeights: Float32Array, + opaque: boolean + ) => ImageData; + horizontalConvolve: ( + pixels: ImageData, + weightsVector: Float32Array, + opaque: boolean + ) => ImageData; + verticalConvolveFloat32: ( + pixels: ImageData, + weightsVector: Float32Array, + opaque: boolean + ) => ImageData; + luminance: (pixels: ImageData) => ImageData; + convolve: ( + pixels: ImageData, + weights: Float32Array, + opaque: boolean + ) => ImageData; + reducePixels: (imageData: ImageData) => Uint8ClampedArray[]; + detectEdges: (imageData: ImageData) => ImageData; + detectBlur: (pixels: Uint8ClampedArray[]) => BlurredData; +} + +const filters: ImageFilter = { + convertToGrayscale(imageData) { + const grayData = new Uint8ClampedArray( + imageData.width * imageData.height + ); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + grayData[i / 4] = 0.299 * r + 0.587 * g + 0.114 * b; + } + return grayData; + }, + + computeContrast(data) { + const mean = data.reduce((a, b) => a + b, 0) / data.length; + return Math.sqrt( + data.reduce((a, b) => a + (b - mean) ** 2, 0) / data.length + ); + }, + + gaussianBlur(pixels, diameter) { + diameter = Math.abs(diameter); + if (diameter <= 1) return this.identity(pixels); + const radius = diameter / 2; + const len = Math.ceil(diameter) + (1 - (Math.ceil(diameter) % 2)); + const weights = new Float32Array(len); + const rho = (radius + 0.5) / 3; + const rhoSq = rho * rho; + const gaussianFactor = 1 / Math.sqrt(2 * Math.PI * rhoSq); + const rhoFactor = -1 / (2 * rho * rho); + let weightSum = 0; + const middle = Math.floor(len / 2); + for (let i = 0; i < len; i++) { + const x = i - middle; + const gx = gaussianFactor * Math.exp(x * x * rhoFactor); + weights[i] = gx; + weightSum += gx; + } + for (let i = 0; i < weights.length; i++) { + weights[i] /= weightSum; + } + return this.separableConvolve(pixels, weights, weights, false); + }, + + identity(pixels) { + const output = this.createImageData(pixels.width, pixels.height); + const dst = output.data; + const d = pixels.data; + for (let i = 0; i < d.length; i++) { + dst[i] = d[i]; + } + return output; + }, + + createImageData(width, height) { + return { + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }; + }, + + separableConvolve(pixels, horizontalWeights, vertWeights, opaque) { + return this.horizontalConvolve( + this.verticalConvolveFloat32(pixels, vertWeights, opaque), + horizontalWeights, + opaque + ); + }, + + horizontalConvolve(pixels, weightsVector, opaque) { + const side = weightsVector.length; + const halfSide = Math.floor(side / 2); + + const src = pixels.data; + const sw = pixels.width; + const sh = pixels.height; + + const w = sw; + const h = sh; + const output = this.createImageData(w, h); + const dst = output.data; + + const alphaFac = opaque ? 1 : 0; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sy = y; + const sx = x; + const dstOff = (y * w + x) * 4; + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let cx = 0; cx < side; cx++) { + const scy = sy; + const scx = Math.min( + sw - 1, + Math.max(0, sx + cx - halfSide) + ); + const srcOff = (scy * sw + scx) * 4; + const wt = weightsVector[cx]; + r += src[srcOff] * wt; + g += src[srcOff + 1] * wt; + b += src[srcOff + 2] * wt; + a += src[srcOff + 3] * wt; + } + dst[dstOff] = r; + dst[dstOff + 1] = g; + dst[dstOff + 2] = b; + dst[dstOff + 3] = a + alphaFac * (255 - a); + } + } + return output; + }, + + verticalConvolveFloat32( + pixels: ImageData, + weightsVector: Float32Array, + opaque: boolean + ): ImageData { + const side = weightsVector.length; + const halfSide = Math.floor(side / 2); + + const src = pixels.data; + const sw = pixels.width; + const sh = pixels.height; + + const w = sw; + const h = sh; + const output = this.createImageData(w, h); + const dst = output.data; + + const alphaFac = opaque ? 1 : 0; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sy = y; + const sx = x; + const dstOff = (y * w + x) * 4; + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let cy = 0; cy < side; cy++) { + const scy = Math.min( + sh - 1, + Math.max(0, sy + cy - halfSide) + ); + const scx = sx; + const srcOff = (scy * sw + scx) * 4; + const wt = weightsVector[cy]; + r += src[srcOff] * wt; + g += src[srcOff + 1] * wt; + b += src[srcOff + 2] * wt; + a += src[srcOff + 3] * wt; + } + dst[dstOff] = r; + dst[dstOff + 1] = g; + dst[dstOff + 2] = b; + dst[dstOff + 3] = a + alphaFac * (255 - a); + } + } + return output; + }, + + luminance(pixels) { + const output = this.createImageData(pixels.width, pixels.height); + const dst = output.data; + const d = pixels.data; + for (let i = 0; i < d.length; i += 4) { + const r = d[i]; + const g = d[i + 1]; + const b = d[i + 2]; + // CIE luminance for the RGB + const v = 0.2126 * r + 0.7152 * g + 0.0722 * b; + dst[i] = dst[i + 1] = dst[i + 2] = v; + dst[i + 3] = d[i + 3]; + } + return output; + }, + + convolve(pixels, weights, opaque) { + const side = Math.round(Math.sqrt(weights.length)); + const halfSide = Math.floor(side / 2); + + const src = pixels.data; + const sw = pixels.width; + const sh = pixels.height; + + const w = sw; + const h = sh; + const output = this.createImageData(w, h); + const dst = output.data; + + const alphaFac = opaque ? 1 : 0; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sy = y; + const sx = x; + const dstOff = (y * w + x) * 4; + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let cy = 0; cy < side; cy++) { + for (let cx = 0; cx < side; cx++) { + const scy = Math.min( + sh - 1, + Math.max(0, sy + cy - halfSide) + ); + const scx = Math.min( + sw - 1, + Math.max(0, sx + cx - halfSide) + ); + const srcOff = (scy * sw + scx) * 4; + const wt = weights[cy * side + cx]; + r += src[srcOff] * wt; + g += src[srcOff + 1] * wt; + b += src[srcOff + 2] * wt; + a += src[srcOff + 3] * wt; + } + } + dst[dstOff] = r; + dst[dstOff + 1] = g; + dst[dstOff + 2] = b; + dst[dstOff + 3] = a + alphaFac * (255 - a); + } + } + return output; + }, + + reducePixels(imageData) { + const { data: pixels, width } = imageData; + const rowLen = width * 4; + let i; + let x; + let y; + let row; + const rows = []; + + for (y = 0; y < pixels.length; y += rowLen) { + row = new Uint8ClampedArray(imageData.width); + x = 0; + for (i = y; i < y + rowLen; i += 4) { + row[x] = pixels[i]; + x += 1; + } + rows.push(row); + } + return rows; + }, + + detectEdges(imageData) { + const preBlurredImageData = + imageData.width >= BLUR_BEFORE_EDGE_DETECTION_MIN_WIDTH + ? filters.gaussianBlur( + imageData, + BLUR_BEFORE_EDGE_DETECTION_DIAMETER + ) + : imageData; + + const grayscale = filters.luminance(preBlurredImageData); + const sobelKernel = new Float32Array([1, 0, -1, 2, 0, -2, 1, 0, -1]); + + return filters.convolve(grayscale, sobelKernel, true); + }, + + detectBlur(pixels) { + const width = pixels[0].length; + const height = pixels.length; + + let x; + let y; + let value; + let oldValue; + let edgeStart; + let edgeWidth; + let bm; + let percWidth; + let numEdges = 0; + let sumEdgeWidths = 0; + + for (y = 0; y < height; y += 1) { + // Reset edge marker, none found yet + edgeStart = -1; + for (x = 0; x < width; x += 1) { + value = pixels[y][x]; + // Edge is still open + if (edgeStart >= 0 && x > edgeStart) { + oldValue = pixels[y][x - 1]; + // Value stopped increasing => edge ended + if (value < oldValue) { + // Only count edges that reach a certain intensity + if (oldValue >= MIN_EDGE_INTENSITY) { + edgeWidth = x - edgeStart - 1; + numEdges += 1; + sumEdgeWidths += edgeWidth; + } + edgeStart = -1; // Reset edge marker + } + } + // Edge starts + if (value === 0) { + edgeStart = x; + } + } + } + + if (numEdges === 0) { + bm = 0; + percWidth = 0; + } else { + bm = sumEdgeWidths / numEdges; + percWidth = (bm / width) * 100; + } + + return { + width, + height, + num_edges: numEdges, + avg_edge_width: bm, + avg_edge_width_perc: percWidth, + }; + }, +}; + +function measureBlur(imageData: ImageData): BlurredData { + const edgeData = filters.detectEdges(imageData); + const reducedPixelsData = filters.reducePixels(edgeData); + + return filters.detectBlur(reducedPixelsData); +} + +// Convert to imageData +function convertToImageData(img: HTMLImageElement): ImageData | undefined { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx == null) { + return undefined; + } + const fixedWidth = 800; + const scaleFactor = fixedWidth / img.width; + canvas.width = fixedWidth; + canvas.height = img.height * scaleFactor; + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + export default async function preProcessImage(blob: Blob): Promise<{ isBlurDetected: boolean; - isEdgeDetected: boolean; isContrastLow: boolean; }> { return await new Promise((resolve, reject) => { @@ -23,80 +427,28 @@ export default async function preProcessImage(blob: Blob): Promise<{ img.onload = function () { try { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (ctx == null) { + const imgData = convertToImageData(img); + if (imgData == null) { resolve(defaultMetrics); return; } - const fixedWidth = 800; - const scaleFactor = fixedWidth / img.width; - canvas.width = fixedWidth; - canvas.height = img.height * scaleFactor; - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - const imgData = ctx.getImageData( - 0, - 0, - canvas.width, - canvas.height - ); - - const gamma = 2.2; - for (let i = 0; i < imgData.data.length; i += 4) { - for (let c = 0; c < 3; c++) { - let intensity = imgData.data[i + c]; - intensity = Math.pow(intensity / 255, gamma) * 255; - imgData.data[i + c] = intensity; - } - } - let { isBlurDetected, isEdgeDetected, isContrastLow } = - defaultMetrics; - - if (cv !== undefined) { - const src = cv.matFromImageData(imgData); - const dst = new cv.Mat(); - cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY); - const laplaceMat = new cv.Mat(); - const edgeMat = new cv.Mat(); - cv.Laplacian( - dst, - laplaceMat, - cv.CV_8U, - 1, - 1, - 0, - cv.BORDER_DEFAULT - ); + const blurResult = measureBlur(imgData); - // blur - const varianceLap = computeVarianceOrContrast( - laplaceMat, - true - ); - isBlurDetected = varianceLap < BLUR_THRESHOLD; - - cv.Canny( - dst, - edgeMat, - CANNY_LOWER_THRESHOLD, - CANNY_UPPER_THRESHOLD, - 3, - false - ); - // edge - isEdgeDetected = cv.countNonZero(edgeMat) > 0; + const isBlurDetected = + blurResult.num_edges < NUMBER_EDGES_THRESHOLD || + blurResult.avg_edge_width > AVERAGE_EDGES_WIDTH_THRESHOLD || + blurResult.avg_edge_width_perc > + AVERAGE_EDGES_WIDTH_PERC_THRESHOLD; - // contrast - const contrastValue = computeVarianceOrContrast(dst, false); - isContrastLow = contrastValue < CONTRAST_THRESHOLD; + const grayData = filters.convertToGrayscale(imgData); - laplaceMat.delete(); - edgeMat.delete(); - src.delete(); - dst.delete(); - } - resolve({ isBlurDetected, isEdgeDetected, isContrastLow }); + // Perform contrast detection + const contrastValue = filters.computeContrast(grayData); + + const isContrastLow = contrastValue < CONTRAST_THRESHOLD; + + resolve({ isBlurDetected, isContrastLow }); } catch (error) { reject(error); } @@ -107,15 +459,3 @@ export default async function preProcessImage(blob: Blob): Promise<{ }; }); } - -function computeVarianceOrContrast(mat: any, isVariance: boolean): any { - const mean = new cv.Mat(); - const stdDev = new cv.Mat(); - cv.meanStdDev(mat, mean, stdDev); - const result = isVariance - ? Math.pow(stdDev.data64F[0], 2) - : stdDev.data64F[0]; - mean.delete(); - stdDev.delete(); - return result; -} diff --git a/src/modules/ImageChecker.ts b/src/modules/ImageChecker.ts index adac190..51b3ebf 100644 --- a/src/modules/ImageChecker.ts +++ b/src/modules/ImageChecker.ts @@ -37,9 +37,10 @@ export default class ImageChecker { public async isImageQualityGood(): Promise<boolean> { if (this.blob === null) throw new Error('No image to process'); try { - const { isBlurDetected, isContrastLow, isEdgeDetected } = - await preProcessImage(this.blob); - return !isBlurDetected && isEdgeDetected && !isContrastLow; + const { isBlurDetected, isContrastLow } = await preProcessImage( + this.blob + ); + return !(isBlurDetected || isContrastLow); } catch (err) { return true; } diff --git a/src/modules/OpenCVManager.ts b/src/modules/OpenCVManager.ts deleted file mode 100644 index 79a6e18..0000000 --- a/src/modules/OpenCVManager.ts +++ /dev/null @@ -1,81 +0,0 @@ -export default class OpenCVManager { - private static instance: OpenCVManager | null = null; - private readonly script: HTMLScriptElement = - document.createElement('script'); - - private readonly opencvLoadedPromise: Promise<void>; - private opencvLoadedResolve: (() => void) | null = null; - private opencvLoadedReject: ((reason?: any) => void) | null = null; - public isOpenCVLoaded: boolean = false; - - private constructor() { - this.opencvLoadedPromise = new Promise<void>((resolve, reject) => { - this.opencvLoadedResolve = resolve; - this.opencvLoadedReject = reject; - }); - } - - public static getInstance(): OpenCVManager { - if (OpenCVManager.instance === null) { - OpenCVManager.instance = new OpenCVManager(); - } - return OpenCVManager.instance; - } - - public loadOpenCV(): void { - if (Boolean((window as any)?.cv) && this.opencvLoadedResolve !== null) { - this.opencvLoadedResolve(); - this.isOpenCVLoaded = true; - return; - } - - if (document.getElementById('anyline-guidance-sdk-opencv') != null) - return; - - this.script.type = 'text/javascript'; - this.script.src = 'https://docs.opencv.org/4.9.0/opencv.js'; - this.script.setAttribute('data-testid', 'modules-opencvmanager'); - this.script.id = 'anyline-guidance-sdk-opencv'; - const head = document.getElementsByTagName('head')[0]; - head.appendChild(this.script); - this.script.onload = () => { - cv.onRuntimeInitialized = () => { - if (this.opencvLoadedResolve !== null) { - this.opencvLoadedResolve(); - this.isOpenCVLoaded = true; - } - }; - }; - this.script.onerror = () => { - if (this.opencvLoadedReject !== null) { - this.opencvLoadedReject( - new Error('Failed to load OpenCV script') - ); - } - }; - } - - public onLoad(callback: (error?: Error) => Promise<void>): void { - if (!this.isOpenCVLoaded) { - void callback(new Error('OpenCV is not fully loaded yet')); - return; - } - - this.opencvLoadedPromise - .then(async () => { - await callback(); - }) - .catch(async error => { - await callback(error as Error); - }); - } - - public destroy(): void { - if (this.script.parentNode != null) { - this.script.parentNode.removeChild(this.script); - } - OpenCVManager.instance = null; - delete (global as any).cv; - this.isOpenCVLoaded = false; - } -} diff --git a/src/screens/videoStream/button/index.ts b/src/screens/videoStream/button/index.ts index 9e2f452..c73cd60 100644 --- a/src/screens/videoStream/button/index.ts +++ b/src/screens/videoStream/button/index.ts @@ -1,15 +1,11 @@ import VideoStreamScreen from '..'; import FileInputManager from '../../../modules/FileInputManager'; import ImageChecker from '../../../modules/ImageChecker'; -import ImageManager from '../../../modules/ImageManager'; -import OpenCVManager from '../../../modules/OpenCVManager'; import Router from '../../../modules/Router'; import PreProcessingScreen from '../../preProcessing'; import css from './index.module.css'; import rightArrow from './assets/rightArrow.svg'; import createPrimaryActionButton from '../../../components/primaryActionButton'; -import CallbackHandler from '../../../modules/CallbackHandler'; -import closeSDK from '../../../lib/closeSDK'; export default function createButtonElement(): HTMLDivElement { const buttonWrapper = document.createElement('div'); @@ -41,25 +37,14 @@ export default function createButtonElement(): HTMLDivElement { await fileInputManager .onFileSet() .then(file => { - const opencvManager = OpenCVManager.getInstance(); - opencvManager.onLoad(async error => { - if (error != null) { - const imageManager = ImageManager.getInstance(); - imageManager.setImageBlob(file); - const callbackHandler = CallbackHandler.getInstance(); - callbackHandler.callOnComplete({ blob: file }); - closeSDK(); - return; - } - const imagechecker = ImageChecker.getInstance(); - imagechecker.setImageBlob(file); + const imagechecker = ImageChecker.getInstance(); + imagechecker.setImageBlob(file); - const preProcessingScreen = - PreProcessingScreen.getInstance().getElement(); + const preProcessingScreen = + PreProcessingScreen.getInstance().getElement(); - routerManager.pop(); - routerManager.push(preProcessingScreen); - }); + routerManager.pop(); + routerManager.push(preProcessingScreen); }) .catch(e => { routerManager.pop(); diff --git a/tests/unit/ImageChecker.test.ts b/tests/unit/ImageChecker.test.ts index 4369fd0..75da8e7 100644 --- a/tests/unit/ImageChecker.test.ts +++ b/tests/unit/ImageChecker.test.ts @@ -21,7 +21,6 @@ describe('ImageChecker', () => { mockReturnValue: { isBlurDetected: false, isContrastLow: false, - isEdgeDetected: true, }, expected: true, }, @@ -29,7 +28,6 @@ describe('ImageChecker', () => { mockReturnValue: { isBlurDetected: true, isContrastLow: true, - isEdgeDetected: false, }, expected: false, }, @@ -37,7 +35,6 @@ describe('ImageChecker', () => { mockReturnValue: { isBlurDetected: true, isContrastLow: false, - isEdgeDetected: false, }, expected: false, }, @@ -45,7 +42,6 @@ describe('ImageChecker', () => { mockReturnValue: { isBlurDetected: false, isContrastLow: true, - isEdgeDetected: false, }, expected: false, }, @@ -53,7 +49,6 @@ describe('ImageChecker', () => { mockReturnValue: { isBlurDetected: false, isContrastLow: true, - isEdgeDetected: true, }, expected: false, }, diff --git a/tests/unit/OpenCVManager.test.ts b/tests/unit/OpenCVManager.test.ts deleted file mode 100644 index 9375446..0000000 --- a/tests/unit/OpenCVManager.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import '@testing-library/jest-dom'; -import { waitFor } from '@testing-library/dom'; -import OpenCVManager from '../../src/modules/OpenCVManager'; - -describe('OpenCVManager', () => { - let opencvManager: OpenCVManager; - - beforeEach(() => { - opencvManager = OpenCVManager.getInstance(); - }); - - afterEach(() => { - opencvManager.destroy(); - jest.clearAllMocks(); - }); - - it('loads opencv successfully', async () => { - void expect((global as any).cv).toBeUndefined(); - - opencvManager.isOpenCVLoaded = true; - const mockCallback = jest.fn(); - opencvManager.onLoad(mockCallback); - - void expect(mockCallback).not.toHaveBeenCalled(); - - opencvManager.loadOpenCV(); - - const scriptElement = document.getElementById( - 'anyline-guidance-sdk-opencv' - ) as HTMLScriptElement; - - if (scriptElement?.onload != null) { - (global as any).cv = { - onRuntimeInitialized: jest.fn(), - }; - - scriptElement.onload(new Event('load')); - (global as any).cv.onRuntimeInitialized(); - } - - await waitFor(() => { - void expect(mockCallback).toHaveBeenCalled(); - void expect((global as any).cv).toBeDefined(); - }); - }); - - it('handles opencv load error', async () => { - void expect((global as any).cv).toBeUndefined(); - - opencvManager.isOpenCVLoaded = true; - const mockCallback = jest.fn(); - opencvManager.onLoad(mockCallback); - - void expect(mockCallback).not.toHaveBeenCalled(); - - opencvManager.loadOpenCV(); - - const scriptElement = document.getElementById( - 'anyline-guidance-sdk-opencv' - ) as HTMLScriptElement; - - if (scriptElement?.onerror != null) { - scriptElement.onerror(new Event('error')); - } - - await waitFor(() => { - void expect(mockCallback).toHaveBeenCalledWith(expect.any(Error)); - void expect((global as any).cv).toBeUndefined(); - }); - }); - - it('throws error when opencv has not finished loading', async () => { - opencvManager.isOpenCVLoaded = false; - const mockCallback = jest.fn().mockResolvedValue(undefined); - opencvManager.onLoad(mockCallback); - - await waitFor(() => { - void expect(mockCallback).toHaveBeenCalledWith(expect.any(Error)); - }); - }); -});