feat: core instruments

This commit is contained in:
2026-08-22 09:49:53 +05:00
parent 4ec2b418fd
commit 9ccc9f94ac
13 changed files with 829 additions and 2 deletions
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { parseHex, removeColorToAlpha } from './alpha';
import { makeImage } from './test-helpers';
describe('removeColorToAlpha', () => {
const fixture = () =>
makeImage(2, 1, [
[255, 255, 255, 255],
[255, 0, 0, 255]
]);
it('обнуляет альфу только у точного совпадения при tolerance=0', () => {
const out = removeColorToAlpha(fixture(), '#ff0000', 0);
expect(out.data[3]).toBe(255);
expect(out.data[7]).toBe(0);
expect(out.data[4]).toBe(255);
expect(out.data[5]).toBe(0);
});
it('поддерживает короткую форму и регистр hex', () => {
expect([...removeColorToAlpha(fixture(), '#f00', 0).data.slice(4)]).toEqual([
255, 0, 0, 0
]);
expect([...removeColorToAlpha(fixture(), '#FF0000', 0).data.slice(4)]).toEqual([
255, 0, 0, 0
]);
});
it('tolerance 100% удаляет весь диапазон расстояний', () => {
const out = removeColorToAlpha(fixture(), '#000000', 100);
expect(out.data[3]).toBe(0);
expect(out.data[7]).toBe(0);
});
it('промежуточный tolerance различает близкие и далёкие цвета', () => {
const img = makeImage(2, 1, [
[0, 0, 0, 255],
[128, 128, 128, 255]
]);
const kept = removeColorToAlpha(img, '#000000', 40);
const removed = removeColorToAlpha(img, '#000000', 60);
expect(kept.data[3]).toBe(0);
expect(kept.data[7]).toBe(255);
expect(removed.data[3]).toBe(0);
expect(removed.data[7]).toBe(0);
});
it('не мутирует вход', () => {
const img = fixture();
removeColorToAlpha(img, '#ffffff', 100);
expect([...img.data]).toEqual([
255, 255, 255, 255,
255, 0, 0, 255
]);
});
});
describe('parseHex', () => {
it('разбирает #rrggbb, rrggbb, #rgb', () => {
expect(parseHex('#ff8040')).toEqual([255, 128, 64]);
expect(parseHex('ff8040')).toEqual([255, 128, 64]);
expect(parseHex('#F80')).toEqual([255, 136, 0]);
});
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
expect(() => parseHex(bad)).toThrow(/Некорректный HEX/);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { PixelImage } from './types';
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
export function removeColorToAlpha(
img: PixelImage,
hex: string,
tolerancePercent = 0
): PixelImage {
const [targetR, targetG, targetB] = parseHex(hex);
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
const thresholdSq = tolerance * tolerance;
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
for (let i = 0; i < out.data.length; i += 4) {
const dr = out.data[i] - targetR;
const dg = out.data[i + 1] - targetG;
const db = out.data[i + 2] - targetB;
if (dr * dr + dg * dg + db * db <= thresholdSq) {
out.data[i + 3] = 0;
}
}
return out;
}
export function parseHex(hex: string): [number, number, number] {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
}
const digits = match[1];
if (digits.length === 3) {
return [
parseInt(digits[0] + digits[0], 16),
parseInt(digits[1] + digits[1], 16),
parseInt(digits[2] + digits[2], 16)
];
}
return [
parseInt(digits.slice(0, 2), 16),
parseInt(digits.slice(2, 4), 16),
parseInt(digits.slice(4, 6), 16)
];
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { imageInfo } from './analyze';
import { makeImage } from './test-helpers';
describe('imageInfo', () => {
it('находит полупрозрачные пиксели и считает уникальные RGBA-цвета', () => {
const info = imageInfo(
makeImage(2, 2, [
[0, 0, 0, 255],
[0, 0, 0, 255],
[255, 0, 0, 128],
[255, 0, 0, 128]
])
);
expect(info).toEqual({ width: 2, height: 2, hasAlpha: true, colorCount: 2 });
});
it('полностью непрозрачное изображение — hasAlpha false', () => {
const info = imageInfo(
makeImage(2, 1, [
[10, 20, 30, 255],
[40, 50, 60, 255]
])
);
expect(info.hasAlpha).toBe(false);
expect(info.colorCount).toBe(2);
});
it('разная альфа означает разные цвета', () => {
const info = imageInfo(
makeImage(2, 1, [
[255, 0, 0, 255],
[255, 0, 0, 128]
])
);
expect(info.hasAlpha).toBe(true);
expect(info.colorCount).toBe(2);
});
it('возвращает корректные размеры неквадрата', () => {
const info = imageInfo(makeImage(5, 3, new Array(15).fill([1, 2, 3, 4])));
expect(info.width).toBe(5);
expect(info.height).toBe(3);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { PixelImage } from './types';
export type ImageInfo = {
width: number;
height: number;
hasAlpha: boolean;
colorCount: number;
};
export function imageInfo(img: PixelImage): ImageInfo {
let hasAlpha = false;
const seen = new Set<number>();
for (let i = 0; i < img.data.length; i += 4) {
if (!hasAlpha && img.data[i + 3] !== 255) {
hasAlpha = true;
}
const key =
((img.data[i] << 24) |
(img.data[i + 1] << 16) |
(img.data[i + 2] << 8) |
img.data[i + 3]) >>>
0;
seen.add(key);
}
return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size };
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { brightnessContrast, grayscale, invert } from './color';
import { makeImage } from './test-helpers';
describe('grayscale', () => {
it('считает luma по весам BT.601 с округлением', () => {
const out = grayscale(
makeImage(3, 1, [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255]
])
);
const rgb = [...out.data].reduce<number[]>((acc, v, i) => {
if (i % 4 === 0) acc.push(v);
return acc;
}, []);
expect(rgb).toEqual([76, 150, 29]);
});
it('сохраняет альфу и не мутирует вход', () => {
const img = makeImage(1, 1, [[10, 20, 30, 200]]);
const out = grayscale(img);
expect([...out.data]).toEqual([18, 18, 18, 200]);
expect([...img.data]).toEqual([10, 20, 30, 200]);
});
});
describe('invert', () => {
it('инвертирует RGB, не трогая альфу', () => {
const out = invert(makeImage(1, 1, [[10, 200, 30, 7]]));
expect([...out.data]).toEqual([245, 55, 225, 7]);
});
});
describe('brightnessContrast', () => {
const pixel = (r: number) => makeImage(1, 1, [[r, r, r, 255]]);
const red = (out: number[]) => [out[0], out[1], out[2]];
it('b=0, c=0 — тождественное преобразование', () => {
const out = brightnessContrast(pixel(77), 0, 0);
expect(red([...out.data])).toEqual([77, 77, 77]);
});
it('brightness +100 насыщает всё в белый', () => {
const out = brightnessContrast(pixel(10), 100, 0);
expect(red([...out.data])).toEqual([255, 255, 255]);
});
it('brightness -100 заливает чёрным', () => {
const out = brightnessContrast(pixel(240), -100, 0);
expect(red([...out.data])).toEqual([0, 0, 0]);
});
it('contrast -100 сводит всё к серому 128', () => {
const out = brightnessContrast(pixel(30), 0, -100);
expect(red([...out.data])).toEqual([128, 128, 128]);
});
it('параметры вне диапазона клампятся', () => {
const out = brightnessContrast(pixel(10), 150, 0);
expect(red([...out.data])).toEqual([255, 255, 255]);
});
it('альфа не меняется', () => {
const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50);
expect(out.data[3]).toBe(64);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { createPixelImage, type PixelImage } from './types';
export function grayscale(img: PixelImage): PixelImage {
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) {
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
out.data[i] = luma;
out.data[i + 1] = luma;
out.data[i + 2] = luma;
out.data[i + 3] = img.data[i + 3];
}
return out;
}
export function invert(img: PixelImage): PixelImage {
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) {
out.data[i] = 255 - img.data[i];
out.data[i + 1] = 255 - img.data[i + 1];
out.data[i + 2] = 255 - img.data[i + 2];
out.data[i + 3] = img.data[i + 3];
}
return out;
}
export function brightnessContrast(
img: PixelImage,
brightness: number,
contrast: number
): PixelImage {
const offset = (clamp(brightness, -100, 100) / 100) * 255;
const c = (clamp(contrast, -100, 100) / 100) * 255;
const factor = (259 * (c + 255)) / (255 * (259 - c));
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) {
for (let ch = 0; ch < 3; ch++) {
const v = factor * (img.data[i + ch] + offset - 128) + 128;
out.data[i + ch] = v;
}
out.data[i + 3] = img.data[i + 3];
}
return out;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { crop, flip, resize, rotate90 } from './geometry';
import { makeImage } from './test-helpers';
const square = () =>
makeImage(2, 2, [
[1, 1, 1, 1],
[2, 2, 2, 2],
[3, 3, 3, 3],
[4, 4, 4, 4]
]);
describe('flip', () => {
it('отражает по горизонтали (зеркало слева-направо)', () => {
const out = flip(square(), 'horizontal');
expect(out.width).toBe(2);
expect(out.height).toBe(2);
expect([...out.data]).toEqual([
2, 2, 2, 2,
1, 1, 1, 1,
4, 4, 4, 4,
3, 3, 3, 3
]);
});
it('отражает по вертикали (сверху-вниз)', () => {
const out = flip(square(), 'vertical');
expect([...out.data]).toEqual([
3, 3, 3, 3,
4, 4, 4, 4,
1, 1, 1, 1,
2, 2, 2, 2
]);
});
it('не мутирует вход', () => {
const img = square();
flip(img, 'horizontal');
expect([...img.data]).toEqual([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]);
});
});
describe('rotate90', () => {
it('поворачивает неквадрат 2x3 на 90° по часовой', () => {
const rect = makeImage(2, 3, [
[1, 1, 1, 1],
[2, 2, 2, 2],
[3, 3, 3, 3],
[4, 4, 4, 4],
[5, 5, 5, 5],
[6, 6, 6, 6]
]);
const out = rotate90(rect, 1);
expect(out.width).toBe(3);
expect(out.height).toBe(2);
expect([...out.data]).toEqual([
5, 5, 5, 5,
3, 3, 3, 3,
1, 1, 1, 1,
6, 6, 6, 6,
4, 4, 4, 4,
2, 2, 2, 2
]);
});
it('turns=0 возвращает копию без изменений', () => {
const img = square();
const out = rotate90(img, 0);
expect(out).not.toBe(img);
expect([...out.data]).toEqual([...img.data]);
});
it('нормализует turns: 5 ≡ 1, -3 ≡ 1, 4 ≡ 0', () => {
const once = [...rotate90(square(), 1).data];
expect([...rotate90(square(), 5).data]).toEqual(once);
expect([...rotate90(square(), -3).data]).toEqual(once);
expect([...rotate90(square(), 4).data]).toEqual([...square().data]);
});
it('turns=2 на квадрате равно двойному отражению', () => {
const out = rotate90(square(), 2);
expect([...out.data]).toEqual([
4, 4, 4, 4,
3, 3, 3, 3,
2, 2, 2, 2,
1, 1, 1, 1
]);
});
});
describe('crop', () => {
const grid = () =>
makeImage(3, 3, [
[1, 1, 1, 1],
[2, 2, 2, 2],
[3, 3, 3, 3],
[4, 4, 4, 4],
[5, 5, 5, 5],
[6, 6, 6, 6],
[7, 7, 7, 7],
[8, 8, 8, 8],
[9, 9, 9, 9]
]);
it('вырезает центральную область 2x2 из 3x3', () => {
const out = crop(grid(), 1, 1, 2, 2);
expect(out.width).toBe(2);
expect(out.height).toBe(2);
expect([...out.data]).toEqual([
5, 5, 5, 5,
6, 6, 6, 6,
8, 8, 8, 8,
9, 9, 9, 9
]);
});
it('усекает область, выходящую за границы', () => {
const out = crop(grid(), -1, -1, 2, 2);
expect(out.width).toBe(1);
expect(out.height).toBe(1);
expect([...out.data]).toEqual([1, 1, 1, 1]);
});
it('бросает RangeError для области вне изображения', () => {
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(RangeError);
});
});
describe('resize', () => {
const twoByTwo = () =>
makeImage(2, 2, [
[0, 0, 0, 255],
[100, 100, 100, 255],
[200, 200, 200, 255],
[255, 255, 255, 255]
]);
it('совпадает с входом при тех же размерах', () => {
const img = twoByTwo();
expect([...resize(img, 2, 2).data]).toEqual([...img.data]);
});
it('апскейл 2x2 -> 4x4 билинейно интерполирует', () => {
const out = resize(twoByTwo(), 4, 4);
expect(out.width).toBe(4);
expect(out.height).toBe(4);
const red = [...out.data].filter((_, i) => i % 4 === 0);
expect(red.slice(0, 4)).toEqual([0, 25, 75, 100]);
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
});
it('бросает RangeError на некорректные размеры', () => {
const img = twoByTwo();
expect(() => resize(img, 0, 10)).toThrow(RangeError);
expect(() => resize(img, 10.5, 10)).toThrow(RangeError);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
export type FlipAxis = 'horizontal' | 'vertical';
export function flip(img: PixelImage, axis: FlipAxis): PixelImage {
const out = createPixelImage(img.width, img.height);
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const sx = axis === 'horizontal' ? img.width - 1 - x : x;
const sy = axis === 'vertical' ? img.height - 1 - y : y;
copyPixel(img, sx, sy, out, x, y);
}
}
return out;
}
export function rotate90(img: PixelImage, turns: number): PixelImage {
const t = ((Math.trunc(turns) % 4) + 4) % 4;
if (t === 0) return clonePixelImage(img);
let current = img;
for (let i = 0; i < t; i++) {
current = rotateClockwise(current);
}
return current;
}
function rotateClockwise(img: PixelImage): PixelImage {
const out = createPixelImage(img.height, img.width);
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
copyPixel(img, x, y, out, img.height - 1 - y, x);
}
}
return out;
}
export function crop(
img: PixelImage,
x: number,
y: number,
width: number,
height: number
): PixelImage {
const sx = clampInt(x, 0, img.width);
const sy = clampInt(y, 0, img.height);
const ex = clampInt(x + width, sx, img.width);
const ey = clampInt(y + height, sy, img.height);
const w = ex - sx;
const h = ey - sy;
if (w <= 0 || h <= 0) {
throw new RangeError('Область обрезки пуста: она целиком вне изображения');
}
const out = createPixelImage(w, h);
for (let row = 0; row < h; row++) {
const si = (sy + row) * img.width * 4 + sx * 4;
out.data.set(img.data.subarray(si, si + w * 4), row * w * 4);
}
return out;
}
export function resize(img: PixelImage, width: number, height: number): PixelImage {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new RangeError('Размеры должны быть целыми числами >= 1');
}
const out = createPixelImage(width, height);
const xr = img.width / width;
const yr = img.height / height;
const maxX = img.width - 1;
const maxY = img.height - 1;
for (let dy = 0; dy < height; dy++) {
const fy = Math.min(Math.max((dy + 0.5) * yr - 0.5, 0), maxY);
const y0 = Math.floor(fy);
const ty = fy - y0;
const y1 = Math.min(y0 + 1, maxY);
for (let dx = 0; dx < width; dx++) {
const fx = Math.min(Math.max((dx + 0.5) * xr - 0.5, 0), maxX);
const x0 = Math.floor(fx);
const tx = fx - x0;
const x1 = Math.min(x0 + 1, maxX);
const di = (dy * width + dx) * 4;
for (let ch = 0; ch < 4; ch++) {
const p00 = img.data[(y0 * img.width + x0) * 4 + ch];
const p10 = img.data[(y0 * img.width + x1) * 4 + ch];
const p01 = img.data[(y1 * img.width + x0) * 4 + ch];
const p11 = img.data[(y1 * img.width + x1) * 4 + ch];
const top = (1 - tx) * p00 + tx * p10;
const bottom = (1 - tx) * p01 + tx * p11;
out.data[di + ch] = (1 - ty) * top + ty * bottom;
}
}
}
return out;
}
function copyPixel(src: PixelImage, sx: number, sy: number, dst: PixelImage, dx: number, dy: number): void {
const si = (sy * src.width + sx) * 4;
const di = (dy * dst.width + dx) * 4;
dst.data[di] = src.data[si];
dst.data[di + 1] = src.data[si + 1];
dst.data[di + 2] = src.data[si + 2];
dst.data[di + 3] = src.data[si + 3];
}
function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}
+14
View File
@@ -0,0 +1,14 @@
import { expect } from 'vitest';
import type { PixelImage } from './types';
export function makeImage(width: number, height: number, pixels: number[][]): PixelImage {
const data = new Uint8ClampedArray(pixels.flat());
if (data.length !== width * height * 4) {
throw new Error(`Fixture mismatch: ${data.length} байт на ${width}x${height}`);
}
return { width, height, data };
}
export function expectImageEqual(actual: PixelImage, expectedPixels: number[][]): void {
expect([...actual.data]).toEqual(expectedPixels.flat());
}
+4
View File
@@ -11,3 +11,7 @@ export function createPixelImage(width: number, height: number): PixelImage {
data: new Uint8ClampedArray(width * height * 4)
};
}
export function clonePixelImage(img: PixelImage): PixelImage {
return { width: img.width, height: img.height, data: img.data.slice() };
}