From 7c59f752e8854456ad65f69b881f8bc320b56a0c Mon Sep 17 00:00:00 2001 From: "Michael B. Klein" Date: Tue, 14 Jan 2025 12:41:31 -0500 Subject: [PATCH] Add `debugBorder` and `pageThreshhold` options to `Processor` constructor Add 1-pixel default fudge factor to pyramid page selection --- README.md | 2 ++ examples/tiny-iiif/iiif.js | 5 +++-- src/processor.js | 35 ++++++++++++++++++++++++++++++++--- src/transform.js | 8 +++++--- tests/v2/integration.test.js | 29 +++++++++++++++++++++++++++++ tests/v3/integration.test.js | 29 +++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ab7b9d5..68c9dc7 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,11 @@ const processor = new IIIF.Processor(url, streamResolver, opts); * `height` (integer) - the maximum pixel height of the returned image * `area` (integer) - the maximum total number of pixels in the returned image * `includeMetadata` (boolean) – if `true`, all metadata from the source image will be copied to the result + * `debugBorder` (boolean) – if `true`, add a 1px red border to every generated image (for tile debugging) * `density` (integer) – the pixel density to be included in the result image in pixels per inch * This has no effect whatsoever on the size of the image that gets returned; it's simply for convenience when using the resulting image in software that calculates a default print size based on the height, width, and density + * `pageThreshold` (integer) – the fudge factor (in number of pixels) to mitigate rounding errors in pyramid page selection (default: `1`) * `pathPrefix` (string) – the template used to extract the IIIF version and API parameters from the URL path (default: `/iiif/{{version}}/`) ([see below](#path-prefix)) * `version` (number) – the major version (`2` or `3`) of the IIIF Image API to use (default: inferred from `/iiif/{version}/`) diff --git a/examples/tiny-iiif/iiif.js b/examples/tiny-iiif/iiif.js index 2382c5c..901ccfc 100644 --- a/examples/tiny-iiif/iiif.js +++ b/examples/tiny-iiif/iiif.js @@ -1,5 +1,6 @@ import { App } from '@tinyhttp/app'; -import { Processor } from 'iiif-processor'; +import iiif from 'iiif-processor'; +const { Processor } = iiif; import fs from 'fs'; import path from 'path'; import { iiifImagePath, iiifpathPrefix, fileTemplate } from './config.js'; @@ -21,7 +22,7 @@ function createRouter(version) { try { const iiifUrl = `${req.protocol}://${req.get("host")}${req.path}`; - const iiifProcessor = new Processor(iiifUrl, streamImageFromFile, { pathPrefix: iiifpathPrefix }); + const iiifProcessor = new Processor(iiifUrl, streamImageFromFile, { pathPrefix: iiifpathPrefix, debugBorder: !!process.env.DEBUG_IIIF_BORDER }); const result = await iiifProcessor.execute(); return res .set("Content-Type", result.contentType) diff --git a/src/processor.js b/src/processor.js index 1e13f42..b91df5d 100644 --- a/src/processor.js +++ b/src/processor.js @@ -1,4 +1,5 @@ const debug = require('debug')('iiif-processor:main'); +const debugv = require('debug')('verbose:iiif-processor'); const mime = require('mime-types'); const path = require('path'); const sharp = require('sharp'); @@ -51,6 +52,8 @@ class Processor { this.includeMetadata = !!opts.includeMetadata; this.density = opts.density; this.baseUrl = opts.prefix; + this.debugBorder = !!opts.debugBorder; + this.pageThreshold = opts.pageThreshold; this.sharpOptions = { ...opts.sharpOptions }; this.version = opts.iiifVersion; this.request = opts.request; @@ -147,8 +150,9 @@ class Processor { } operations (dim) { - const { sharpOptions: sharp, max } = this; - return new Operations(this.version, dim, { sharp, max }) + const { sharpOptions: sharp, max, pageThreshold } = this; + debug('pageThreshold: %d', pageThreshold); + return new Operations(this.version, dim, { sharp, max, pageThreshold }) .region(this.region) .size(this.size) .rotation(this.rotation) @@ -157,14 +161,39 @@ class Processor { .withMetadata(this.includeMetadata); } + async applyBorder (transformed) { + const buf = await transformed.toBuffer(); + const borderPipe = sharp(buf, { limitInputPixels: false }) + const { width, height } = await borderPipe.metadata(); + const background = { r: 255, g: 0, b: 0, alpha: 1 }; + + // Create small images for each border “strip” + const topBorder = { create: { width, height: 1, channels: 4, background } }; + const bottomBorder = { create: { width, height: 1, channels: 4, background } }; + const leftBorder = { create: { width: 1, height, channels: 4, background } }; + const rightBorder = { create: { width: 1, height, channels: 4, background } }; + + return borderPipe.composite([ + { input: topBorder, left: 0, top: 0 }, + { input: bottomBorder, left: 0, top: height - 1 }, + { input: leftBorder, left: 0, top: 0 }, + { input: rightBorder, left: width - 1, top: 0 } + ]); + } + async iiifImage () { + debugv('Request %s', this.request); const dim = await this.dimensions(); const operations = this.operations(dim); + debugv('Operations: %j', operations); const pipeline = await operations.pipeline(); const result = await this.withStream({ id: this.id, baseUrl: this.baseUrl }, async (stream) => { debug('piping stream to pipeline'); - const transformed = await stream.pipe(pipeline); + let transformed = await stream.pipe(pipeline); + if (this.debugBorder) { + transformed = await this.applyBorder(transformed); + } debug('converting to buffer'); return await transformed.toBuffer(); }); diff --git a/src/transform.js b/src/transform.js index e98d382..94d683d 100644 --- a/src/transform.js +++ b/src/transform.js @@ -9,6 +9,7 @@ const ExtractAttributes = [ 'heightPre' ]; +const DEFAULT_PAGE_THRESHOLD = 1; const SCALE_PRECISION = 10000000; class Operations { @@ -16,9 +17,10 @@ class Operations { #pipeline; constructor (version, dims, opts) { - const { sharp, ...rest } = opts; + const { sharp, pageThreshold, ...rest } = opts; const Implementation = IIIFVersions[version]; this.calculator = new Implementation.Calculator(dims[0], rest); + this.pageThreshold = typeof pageThreshold === 'number' ? pageThreshold : DEFAULT_PAGE_THRESHOLD; this.#pages = dims .map((dim, page) => { @@ -119,8 +121,8 @@ class Operations { const { fullSize } = this.info(); const { page } = this.#pages.find((_candidate, index) => { const next = this.#pages[index + 1]; - debug('comparing candidate %j to target %j', next, fullSize); - return !next || (next.width < fullSize.width && next.height < fullSize.height); + debug('comparing candidate %j to target %j with a %d-pixel buffer', next, fullSize, this.pageThreshold); + return !next || (next.width + this.pageThreshold < fullSize.width && next.height + this.pageThreshold < fullSize.height); }); const resolution = this.#pages[page]; diff --git a/tests/v2/integration.test.js b/tests/v2/integration.test.js index b9b5970..ae4300a 100644 --- a/tests/v2/integration.test.js +++ b/tests/v2/integration.test.js @@ -88,6 +88,17 @@ describe('size', () => { pipeline = await subject.operations(await subject.dimensions()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); }); + + it('should respect the pixel page buffer', async () => { + let pipeline; + subject = new Processor(`${base}/full/312,165/0/default.png`, streamResolver); + pipeline = await subject.operations(await subject.dimensions()).pipeline(); + assert.strictEqual(pipeline.options.input.page, 1); + + subject = new Processor(`${base}/full/312,165/0/default.png`, streamResolver, { pageThreshold: 0 }); + pipeline = await subject.operations(await subject.dimensions()).pipeline(); + assert.strictEqual(pipeline.options.input.page, 0); + }); }); describe('rotation', () => { @@ -146,3 +157,21 @@ describe('Two-argument streamResolver', () => { assert.strictEqual(size.format, 'png'); }); }); + +describe('Debug border', () => { + it('should produce an image without a border by default', async () => { + subject = new Processor(`${base}/full/full/0/default.png`, streamResolver); + const result = await subject.execute(); + const image = await Sharp(result.body).removeAlpha().raw().toBuffer(); + const pixel = image.readUInt32LE(0); + assert.strictEqual(pixel, 0xffffffff); + }); + + it('should add a border when `debugBorder` is specified', async () => { + subject = new Processor(`${base}/full/full/0/default.png`, streamResolver, { debugBorder: true }); + const result = await subject.execute(); + const image = await Sharp(result.body).removeAlpha().raw().toBuffer(); + const pixel = image.readUInt32LE(0); + assert.strictEqual(pixel, 0xff0000ff); + }); +}); diff --git a/tests/v3/integration.test.js b/tests/v3/integration.test.js index dfca886..cef3d6b 100644 --- a/tests/v3/integration.test.js +++ b/tests/v3/integration.test.js @@ -88,6 +88,17 @@ describe('size', () => { pipeline = await subject.operations(await subject.dimensions()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); }); + + it('should respect the pixel page buffer', async () => { + let pipeline; + subject = new Processor(`${base}/full/312,165/0/default.png`, streamResolver); + pipeline = await subject.operations(await subject.dimensions()).pipeline(); + assert.strictEqual(pipeline.options.input.page, 1); + + subject = new Processor(`${base}/full/312,165/0/default.png`, streamResolver, { pageThreshold: 0 }); + pipeline = await subject.operations(await subject.dimensions()).pipeline(); + assert.strictEqual(pipeline.options.input.page, 0); + }); }); describe('rotation', () => { @@ -144,3 +155,21 @@ describe('Two-argument streamResolver', () => { assert.strictEqual(size.format, 'png'); }); }); + +describe('Debug border', () => { + it('should produce an image without a border by default', async () => { + subject = new Processor(`${base}/full/max/0/default.png`, streamResolver); + const result = await subject.execute(); + const image = await Sharp(result.body).removeAlpha().raw().toBuffer(); + const pixel = image.readUInt32LE(0); + assert.strictEqual(pixel, 0xffffffff); + }); + + it('should add a border when `debugBorder` is specified', async () => { + subject = new Processor(`${base}/full/max/0/default.png`, streamResolver, { debugBorder: true }); + const result = await subject.execute(); + const image = await Sharp(result.body).removeAlpha().raw().toBuffer(); + const pixel = image.readUInt32LE(0); + assert.strictEqual(pixel, 0xff0000ff); + }); +});