diff --git a/app/src/pipeline/detect.ts b/app/src/pipeline/detect.ts index 53a2225..535bb01 100644 --- a/app/src/pipeline/detect.ts +++ b/app/src/pipeline/detect.ts @@ -3,7 +3,7 @@ import * as ort from 'onnxruntime-web/wasm'; import wasmUrl from 'onnxruntime-web/ort-wasm-simd-threaded.wasm?url'; import yunetUrl from '../../models/face_detection_yunet_2023mar.onnx?url'; import { YUNET } from './models.ts'; -import { decodeOutputs, letterbox, nms, STRIDES } from './yunet.ts'; +import { type Candidate, decodeOutputs, letterbox, nms, resizeArea, STRIDES, tileCandidates, tiledSize } from './yunet.ts'; export type Box = { x: number; // left edge in pixels of the bitmap passed in (origin top-left, x to the right) @@ -16,7 +16,8 @@ export type Box = { export type Tiling = | { kind: 'none' } // long side resized to inputSize, one pass | { kind: 'tiles'; longSide: number; overlap: number }; // long side resized to longSide (never enlarged), cut into - // inputSize tiles overlapping by `overlap` (0 to 1), merged with NMS + // inputSize tiles overlapping by `overlap` (0 to under 1), + // merged with NMS export type DetectOptions = { model: 'yunet' | 'yolo5face'; @@ -97,16 +98,8 @@ function readPixels(image: ImageBitmap): Uint8ClampedArray { return pixels; } -export async function detectFaces(image: ImageBitmap, options: DetectOptions): Promise { - if (options.model !== 'yunet') throw new Error(`the ${options.model} model is not supported yet`); - if (options.tiling.kind !== 'none') throw new Error('tiling is not supported yet'); - if (options.inputSize !== YUNET.inputSize) throw new Error(`${YUNET.file} takes ${YUNET.inputSize} px input only`); - const session = await loadDetector(options.modelSource); - - const pixels = readPixels(image); - const size = options.inputSize; - const { tensor, scaleX, scaleY } = letterbox(pixels, image.width, image.height, size); - +/** Runs the model on one input tensor: every candidate scoring at least minScore, in the tensor's pixels. */ +async function detectTensor(session: ort.InferenceSession, tensor: Float32Array, size: number, minScore: number): Promise { const results = await session.run({ input: new ort.Tensor('float32', tensor, [1, 3, size, size]) }); const outputs: Record = {}; for (const s of STRIDES) { @@ -116,8 +109,49 @@ export async function detectFaces(image: ImageBitmap, options: DetectOptions): P outputs[name] = data; } } + return decodeOutputs(outputs, size, minScore); +} + +export async function detectFaces(image: ImageBitmap, options: DetectOptions): Promise { + if (options.model !== 'yunet') throw new Error(`the ${options.model} model is not supported yet`); + if (options.inputSize !== YUNET.inputSize) throw new Error(`${YUNET.file} takes ${YUNET.inputSize} px input only`); + // The options come from the eval runner as untyped JSON, so check them before any work. + const { tiling } = options; + const kind: string = tiling.kind; + if (kind !== 'none' && kind !== 'tiles') throw new Error(`unknown tiling kind ${kind}`); + const size = options.inputSize; + let stride = 0; + if (tiling.kind === 'tiles') { + if (!(Number.isInteger(tiling.longSide) && tiling.longSide > 0)) throw new Error(`longSide ${tiling.longSide} is not a positive whole number of pixels`); + if (!(tiling.overlap >= 0 && tiling.overlap < 1)) throw new Error(`overlap ${tiling.overlap} is not from 0 to under 1`); + stride = Math.round(size * (1 - tiling.overlap)); + if (stride < 1) throw new Error(`overlap ${tiling.overlap} puts tiles under 1 px apart`); + } + const session = await loadDetector(options.modelSource); + + // The photo's full-size pixels are read inside each branch and kept by neither, so they can be + // freed before the model runs. + const minScore = Math.min(NMS_FLOOR, options.scoreThreshold); + let candidates: Candidate[]; + let scaleX: number; + let scaleY: number; + if (tiling.kind === 'none') { + const boxed = letterbox(readPixels(image), image.width, image.height, size); + candidates = await detectTensor(session, boxed.tensor, size, minScore); + ({ scaleX, scaleY } = boxed); + } else { + // The long side shrunk to longSide, never enlarged, then tiles of `size` that overlap by + // `overlap` (docs/contracts.md, section 1). NMS runs once, over every tile's candidates in the + // shrunk image's pixels: boxes that different tiles give for one face merge when they overlap + // by more than nmsIoU, as those of a face lying whole in two tiles do. A face that no tile holds + // whole, which can happen when it is larger than the overlap, can come back as several boxes. + const { width, height } = tiledSize(image.width, image.height, tiling.longSide); + const planes = resizeArea(readPixels(image), image.width, image.height, width, height); + candidates = await tileCandidates(planes, width, height, size, stride, (tensor) => detectTensor(session, tensor, size, minScore)); + scaleX = width / image.width; + scaleY = height / image.height; + } - const candidates = decodeOutputs(outputs, size, Math.min(NMS_FLOOR, options.scoreThreshold)); return nms(candidates, options.nmsIoU) .filter((c) => c.score >= options.scoreThreshold) .map((c) => ({ x: c.x / scaleX, y: c.y / scaleY, w: c.w / scaleX, h: c.h / scaleY, score: c.score })); diff --git a/app/src/pipeline/yunet.ts b/app/src/pipeline/yunet.ts index 3cb69b8..9be4729 100644 --- a/app/src/pipeline/yunet.ts +++ b/app/src/pipeline/yunet.ts @@ -1,6 +1,6 @@ -// The parts of YuNet face detection that need no ONNX Runtime: building the input tensor, -// turning the outputs into boxes, and non-maximum suppression. Kept apart so they can be -// unit-tested in Node (tests/unit/). +// The parts of YuNet face detection that need no ONNX Runtime: building the input tensor (one +// for the whole image, or one per tile), turning the outputs into boxes, and non-maximum +// suppression. Kept apart so they can be unit-tested in Node (tests/unit/). // // face_detection_yunet_2023mar.onnx takes "input", 1 x 3 x 640 x 640 float32 in BGR order with // values 0 to 255 and no normalisation, the layout OpenCV's FaceDetectorYN gives it. For each @@ -15,7 +15,9 @@ export const STRIDES = [8, 16, 32] as const; /** * For each output index along one axis, the first input index it covers and the weight of each - * input index it covers: area averaging, which is what OpenCV's INTER_AREA does when shrinking. + * input index it covers: area averaging, the method of OpenCV's INTER_AREA when shrinking. The + * results agree pixel for pixel except where an average falls exactly halfway between two whole + * numbers: resizeArea rounds those up, while OpenCV's float32 sums can land on either side. */ function areaWeights(inSize: number, outSize: number): { first: Int32Array; weights: Float32Array[] } { const scale = inSize / outSize; @@ -35,18 +37,22 @@ function areaWeights(inSize: number, outSize: number): { first: Int32Array; weig } /** - * Resizes RGBA pixels so the long side is `size`, by area averaging, rounds to whole values as an - * 8-bit image would, and writes them as BGR planes into the top-left of a `size` x `size` tensor - * whose other values are 0 (letterboxing). The result does not depend on the browser. + * Resizes RGBA pixels to outW x outH by area averaging and rounds to whole values, halves up, as + * an 8-bit image would. Returns the B, G and R planes, outW x outH each. The result does not + * depend on the browser. */ -export function letterbox( - rgba: Uint8ClampedArray, - width: number, - height: number, - size: number, -): { tensor: Float32Array; scaleX: number; scaleY: number } { - const outW = Math.max(1, Math.round((width * size) / Math.max(width, height))); - const outH = Math.max(1, Math.round((height * size) / Math.max(width, height))); +export function resizeArea(rgba: Uint8ClampedArray, width: number, height: number, outW: number, outH: number): Uint8ClampedArray { + const plane = outW * outH; + const planes = new Uint8ClampedArray(3 * plane); + if (outW === width && outH === height) { + // Area averaging at the same size copies every pixel; this does the same, faster. + for (let i = 0; i < plane; i++) { + planes[i] = rgba[4 * i + 2]; + planes[plane + i] = rgba[4 * i + 1]; + planes[2 * plane + i] = rgba[4 * i]; + } + return planes; + } const cols = areaWeights(width, outW); const rows = areaWeights(height, outH); @@ -72,8 +78,6 @@ export function letterbox( } // Then every column to outH pixels, into the B, G and R planes. - const plane = size * size; - const tensor = new Float32Array(3 * plane); for (let y = 0; y < outH; y++) { const w = rows.weights[y]; const top = rows.first[y]; @@ -87,15 +91,101 @@ export function letterbox( g += w[k] * narrow[n + 1]; b += w[k] * narrow[n + 2]; } - const o = y * size + x; - tensor[o] = Math.round(b); - tensor[plane + o] = Math.round(g); - tensor[2 * plane + o] = Math.round(r); + const o = y * outW + x; + planes[o] = Math.round(b); + planes[plane + o] = Math.round(g); + planes[2 * plane + o] = Math.round(r); } } + return planes; +} + +/** + * A `size` x `size` input tensor holding the pixels of B, G and R planes (width x height each) + * from (left, top) at its top-left, and 0 wherever it reaches past them. + */ +export function tile(planes: Uint8ClampedArray, width: number, height: number, left: number, top: number, size: number): Float32Array { + const tensor = new Float32Array(3 * size * size); + const w = Math.min(size, width - left); + const h = Math.min(size, height - top); + for (let c = 0; c < 3; c++) { + for (let y = 0; y < h; y++) { + const from = c * width * height + (top + y) * width + left; + tensor.set(planes.subarray(from, from + w), c * size * size + y * size); + } + } + return tensor; +} + +/** + * Resizes RGBA pixels so the long side is `size`, by area averaging, rounds to whole values as an + * 8-bit image would, and writes them as BGR planes into the top-left of a `size` x `size` tensor + * whose other values are 0 (letterboxing). The result does not depend on the browser. + */ +export function letterbox( + rgba: Uint8ClampedArray, + width: number, + height: number, + size: number, +): { tensor: Float32Array; scaleX: number; scaleY: number } { + const outW = Math.max(1, Math.round((width * size) / Math.max(width, height))); + const outH = Math.max(1, Math.round((height * size) / Math.max(width, height))); + const tensor = tile(resizeArea(rgba, width, height, outW, outH), outW, outH, 0, 0, size); return { tensor, scaleX: outW / width, scaleY: outH / height }; } +/** + * The size the tiled setting shrinks an image to: its long side at most `longSide`, the other side + * in proportion. An image already no longer keeps its size: the tiled setting never enlarges. + */ +export function tiledSize(width: number, height: number, longSide: number): { width: number; height: number } { + const long = Math.max(width, height); + if (long <= longSide) return { width, height }; + return { + width: Math.max(1, Math.round((width * longSide) / long)), + height: Math.max(1, Math.round((height * longSide) / long)), + }; +} + +/** + * Where the tiles start along a side of `length` pixels: every `stride` pixels while a tile still + * ends before the edge, then once more so that the last tile ends exactly at the edge. No tile + * reaches past the edge, and neighbours overlap by at least size - stride. A side no longer than + * a tile gets one tile. + */ +export function tileStarts(length: number, size: number, stride: number): number[] { + if (!(stride >= 1)) throw new Error(`a tile stride of ${stride} px would never reach the edge`); + if (length <= size) return [0]; + const starts: number[] = []; + for (let start = 0; start + size < length; start += stride) starts.push(start); + starts.push(length - size); + return starts; +} + +/** + * Cuts B, G and R planes (width x height each) into `size` x `size` tiles that start every + * `stride` pixels (tileStarts), row by row, runs `detect` on each, and returns every tile's + * candidates in the planes' pixels, ready for one NMS over all of them. + */ +export async function tileCandidates( + planes: Uint8ClampedArray, + width: number, + height: number, + size: number, + stride: number, + detect: (tensor: Float32Array) => Promise, +): Promise { + const found: Candidate[] = []; + for (const top of tileStarts(height, size, stride)) { + for (const left of tileStarts(width, size, stride)) { + for (const c of await detect(tile(planes, width, height, left, top, size))) { + found.push({ ...c, x: c.x + left, y: c.y + top }); + } + } + } + return found; +} + const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); /** Every grid cell scoring at least `minScore`, as a box in the pixels of the input tensor. */ diff --git a/docs/ai-use.md b/docs/ai-use.md index f4bebae..eee5da9 100644 --- a/docs/ai-use.md +++ b/docs/ai-use.md @@ -25,3 +25,4 @@ Both authors work with Claude Code (Anthropic). This log records what Claude did | 2026-10-28 | Shashwat Deep | Claude Opus 5.5 made `eval/build_manifest.py` leave out images whose file carries an EXIF orientation other than 1 or EXIF data it cannot read, each replaced by the next image in its split's ranking, and print how many drawn files are larger than 1,024 px or carry EXIF data or an ICC profile; made `eval/run_detect.py`, `eval/render_copies.py`, `eval/check_colour.py` and `transforms.upright()` refuse such files, and the runner replace the EXIF data of an image stored turned with its own orientation tag; checked in Chromium that it turns an image by the orientation in its first EXIF segment only, not by a second one or by XMP's; corrected the image-size and metadata wording in `docs/eval-protocol.md` and `eval/README.md`; with tests. This follows Pratyush's review of #10. | Not yet. | | 2026-10-28 | Shashwat Deep | Claude Opus 5.5 made `eval/yaw_sheet.py` refuse to run until the review keeps every image in the manifest, delete the crops and page of its last build, ignore a key held down or pressed with Ctrl, Alt or Meta, let go of the focus of a clicked button, and count only a copy of the whole box as saved; defined the yaw classes by the turn of the head (under about 30°, 30° to 60°, over 60°) in the page, the scripts and `docs/eval-protocol.md`; kept the label files in `data/yaw/` until both authors are done, then in `results/yaw/`; made `eval/yaw_agreement.py` write `results/yaw/agreement.json` by default, with the SHA-256 of both label files, the disagreements file and the manifest, the git commit and the versions; with tests. This follows Pratyush's review of #13. | Not yet. | | 2026-10-28 | Pratyush | Claude Opus 5.5 wrote the eval page (`app/eval.html`, `app/src/eval.ts`: `window.solidMask` as in `docs/contracts.md`, section 3), moved the app's drawing into a shared `paint()` so the page's `render()` encodes exactly as the app saves, and added `tests/app/eval-page.spec.ts`, which pins detection to OpenCV's FaceDetectorYN on the test photo. It checked that the test fails when the colour channels are swapped, and ran Shashwat's `run_detect.py` and `score.py` against the page on a two-image stand-in manifest made from the public-domain test photo (one copy stored turned). | Not yet: Pratyush reviews the pull request before asking Shashwat. | +| 2026-10-29 | Pratyush | Claude Opus 5.5 added the tiled setting to `app/src/pipeline/detect.ts` (the photo shrunk so its long side is at most 1,920 px, cut into 640 px tiles every 480 px with the last one at the edge, and one NMS over the boxes of every tile), with unit tests, two eval-page tests in three browsers (the tiled boxes against OpenCV's, and that a photo no longer than `longSide` is never enlarged), and the tile layout in section 1 of `docs/contracts.md` (version 0.3). A separate Claude agent wrote `tests/reference/opencv_yunet_tiles.py` from a step-by-step description of the setting, without the app's code; in its environment the existing reference reproduces byte for byte, and its 38 boxes match the eval page's within 0.0011 px in Chromium. `/code-review` and four more agents reviewed the change, one of them by mutation testing in its own worktree, and each finding was checked by an agent trying to refute it; the fixes are in the second commit. | Not yet: Pratyush reviews the pull request before asking Shashwat. | diff --git a/docs/contracts.md b/docs/contracts.md index 5bba37d..6e6c911 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -1,8 +1,8 @@ # Contracts -Version 0.2. The app and the evaluation both depend on what is written here. Change it only through a PR that both authors approve, and raise the version number. +Version 0.3. The app and the evaluation both depend on what is written here. Change it only through a PR that both authors approve, and raise the version number. -Changes: 0.1 was the draft merged at M0. 0.2 rounds each mask outward to whole pixels (section 2). +Changes: 0.1 was the draft merged at M0. 0.2 rounds each mask outward to whole pixels (section 2). 0.3 says where the tiles of the tiled setting lie and how their boxes are merged (section 1). ## 1. Face detection @@ -20,7 +20,8 @@ export type Box = { export type Tiling = | { kind: "none" } // long side resized to inputSize, one pass | { kind: "tiles"; longSide: number; overlap: number }; // long side resized to longSide (never enlarged), cut into - // inputSize tiles overlapping by `overlap` (0 to 1), merged with NMS + // inputSize tiles overlapping by `overlap` (0 to under 1), + // merged with NMS export type DetectOptions = { model: "yunet" | "yolo5face"; @@ -36,6 +37,7 @@ export function detectFaces(image: ImageBitmap, options: DetectOptions): Promise - The app passes the upright bitmap from `createImageBitmap(file, { imageOrientation: "from-image" })`. - The score filter runs after NMS. Filtering the runner's boxes at a higher threshold therefore gives the same boxes as running with that threshold, so the evaluation can sweep thresholds without rerunning detection. +- Tiles: along each side of the shrunk image, a tile starts every `round(inputSize × (1 − overlap))` px (480 for 640 and 0.25) for as long as it ends before the edge, and one more is moved back to end exactly at the edge. No tile reaches past the image, and neighbours overlap by at least `overlap`. A side no longer than `inputSize` gets one tile, padded with zeros. NMS runs once, over the boxes of all tiles in the shrunk image's pixels, so boxes that different tiles give for one face merge only when they overlap by more than `nmsIoU`; a face that no tile holds whole can come back as several boxes. - Every run records the ONNX Runtime Web version, the backend, the thread count and the model file's SHA-256. ## 2. Masks and the saved file diff --git a/tests/README.md b/tests/README.md index ad7ca90..0c9e490 100644 --- a/tests/README.md +++ b/tests/README.md @@ -25,13 +25,15 @@ Every privacy test first checks that the face in the saved copy is covered, so n `app/eval-page.spec.ts` drives the eval page (`app/eval.html`, `docs/contracts.md`, section 3) on the app's development server, as `eval/run_detect.py` does: the test photo is served on the page's own origin and detected with the runner's options. It checks that: - the boxes match the 7 that OpenCV's FaceDetectorYN finds with the same model scoring 0.10 or more; +- in the tiled setting (`--tiles 1920,0.25`: the photo shrunk to 1440 x 1920 and cut into 12 tiles of 640 x 640 that overlap), the boxes match the 16 scoring 0.10 or more that FaceDetectorYN finds tile by tile, merged with one NMS; +- in the tiled setting, a photo no longer than `longSide` is never enlarged: the photo drawn at 750 x 1000 gives the very same boxes with `longSide` 1000 and 4000; - a copy of the photo stored turned, with the EXIF orientation the runner adds, decodes upright with the face within 2 px; - `render()` covers the face and writes a file with nothing but the picture; - `info()` gives the ONNX Runtime version, `wasm`, one thread and the model's SHA-256; - a reloaded page refuses to start (Vite reloads it when a file changes, which would change the code in the middle of a run); - the deployed build holds neither the page nor its code. -The OpenCV boxes come from `reference/opencv_yunet.py`, which writes `reference/opencv_yunet.txt` (OpenCV 4.13.0, pinned in `reference/requirements.txt`); the test reads that file. In Chromium, the browser the runner uses, every box scoring 0.10 or more must match within 0.5 px and 0.0005 of score; Firefox and WebKit get 2 px and 0.002, since another engine may decode the JPEG a little differently. +The OpenCV boxes come from `reference/opencv_yunet.py` and `reference/opencv_yunet_tiles.py`, which write `reference/opencv_yunet.txt` and `reference/opencv_yunet_tiles.txt` (OpenCV 4.13.0, pinned in `reference/requirements.txt`); the tests read those files. `opencv_yunet_tiles.py` was written from a step-by-step description of the tiled setting, without the app's code, so the two implementations check each other. The app shrinks by area averaging, as OpenCV's INTER_AREA does, and the two agree pixel for pixel at the test photo's factors (6.25 for the single setting, 25/12 for the tiled one). At factors where an average can fall exactly halfway between two whole values, such as the 1.6 that shrinks a 1,024 px copy to 640, they can differ by 1 on a few percent of pixels; on the test photo shrunk that way, boxes then move by under 0.1 px and scores by up to 0.003. In Chromium, the browser the runner uses, every box scoring 0.10 or more must match within 0.5 px and 0.0005 of score; Firefox and WebKit get 2 px and 0.002, since another engine may decode the JPEG a little differently. Boxes are matched by position, not by rank, since two scores closer than that may trade places. Playwright starts the development server on port 4402 for these tests. diff --git a/tests/app/eval-page.spec.ts b/tests/app/eval-page.spec.ts index 27dc8cd..8f25b2b 100644 --- a/tests/app/eval-page.spec.ts +++ b/tests/app/eval-page.spec.ts @@ -11,24 +11,38 @@ import { facesCoveredProblems } from '../privacy/helpers/pixels.ts'; import { poisonedPhoto } from '../privacy/helpers/poison.ts'; type Box = { x: number; y: number; w: number; h: number; score: number }; +// docs/contracts.md, section 1, as the runner sends it. +type DetectOptions = { + model: 'yunet' | 'yolo5face'; + inputSize: number; + tiling: { kind: 'none' } | { kind: 'tiles'; longSide: number; overlap: number }; + scoreThreshold: number; + nmsIoU: number; +}; type SolidMask = { decode(url: string): Promise; - detectFaces(image: ImageBitmap, options: object): Promise; + detectFaces(image: ImageBitmap, options: DetectOptions): Promise; render(image: ImageBitmap, masks: Omit[]): Promise; info(): { ortVersion: string; backend: string; threads: number; modelSha256: string }; }; -// OpenCV's own YuNet boxes on the test photo, highest score first, written by tests/reference/opencv_yunet.py. -const OPENCV: Box[] = readFileSync(new URL('../reference/opencv_yunet.txt', import.meta.url), 'utf8') - .split('\n') - .filter((line) => line.trim() && !line.startsWith('#')) - .map((line) => { - const [x, y, w, h, score] = line.trim().split(/\s+/).map(Number); - return { x, y, w, h, score }; - }); -// The boxes are compared down to 0.10, where there are 7 (the lowest scores 0.1037, the next 0.0827), so the set is -// not on a knife-edge. Below that, OpenCV's boxes differ from the app's at the right edge, where the app's -// 640 x 640 letterbox and OpenCV's 480 px wide input see different borders. +/** OpenCV's own YuNet boxes on the test photo, highest score first, as a script in tests/reference/ wrote them. */ +function opencvBoxes(file: string): Box[] { + return readFileSync(new URL(`../reference/${file}`, import.meta.url), 'utf8') + .split('\n') + .filter((line) => line.trim() && !line.startsWith('#')) + .map((line) => { + const [x, y, w, h, score] = line.trim().split(/\s+/).map(Number); + return { x, y, w, h, score }; + }); +} +// The single setting, from opencv_yunet.py, and the tiled one, from opencv_yunet_tiles.py. +const OPENCV = opencvBoxes('opencv_yunet.txt'); +const OPENCV_TILES = opencvBoxes('opencv_yunet_tiles.txt'); +// The boxes are compared down to 0.10: 7 in the single setting (the lowest scores 0.1037, the next 0.0827) and 16 +// in the tiled one (0.1060, then 0.0939), so neither set is on a knife-edge. Below that, OpenCV's single-setting +// boxes differ from the app's at the right edge, where the app's 640 x 640 letterbox and OpenCV's 480 px wide input +// see different borders; the tiles are 640 x 640 in both. const CLEAR_SCORE = 0.1; // The runner uses Chromium (docs/contracts.md, section 3), and there the boxes must match to 0.5 px. Another // engine may decode the JPEG a little differently (WebKit on macOS uses Apple's ImageIO), so it gets 2 px. @@ -38,20 +52,42 @@ function expectNear(actual: Box, expected: Box, bound: { px: number; score: numb for (const k of ['x', 'y', 'w', 'h'] as const) expect(Math.abs(actual[k] - expected[k]), `${what} ${k}`).toBeLessThanOrEqual(bound.px); expect(Math.abs(actual.score - expected.score), `${what} score`).toBeLessThanOrEqual(bound.score); } + +/** The boxes scoring CLEAR_SCORE or more match the expected ones in number, and each lies within the bound of one. */ +function expectSameBoxes(found: Box[], expected: Box[], bound: { px: number; score: number }, what: string): void { + const clear = (boxes: Box[]) => boxes.filter((b) => b.score >= CLEAR_SCORE); + const unmatched = clear(found); + expect(unmatched, `${what}: boxes scoring ${CLEAR_SCORE} or more`).toHaveLength(clear(expected).length); + clear(expected).forEach((box, i) => { + // Matched by position, not by rank: two scores 0.0005 apart may trade places in another engine. + const distance = (b: Box) => Math.max(...(['x', 'y', 'w', 'h'] as const).map((k) => Math.abs(b[k] - box[k]))); + const nearest = unmatched.reduce((best, b) => (distance(b) < distance(best) ? b : best)); + unmatched.splice(unmatched.indexOf(nearest), 1); + expectNear(nearest, box, bound, `${what} box ${i + 1}`); + }); +} // The model's SHA-256 in the OpenCV Zoo, as fetch-model.ts checks it. const MODEL_SHA256 = '8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4'; -// The options eval/run_detect.py passes for YuNet in the single setting. -const RUNNER_OPTIONS = { model: 'yunet', inputSize: 640, tiling: { kind: 'none' }, scoreThreshold: 0.05, nmsIoU: 0.3 }; +// The options eval/run_detect.py passes for YuNet in the single setting, and with --tiles 1920,0.25, the +// protocol's tiled setting (docs/eval-protocol.md, part B). +const RUNNER_OPTIONS: DetectOptions = { model: 'yunet', inputSize: 640, tiling: { kind: 'none' }, scoreThreshold: 0.05, nmsIoU: 0.3 }; +const TILED_OPTIONS: DetectOptions = { ...RUNNER_OPTIONS, tiling: { kind: 'tiles', longSide: 1920, overlap: 0.25 } }; const ORT_VERSION: string = JSON.parse( readFileSync(new URL('../../app/node_modules/onnxruntime-web/package.json', import.meta.url), 'utf8'), ).version; -/** Serves bytes on the page's own origin, as the runner does, decodes and detects them, and renders the first box. */ -async function runPage(page: Page, image: Uint8Array) { +/** Opens the eval page with the image served on its own origin, as the runner serves it, and returns its URL. */ +async function openPage(page: Page, image: Uint8Array): Promise { const url = `${EVAL_ORIGIN}/__eval__/images/photo.jpg`; await page.route(url, (route) => route.fulfill({ body: Buffer.from(image), contentType: 'image/jpeg' })); await page.goto(EVAL_PAGE); await page.waitForFunction(() => (window as unknown as { solidMask?: unknown }).solidMask !== undefined); + return url; +} + +/** Serves bytes on the page's own origin, as the runner does, decodes and detects them, and renders the first box. */ +async function runPage(page: Page, image: Uint8Array, options: DetectOptions = RUNNER_OPTIONS) { + const url = await openPage(page, image); const result = await page.evaluate( async ({ url, options }) => { const api = (window as unknown as { solidMask: SolidMask }).solidMask; @@ -66,7 +102,7 @@ async function runPage(page: Page, image: Uint8Array) { bitmap.close(); } }, - { url, options: RUNNER_OPTIONS }, + { url, options }, ); return { ...result, copy: new Uint8Array(Buffer.from(result.copy, 'base64')) }; } @@ -100,15 +136,50 @@ test('the eval page finds the boxes OpenCV finds, renders as the app saves, and expect(result.info, 'info()').toEqual({ ortVersion: ORT_VERSION, backend: 'wasm', threads: 1, modelSha256: MODEL_SHA256 }); expect(result.size, 'decoded size').toEqual([photo.width, photo.height]); - const expected = OPENCV.filter((b) => b.score >= CLEAR_SCORE); - const found = result.boxes.filter((b) => b.score >= CLEAR_SCORE); - expect(found, `boxes scoring ${CLEAR_SCORE} or more`).toHaveLength(expected.length); - expected.forEach((box, i) => expectNear(found[i], box, tolerance(browserName), `box ${i + 1}`)); + expectSameBoxes(result.boxes, OPENCV, tolerance(browserName), 'single'); expect(facesCoveredProblems(result.copy, photo.faces), 'faces left uncovered by render()').toEqual([]); expect(hiddenDataProblems(result.copy), 'hidden data in the copy render() encodes').toEqual([]); }); +test('in the tiled setting, the eval page finds the boxes OpenCV finds tile by tile', async ({ page, browserName }) => { + // OpenCV ran FaceDetectorYN on each of the 12 tiles of the photo shrunk to 1440 x 1920 and merged the boxes with + // one NMS (tests/reference/opencv_yunet_tiles.py), so this checks the shrinking, the tile layout, the offsets + // into the photo's pixels and the merge. + const photo = await poisonedPhoto(); + const result = await runPage(page, new Uint8Array(await readFile(photo.sourcePath)), TILED_OPTIONS); + expectSameBoxes(result.boxes, OPENCV_TILES, tolerance(browserName), 'tiled'); +}); + +test('in the tiled setting, a photo no longer than longSide runs at its own size, never enlarged', async ({ page }) => { + // Most Open Images copies take this path: at most 1,024 px, below the protocol's 1,920. The golden photo, 4,000 px + // long, never does, so here it is drawn at a quarter of its size, 750 x 1000, and detected with longSide 1000 + // (its own size) and 4000 (which would enlarge it four times): the boxes must be the very same. + const photo = await poisonedPhoto(); + const url = await openPage(page, new Uint8Array(await readFile(photo.sourcePath))); + const [ownSize, larger] = await page.evaluate( + async ({ url, options }) => { + const api = (window as unknown as { solidMask: SolidMask }).solidMask; + const bitmap = await api.decode(url); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width / 4; + canvas.height = bitmap.height / 4; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + const small = await createImageBitmap(canvas); + try { + const tiled = (longSide: number) => api.detectFaces(small, { ...options, tiling: { kind: 'tiles', longSide, overlap: 0.25 } }); + return [await tiled(1000), await tiled(4000)]; + } finally { + small.close(); + } + }, + { url, options: RUNNER_OPTIONS }, + ); + expect(ownSize.filter((b) => b.score >= 0.5), 'faces found at 750 x 1000').not.toHaveLength(0); + expect(larger, 'boxes with longSide 4000').toEqual(ownSize); +}); + test('the eval page turns a photo stored turned upright by its EXIF orientation, as the runner relies on', async ({ page }) => { const photo = await poisonedPhoto(); const result = await runPage(page, storedTurned(new Uint8Array(await readFile(photo.sourcePath)))); diff --git a/tests/reference/opencv_yunet_tiles.py b/tests/reference/opencv_yunet_tiles.py new file mode 100644 index 0000000..2243866 --- /dev/null +++ b/tests/reference/opencv_yunet_tiles.py @@ -0,0 +1,108 @@ +"""Reference boxes for the tiled setting: OpenCV's own YuNet detector, tile by tile, on the test photo. + + python -m pip install -r tests/reference/requirements.txt + python tests/reference/opencv_yunet_tiles.py > tests/reference/opencv_yunet_tiles.txt + +Needs app/models/face_detection_yunet_2023mar.onnx (`npm run fetch-model` in app/), +tests/fixtures/data/glover.jpg (`npm run fixtures` in tests/), and OpenCV and numpy as requirements.txt pins them. +Runs the tiled setting of docs/contracts.md (section 1) with longSide 1920, overlap 0.25 and input size 640, as in +docs/eval-protocol.md (part B): shrinks the photo with INTER_AREA so the long side is 1920 px (never enlarging it), +cuts it into 640 x 640 tiles 480 px apart, the last one at the image's edge, and runs cv2.FaceDetectorYN on each tile, +keeping every box that scores 0.05 or more, with no NMS inside a tile. Then it applies NMS once over the boxes of all +tiles, at IoU 0.3 in the shrunk image's pixels, and prints every box in pixels of the full photo, highest score first. +""" +from pathlib import Path +import math +import cv2 +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +MODEL = ROOT / 'app/models/face_detection_yunet_2023mar.onnx' +PHOTO = ROOT / 'tests/fixtures/data/glover.jpg' + +LONG_SIDE, OVERLAP = 1920, 0.25 # tiling: { kind: "tiles", longSide: 1920, overlap: 0.25 } +SIZE = 640 # inputSize: every tile is SIZE x SIZE +FLOOR = 0.05 # score floor inside each tile, and the score threshold after NMS +NMS_IOU = 0.3 # nmsIoU for YuNet +CELLS = 80 * 80 + 40 * 40 + 20 * 20 # the model's grid at 640 x 640 (strides 8, 16, 32): the most boxes a tile gives + + +def round_half_up(v): + """Nearest whole number, halves rounded up; Python's round() rounds halves to even.""" + whole = math.floor(v) + return whole + 1 if v - whole >= 0.5 else whole + + +def tile_starts(n): + """Tile starts along an axis of n pixels: 0, S, 2S, ... while start + SIZE < n, then n - SIZE.""" + if n <= SIZE: + return [0] + stride = round_half_up(SIZE * (1 - OVERLAP)) + starts = [0] + while starts[-1] + stride + SIZE < n: + starts.append(starts[-1] + stride) + return starts + [n - SIZE] + + +def iou(a, b): + """Intersection over union of two (x, y, w, h, ...) boxes; boxes that only touch give 0.""" + iw = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]) + ih = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]) + if iw <= 0 or ih <= 0: + return 0.0 + inter = iw * ih + return inter / (a[2] * a[3] + b[2] * b[3] - inter) + + +image = cv2.imread(str(PHOTO), cv2.IMREAD_COLOR) # BGR, as the model expects; the photo's EXIF orientation is 1 +height, width = image.shape[:2] + +# 1. Shrink so the long side is LONG_SIDE, by area averaging into 8-bit pixels; a smaller photo is used as it is. +if max(width, height) > LONG_SIDE: + size = (round_half_up(width * LONG_SIDE / max(width, height)), round_half_up(height * LONG_SIDE / max(width, height))) + shrunk = cv2.resize(image, size, interpolation=cv2.INTER_AREA) +else: + shrunk = image +out_h, out_w = shrunk.shape[:2] + +# 2. Tile starts along x and along y. +xs, ys = tile_starts(out_w), tile_starts(out_h) + +# One detector for every tile. NMSBoxes, which FaceDetectorYN calls (OpenCV's modules/dnn/src/nms.inl.hpp), keeps a box +# whose overlap with a kept box is <= nms_threshold, so 1.0 suppresses nothing, and top_k CELLS drops nothing. It also +# keeps only scores strictly above the threshold, so the detector gets the float32 just below FLOOR and the floor +# itself is applied below. +detector = cv2.FaceDetectorYN.create(str(MODEL), '', (SIZE, SIZE), float(np.nextafter(np.float32(FLOOR), np.float32(0))), + 1.0, CELLS) + +# 3. Tiles in row-major order, 0 where a tile extends past the shrunk image (only when that side is under SIZE). +# 4. Every box scoring FLOOR or more, with no NMS inside a tile. 5. Into the shrunk image's pixels. +candidates = [] # (x, y, w, h, score) in the shrunk image's pixels, tile by tile +for ty in ys: + for tx in xs: + tile = np.zeros((SIZE, SIZE, 3), np.uint8) + part = shrunk[ty:ty + SIZE, tx:tx + SIZE] + tile[:part.shape[0], :part.shape[1]] = part + _, faces = detector.detect(tile) + for face in faces if faces is not None else []: # highest score first; equal scores in the model's grid order + x, y, w, h, score = (float(v) for v in (*face[:4], face[14])) + if score >= FLOOR: + candidates.append((x + tx, y + ty, w, h, score)) + +# 6. NMS once over all tiles: highest score first, by a stable sort (equal scores keep tile order, then their order +# within the tile); a box is dropped when its IoU with a box already kept is greater than NMS_IOU. +kept = [] +for box in sorted(candidates, key=lambda c: -c[4]): + if all(iou(box, k) <= NMS_IOU for k in kept): + kept.append(box) + +# 7. Score filter after NMS. +kept = [box for box in kept if box[4] >= FLOOR] + +print(f'# OpenCV {cv2.__version__}, numpy {np.__version__}: {PHOTO.name} ({width} x {height}) shrunk to {out_w} x {out_h}') +print(f'# tile starts: x {" ".join(map(str, xs))}, y {" ".join(map(str, ys))}') +print(f'# {len(xs) * len(ys)} tiles of {SIZE} x {SIZE}') +print(f'# {len(candidates)} candidates scoring {FLOOR} or more before NMS') +print('# x y w h score, in pixels of the full photo, highest score first') +for x, y, w, h, score in kept: # 8. into the photo's pixels + print(f'{x * width / out_w:.3f} {y * height / out_h:.3f} {w * width / out_w:.3f} {h * height / out_h:.3f} {score:.6f}') diff --git a/tests/reference/opencv_yunet_tiles.txt b/tests/reference/opencv_yunet_tiles.txt new file mode 100644 index 0000000..0e28e71 --- /dev/null +++ b/tests/reference/opencv_yunet_tiles.txt @@ -0,0 +1,43 @@ +# OpenCV 4.13.0, numpy 2.4.4: glover.jpg (3000 x 4000) shrunk to 1440 x 1920 +# tile starts: x 0 480 800, y 0 480 960 1280 +# 12 tiles of 640 x 640 +# 322 candidates scoring 0.05 or more before NMS +# x y w h score, in pixels of the full photo, highest score first +1197.706 1007.301 736.562 593.053 0.939303 +1662.094 628.038 315.587 755.750 0.584881 +408.851 2011.097 1003.030 1254.948 0.292599 +1132.974 401.741 842.998 953.771 0.238048 +1166.942 1025.121 166.879 375.587 0.158867 +1323.490 2463.346 1006.584 1255.724 0.149664 +1129.579 682.752 202.600 597.459 0.139183 +1650.461 356.317 254.579 619.689 0.137891 +61.430 6.491 94.253 98.085 0.131676 +1625.451 2008.118 628.910 601.562 0.124065 +82.210 261.294 81.818 99.036 0.122749 +1992.679 2878.893 1070.939 1238.439 0.120211 +6.289 1055.077 84.388 139.899 0.114337 +943.151 2193.363 161.806 179.700 0.107316 +2129.116 2131.081 864.059 1236.220 0.106859 +2162.327 1064.513 643.933 777.632 0.106035 +1302.588 3618.453 29.374 51.946 0.093879 +48.503 2970.150 673.532 937.008 0.087793 +1241.978 916.235 91.482 155.597 0.085145 +408.347 622.403 81.389 96.253 0.083220 +1286.315 3633.153 39.568 64.911 0.081773 +1650.724 1066.833 240.896 517.794 0.080186 +211.923 543.978 93.122 118.117 0.079006 +2653.372 1160.532 135.456 144.523 0.076112 +1658.694 102.374 537.940 967.140 0.070768 +402.291 867.848 75.676 112.440 0.070568 +1666.499 923.476 895.326 1016.777 0.069475 +574.980 669.435 52.664 60.731 0.065903 +2896.923 1190.408 110.561 159.468 0.064537 +-1.802 14.543 116.343 130.594 0.064004 +1655.678 1206.386 153.182 218.711 0.063286 +1664.066 1257.069 124.109 79.797 0.059916 +446.149 341.583 82.646 98.052 0.059104 +2779.281 1157.291 209.520 221.925 0.058110 +1242.770 3400.585 85.485 118.813 0.057126 +223.980 32.022 101.841 115.515 0.056447 +755.732 2051.419 489.937 536.203 0.055135 +1064.544 3331.172 357.369 461.189 0.052717 diff --git a/tests/reference/requirements.txt b/tests/reference/requirements.txt index 42b9c10..5b09e73 100644 --- a/tests/reference/requirements.txt +++ b/tests/reference/requirements.txt @@ -1,4 +1,5 @@ -# For opencv_yunet.py only, which writes opencv_yunet.txt; the tests read that file and need none of these. +# For opencv_yunet.py and opencv_yunet_tiles.py only, which write opencv_yunet.txt and opencv_yunet_tiles.txt; the +# tests read those files and need none of these. # OpenCV 4.x, since 5.x runs this fixed-size model through a different engine. Licences: Apache-2.0, BSD-3-Clause. opencv-python-headless==4.13.0.92 numpy==2.4.4 diff --git a/tests/unit/yunet.test.ts b/tests/unit/yunet.test.ts index 8d77b60..c810933 100644 --- a/tests/unit/yunet.test.ts +++ b/tests/unit/yunet.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { type Candidate, decodeOutputs, letterbox, nms } from '../../app/src/pipeline/yunet.ts'; +import { + type Candidate, + decodeOutputs, + letterbox, + nms, + resizeArea, + tile, + tileCandidates, + tiledSize, + tileStarts, +} from '../../app/src/pipeline/yunet.ts'; /** RGBA pixels from a function of position. */ function image(width: number, height: number, rgb: (x: number, y: number) => [number, number, number]): Uint8ClampedArray { @@ -32,6 +42,136 @@ describe('letterbox', () => { expect(scaleX).toBeCloseTo(2 / 3); expect([tensor[8], tensor[9]]).toEqual([30, 150]); // (0 + 0.5 x 90) / 1.5 and (0.5 x 90 + 180) / 1.5 }); + + it('fills the height and leaves the columns right of the picture at zero when the photo is portrait', () => { + const { tensor, scaleX, scaleY } = letterbox(image(2, 4, () => [200, 100, 50]), 2, 4, 8); + const plane = 8 * 8; + expect([scaleX, scaleY]).toEqual([2, 2]); + expect([tensor[7 * 8 + 3], tensor[plane + 7 * 8 + 3], tensor[2 * plane + 7 * 8 + 3]]).toEqual([50, 100, 200]); + for (let y = 0; y < 8; y++) expect([...tensor.subarray(y * 8 + 4, y * 8 + 8)]).toEqual([0, 0, 0, 0]); + }); +}); + +describe('resizeArea', () => { + it('copies every pixel into B, G and R planes when the size does not change', () => { + const planes = resizeArea(image(3, 2, (x, y) => [10 * x, 20 * y, 7]), 3, 2, 3, 2); + expect([...planes]).toEqual([7, 7, 7, 7, 7, 7, 0, 0, 0, 20, 20, 20, 0, 10, 20, 0, 10, 20]); + }); + + it('rounds each average to the nearest whole value, halves up', () => { + // Three pixels to two: (0 + 0.5 x 100) / 1.5 = 33.3 and (0.5 x 100 + 200) / 1.5 = 166.7. + const thirds = resizeArea(image(3, 1, (x) => [[0, 100, 200][x], 0, 0]), 3, 1, 2, 1); + expect([...thirds.subarray(4, 6)]).toEqual([33, 167]); + // Two pixels to one: (0 + 1) / 2 = 0.5. + expect(resizeArea(image(2, 1, (x) => [x, 0, 0]), 2, 1, 1, 1)[2]).toBe(1); + }); +}); + +describe('tiledSize', () => { + it('shrinks the long side to longSide, portrait or landscape, and the other side in proportion', () => { + expect(tiledSize(3000, 4000, 1920)).toEqual({ width: 1440, height: 1920 }); + expect(tiledSize(4000, 3000, 1920)).toEqual({ width: 1920, height: 1440 }); + // 1365 x 1920 / 2048 = 1279.7: the sides round separately, so their scales differ slightly. + expect(tiledSize(2048, 1365, 1920)).toEqual({ width: 1920, height: 1280 }); + expect(tiledSize(20000, 5, 1920)).toEqual({ width: 1920, height: 1 }); + }); + + it('never enlarges an image whose long side is longSide or less', () => { + expect(tiledSize(768, 1024, 1920)).toEqual({ width: 768, height: 1024 }); + expect(tiledSize(1000, 500, 1920)).toEqual({ width: 1000, height: 500 }); + expect(tiledSize(1920, 1080, 1920)).toEqual({ width: 1920, height: 1080 }); + }); +}); + +describe('tile', () => { + // B, G and R planes of a 5 x 3 image whose values say where they are: 10 x row + column, plus + // 100 in G and 200 in R. + const planes = Uint8ClampedArray.from({ length: 45 }, (_, i) => 100 * Math.floor(i / 15) + 10 * Math.floor((i % 15) / 5) + (i % 5)); + + it('holds the pixels from (left, top) at its top-left and zeros past the image', () => { + const tensor = tile(planes, 5, 3, 2, 1, 4); + const plane = (c: number) => [...tensor.subarray(16 * c, 16 * (c + 1))]; + expect(plane(0)).toEqual([12, 13, 14, 0, 22, 23, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(plane(2)).toEqual([212, 213, 214, 0, 222, 223, 224, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + }); +}); + +describe('tileStarts', () => { + it('starts a tile every stride and moves the last one back to end at the edge', () => { + // 640 px tiles overlapping by 25%: the protocol's tiled setting. + expect(tileStarts(1920, 640, 480)).toEqual([0, 480, 960, 1280]); + expect(tileStarts(1440, 640, 480)).toEqual([0, 480, 800]); + expect(tileStarts(1024, 640, 480)).toEqual([0, 384]); + expect(tileStarts(1120, 640, 480)).toEqual([0, 480]); + expect(tileStarts(641, 640, 480)).toEqual([0, 1]); + expect(tileStarts(640, 640, 480)).toEqual([0]); + expect(tileStarts(300, 640, 480)).toEqual([0]); + }); + + it('covers every pixel, stays inside the edge and keeps neighbours overlapping by at least size - stride', () => { + for (const stride of [160, 320, 480, 640]) { + for (let length = 1; length <= 2000; length++) { + const starts = tileStarts(length, 640, stride); + expect(starts[0]).toBe(0); + expect(starts.at(-1)).toBe(Math.max(0, length - 640)); + for (let i = 1; i < starts.length; i++) { + expect(starts[i] - starts[i - 1]).toBeGreaterThan(0); + expect(starts[i] - starts[i - 1]).toBeLessThanOrEqual(stride); + } + } + } + }); + + it('refuses a stride under one pixel, which would never reach the edge', () => { + expect(() => tileStarts(1000, 640, 0)).toThrow(); + expect(() => tileStarts(1000, 640, Number.NaN)).toThrow(); + }); +}); + +describe('tileCandidates', () => { + /** A detector that finds the bounding box of the B values of 255, scoring the share of a 100 x 100 square it sees. */ + const findSquare = async (tensor: Float32Array): Promise => { + let [x0, y0, x1, y1] = [Infinity, Infinity, -1, -1]; + for (let i = 0; i < 640 * 640; i++) { + if (tensor[i] !== 255) continue; + const [x, y] = [i % 640, Math.floor(i / 640)]; + [x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x), Math.max(y1, y)]; + } + if (x1 < 0) return []; + const [w, h] = [x1 - x0 + 1, y1 - y0 + 1]; + return [{ x: x0, y: y0, w, h, score: (w * h) / 10000 }]; + }; + + /** The planes of a 1000 x 700 image, black but for a white 100 x 100 square with its top-left corner at (left, 300). */ + const squareAt = (left: number) => + resizeArea(image(1000, 700, (x, y) => (x >= left && x < left + 100 && y >= 300 && y < 400 ? [255, 255, 255] : [0, 0, 0])), 1000, 700, 1000, 700); + + it("gives each tile's candidates in the image's pixels, and one NMS merges the boxes tiles give for one face when they overlap by more than the limit", async () => { + // Tiles start at x 0 and 360 and at y 0 and 60: the tiles at x 0 cut the square at x 640, the + // tiles at x 360 hold all of it. + const found = await tileCandidates(squareAt(600), 1000, 700, 640, 480, findSquare); + const square = { x: 600, y: 300, w: 100, h: 100, score: 1 }; + const cut = { x: 600, y: 300, w: 40, h: 100, score: 0.4 }; + expect(found).toEqual([cut, square, cut, square]); + expect(nms(found, 0.3)).toEqual([square]); // the cut boxes overlap the square by 0.4 + }); + + it("keeps a tile's cut view of a face as a box of its own when it overlaps the whole face by the limit or less", async () => { + // Moved to x 610, the square shows only 30 of its 100 columns in the tiles at x 0: IoU 0.3. + const found = await tileCandidates(squareAt(610), 1000, 700, 640, 480, findSquare); + expect(nms(found, 0.3)).toEqual([{ x: 610, y: 300, w: 100, h: 100, score: 1 }, { x: 610, y: 300, w: 30, h: 100, score: 0.3 }]); + }); + + it('runs one tile on an image no larger than a tile', async () => { + const tensors: Float32Array[] = []; + const planes = resizeArea(image(300, 200, () => [255, 255, 255]), 300, 200, 300, 200); + const found = await tileCandidates(planes, 300, 200, 640, 480, async (tensor) => { + tensors.push(tensor); + return findSquare(tensor); + }); + expect(tensors).toHaveLength(1); + expect(found).toEqual([{ x: 0, y: 0, w: 300, h: 200, score: 6 }]); + }); }); describe('decodeOutputs', () => {