From 692b15513ae177b6641af16a4fb55fe4c2db471f Mon Sep 17 00:00:00 2001 From: EscapedGibbon <101188881+EscapedGibbon@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:59:24 +0200 Subject: [PATCH] feat: blend pixels using alpha in all draw functions (#478) Closes: https://github.com/image-js/image-js-typescript/issues/477 --- src/draw/__tests__/drawLineOnImage.test.ts | 19 +++++++ src/draw/drawCircleOnImage.ts | 9 ++-- src/draw/drawLineOnImage.ts | 3 +- src/draw/drawPoints.ts | 4 +- src/draw/drawPolygonOnImage.ts | 4 +- src/draw/drawRectangle.ts | 22 ++++++--- src/operations/copyTo.ts | 4 +- src/operations/paintMaskOnImage.ts | 4 +- src/utils/__tests__/setBlendedPixel.test.ts | 16 ++---- .../__tests__/setBlendedVisiblePixel.test.ts | 49 +++++++++++++++++++ src/utils/setBlendedPixel.ts | 15 ++---- src/utils/setBlendedVisiblePixel.ts | 22 +++++++++ 12 files changed, 129 insertions(+), 42 deletions(-) create mode 100644 src/utils/__tests__/setBlendedVisiblePixel.test.ts create mode 100644 src/utils/setBlendedVisiblePixel.ts diff --git a/src/draw/__tests__/drawLineOnImage.test.ts b/src/draw/__tests__/drawLineOnImage.test.ts index c62385c20..819b6ba21 100644 --- a/src/draw/__tests__/drawLineOnImage.test.ts +++ b/src/draw/__tests__/drawLineOnImage.test.ts @@ -20,6 +20,24 @@ test('RGB image', () => { expect(result).not.toBe(image); }); +test('RGBA image with different alphas', () => { + const image = testUtils.createRgbaImage([ + [100, 150, 200, 150, 100, 150, 0, 150], + [100, 200, 5, 150, 3, 200, 0, 150], + [150, 200, 255, 150, 6, 150, 0, 150], + ]); + + const from = { row: 0, column: 0 }; + const to = { row: 1, column: 1 }; + const result = image.drawLine(from, to, { strokeColor: [255, 0, 0, 50] }); + expect(result).toMatchImageData([ + [145, 106, 141, 170, 100, 150, 0, 150], + [100, 200, 5, 150, 76, 141, 0, 170], + [150, 200, 255, 150, 6, 150, 0, 150], + ]); + expect(result).not.toBe(image); +}); + test('out parameter set to self', () => { const image = testUtils.createRgbImage([ [100, 150, 200, 100, 150, 0], @@ -297,6 +315,7 @@ test('different origin, line out of image', () => { origin: { column: 0, row: 0 }, strokeColor: [1], }); + expect(result).toMatchImageData([ [1, 0, 0, 0], [0, 1, 0, 0], diff --git a/src/draw/drawCircleOnImage.ts b/src/draw/drawCircleOnImage.ts index c84b73394..ed953547b 100644 --- a/src/draw/drawCircleOnImage.ts +++ b/src/draw/drawCircleOnImage.ts @@ -4,6 +4,7 @@ import { Image } from '../Image'; import { Point } from '../utils/geometry/points'; import { getDefaultColor } from '../utils/getDefaultColor'; import { getOutputImage } from '../utils/getOutputImage'; +import { setBlendedVisiblePixel } from '../utils/setBlendedVisiblePixel'; import checkProcessable from '../utils/validators/checkProcessable'; import { validateColor } from '../utils/validators/validators'; @@ -60,20 +61,20 @@ export function drawCircleOnImage( radius = Math.round(radius); if (radius === 0) { - newImage.setVisiblePixel(center.column, center.row, color); + setBlendedVisiblePixel(newImage, center.column, center.row, color); return newImage; } if (!fill) { circle(center.column, center.row, radius, (column: number, row: number) => { - newImage.setVisiblePixel(column, row, color); + setBlendedVisiblePixel(newImage, column, row, color); }); } else { if (radius === 1) { - newImage.setVisiblePixel(center.column, center.row, fill); + setBlendedVisiblePixel(newImage, center.column, center.row, fill); } circle(center.column, center.row, radius, (column: number, row: number) => { - newImage.setVisiblePixel(column, row, color); + setBlendedVisiblePixel(newImage, column, row, color); //todo: fill is not optimal we can fill symmetrically if (column - 1 > center.column) { diff --git a/src/draw/drawLineOnImage.ts b/src/draw/drawLineOnImage.ts index 762d099bc..7ae0c2dca 100644 --- a/src/draw/drawLineOnImage.ts +++ b/src/draw/drawLineOnImage.ts @@ -4,6 +4,7 @@ import { Image } from '../Image'; import { Point } from '../utils/geometry/points'; import { getDefaultColor } from '../utils/getDefaultColor'; import { getOutputImage } from '../utils/getOutputImage'; +import { setBlendedVisiblePixel } from '../utils/setBlendedVisiblePixel'; import checkProcessable from '../utils/validators/checkProcessable'; import { validateColor } from '../utils/validators/validators'; @@ -56,7 +57,7 @@ export function drawLineOnImage( Math.round(origin.column + to.column), Math.round(origin.row + to.row), (column: number, row: number) => { - newImage.setVisiblePixel(column, row, color); + setBlendedVisiblePixel(newImage, column, row, color); }, ); return newImage; diff --git a/src/draw/drawPoints.ts b/src/draw/drawPoints.ts index 070338d33..ab26d3d32 100644 --- a/src/draw/drawPoints.ts +++ b/src/draw/drawPoints.ts @@ -3,6 +3,7 @@ import { Mask } from '../Mask'; import { Point } from '../utils/geometry/points'; import { getDefaultColor } from '../utils/getDefaultColor'; import { getOutputImage, maskToOutputMask } from '../utils/getOutputImage'; +import { setBlendedVisiblePixel } from '../utils/setBlendedVisiblePixel'; import checkProcessable from '../utils/validators/checkProcessable'; import { validateColor } from '../utils/validators/validators'; @@ -61,7 +62,8 @@ export function drawPoints( }); for (const point of points) { - newImage.setVisiblePixel( + setBlendedVisiblePixel( + newImage, Math.round(origin.column + point.column), Math.round(origin.row + point.row), color, diff --git a/src/draw/drawPolygonOnImage.ts b/src/draw/drawPolygonOnImage.ts index be6f3853a..27cbd657f 100644 --- a/src/draw/drawPolygonOnImage.ts +++ b/src/draw/drawPolygonOnImage.ts @@ -4,6 +4,7 @@ import { Image } from '../Image'; import { arrayPointsToObjects } from '../utils/arrayPointsToObjects'; import { Point } from '../utils/geometry/points'; import { getOutputImage } from '../utils/getOutputImage'; +import { setBlendedVisiblePixel } from '../utils/setBlendedVisiblePixel'; import checkProcessable from '../utils/validators/checkProcessable'; import { validateColor } from '../utils/validators/validators'; @@ -61,7 +62,8 @@ export function drawPolygonOnImage( for (let row = 0; row < newImage.height; row++) { for (let column = 0; column < newImage.width; column++) { if (robustPointInPolygon(arrayPoints, [column, row]) === -1) { - newImage.setPixel( + setBlendedVisiblePixel( + newImage, Math.round(origin.column) + column, Math.round(origin.row) + row, fillColor, diff --git a/src/draw/drawRectangle.ts b/src/draw/drawRectangle.ts index 9cb5adea3..d688bed0c 100644 --- a/src/draw/drawRectangle.ts +++ b/src/draw/drawRectangle.ts @@ -3,6 +3,7 @@ import { Mask } from '../Mask'; import { Point } from '../utils/geometry/points'; import { getDefaultColor } from '../utils/getDefaultColor'; import { getOutputImage, maskToOutputMask } from '../utils/getOutputImage'; +import { setBlendedVisiblePixel } from '../utils/setBlendedVisiblePixel'; import checkProcessable from '../utils/validators/checkProcessable'; export interface DrawRectangleOptions { @@ -82,16 +83,26 @@ export function drawRectangle( currentColumn < column + width; currentColumn++ ) { - newImage.setVisiblePixel(currentColumn, row, strokeColor); - newImage.setVisiblePixel(currentColumn, row + height - 1, strokeColor); + setBlendedVisiblePixel(newImage, currentColumn, row, strokeColor); + setBlendedVisiblePixel( + newImage, + currentColumn, + row + height - 1, + strokeColor, + ); } for ( let currentRow = row + 1; currentRow < row + height - 1; currentRow++ ) { - newImage.setVisiblePixel(column, currentRow, strokeColor); - newImage.setVisiblePixel(column + width - 1, currentRow, strokeColor); + setBlendedVisiblePixel(newImage, column, currentRow, strokeColor); + setBlendedVisiblePixel( + newImage, + column + width - 1, + currentRow, + strokeColor, + ); } } if (fillColor !== 'none') { @@ -105,8 +116,7 @@ export function drawRectangle( currentColumn < column + width - 1; currentColumn++ ) { - newImage.setVisiblePixel(currentColumn, currentRow, fillColor); - newImage.setVisiblePixel(currentColumn, currentRow, fillColor); + setBlendedVisiblePixel(newImage, currentColumn, currentRow, fillColor); } } } diff --git a/src/operations/copyTo.ts b/src/operations/copyTo.ts index 7f8c47a67..d072b6747 100644 --- a/src/operations/copyTo.ts +++ b/src/operations/copyTo.ts @@ -69,9 +69,7 @@ export function copyTo( currentColumn - column, currentRow - row, ); - setBlendedPixel(result, currentColumn, currentRow, { - color: sourcePixel, - }); + setBlendedPixel(result, currentColumn, currentRow, sourcePixel); } } diff --git a/src/operations/paintMaskOnImage.ts b/src/operations/paintMaskOnImage.ts index 216bd769e..8d8a41295 100644 --- a/src/operations/paintMaskOnImage.ts +++ b/src/operations/paintMaskOnImage.ts @@ -68,9 +68,7 @@ export function paintMaskOnImage( currentColumn++ ) { if (mask.getBit(currentColumn - column, currentRow - row)) { - setBlendedPixel(result, currentColumn, currentRow, { - color, - }); + setBlendedPixel(result, currentColumn, currentRow, color); } } } diff --git a/src/utils/__tests__/setBlendedPixel.test.ts b/src/utils/__tests__/setBlendedPixel.test.ts index c1b448e0e..15db5777a 100644 --- a/src/utils/__tests__/setBlendedPixel.test.ts +++ b/src/utils/__tests__/setBlendedPixel.test.ts @@ -14,31 +14,23 @@ test('GREYA image, default options', () => { test('GREYA images: transparent source, opaque target', () => { const image = testUtils.createGreyaImage([[50, 255]]); - setBlendedPixel(image, 0, 0, { color: [100, 0] }); + setBlendedPixel(image, 0, 0, [100, 0]); expect(image).toMatchImageData([[50, 255]]); }); test('GREYA images: opaque source, transparent target', () => { const image = testUtils.createGreyaImage([[50, 0]]); - setBlendedPixel(image, 0, 0, { color: [100, 255] }); + setBlendedPixel(image, 0, 0, [100, 255]); expect(image).toMatchImageData([[100, 255]]); }); -test('GREYA image: alpha different from 255', () => { - const image = testUtils.createGreyaImage([[50, 64]]); - setBlendedPixel(image, 0, 0, { color: [100, 128] }); - const alpha = 128 + 64 * (1 - 128 / 255); - const component = (100 * 128 + 50 * 64 * (1 - 128 / 255)) / alpha; - expect(image).toMatchImageData([[component, alpha]]); -}); - test('asymetrical test', () => { const image = testUtils.createGreyaImage([ [50, 255, 1, 2, 3, 4], [20, 30, 5, 6, 7, 8], [1, 2, 3, 4, 5, 6], ]); - setBlendedPixel(image, 2, 0, { color: [0, 125] }); + setBlendedPixel(image, 2, 0, [0, 125]); expect(image).toMatchImageData([ [50, 255, 1, 2, 0, 127], [20, 30, 5, 6, 7, 8], @@ -60,7 +52,7 @@ test('2x2 mask, color is 1', () => { [1, 0], [0, 0], ]); - setBlendedPixel(mask, 1, 0, { color: [1] }); + setBlendedPixel(mask, 1, 0, [1]); expect(mask).toMatchMaskData([ [1, 1], diff --git a/src/utils/__tests__/setBlendedVisiblePixel.test.ts b/src/utils/__tests__/setBlendedVisiblePixel.test.ts new file mode 100644 index 000000000..471e933e1 --- /dev/null +++ b/src/utils/__tests__/setBlendedVisiblePixel.test.ts @@ -0,0 +1,49 @@ +import { setBlendedVisiblePixel } from '../setBlendedVisiblePixel'; + +test('GREYA image, default options', () => { + const image = testUtils.createGreyaImage([ + [50, 255], + [20, 30], + ]); + setBlendedVisiblePixel(image, 0, 1); + expect(image).toMatchImageData([ + [50, 255], + [0, 255], + ]); +}); + +test('GREYA image: set pixel out of bounds', () => { + const data = [ + [50, 255, 1, 2, 3, 4], + [20, 30, 5, 6, 7, 8], + [1, 2, 3, 4, 5, 6], + ]; + const image = testUtils.createGreyaImage(data); + setBlendedVisiblePixel(image, 0, 5, [40, 40]); + expect(image).toMatchImageData(data); +}); + +test('RGBA image: set pixel out of bounds', () => { + const data = [ + [50, 255, 1, 200, 2, 3, 4, 200], + [20, 30, 5, 200, 6, 7, 8, 200], + [1, 2, 3, 200, 4, 5, 6, 200], + ]; + const image = testUtils.createGreyaImage(data); + setBlendedVisiblePixel(image, 0, 5, [40, 40, 40, 40]); + expect(image).toMatchImageData(data); +}); + +test('asymetrical test', () => { + const image = testUtils.createGreyaImage([ + [50, 255, 1, 2, 3, 4], + [20, 30, 5, 6, 7, 8], + [1, 2, 3, 4, 5, 6], + ]); + setBlendedVisiblePixel(image, 2, 0, [0, 125]); + expect(image).toMatchImageData([ + [50, 255, 1, 2, 0, 127], + [20, 30, 5, 6, 7, 8], + [1, 2, 3, 4, 5, 6], + ]); +}); diff --git a/src/utils/setBlendedPixel.ts b/src/utils/setBlendedPixel.ts index 2ead03d56..324dfcaa0 100644 --- a/src/utils/setBlendedPixel.ts +++ b/src/utils/setBlendedPixel.ts @@ -4,28 +4,21 @@ import { Mask } from '../Mask'; import { getDefaultColor } from './getDefaultColor'; import { assert } from './validators/assert'; -export interface SetBlendedPixelOptions { - /** - * Color with which to blend the image pixel. - * @default `'Opaque black'`. - */ - color?: number[]; -} - /** * Blend the given pixel with the pixel at the specified location in the image. * @param image - The image with which to blend. * @param column - Column of the target pixel. * @param row - Row of the target pixel. - * @param options - Set blended pixel options. + * @param color - Color with which to blend the image pixel. @default `'Opaque black'`. */ + export function setBlendedPixel( image: Image | Mask, column: number, row: number, - options: SetBlendedPixelOptions = {}, + color?: number[], ) { - const { color = getDefaultColor(image) } = options; + color = color ?? getDefaultColor(image); if (!image.alpha) { image.setPixel(column, row, color); diff --git a/src/utils/setBlendedVisiblePixel.ts b/src/utils/setBlendedVisiblePixel.ts new file mode 100644 index 000000000..d78ae865e --- /dev/null +++ b/src/utils/setBlendedVisiblePixel.ts @@ -0,0 +1,22 @@ +import { Image } from '../Image'; +import { Mask } from '../Mask'; + +import { setBlendedPixel } from './setBlendedPixel'; + +/** + * Blend the given pixel with the pixel at the specified location in the image if the pixel is in image's bounds. + * @param image - The image with which to blend. + * @param column - Column of the target pixel. + * @param row - Row of the target pixel. + * @param color - Color with which to blend the image pixel. @default `'Opaque black'`. + */ +export function setBlendedVisiblePixel( + image: Image | Mask, + column: number, + row: number, + color?: number[], +) { + if (column >= 0 && column < image.width && row >= 0 && row < image.height) { + setBlendedPixel(image, column, row, color); + } +}