diff --git a/web/src/lib/core/affine.test.ts b/web/src/lib/core/affine.test.ts new file mode 100644 index 0000000..a5b1927 --- /dev/null +++ b/web/src/lib/core/affine.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { rotate90, sampleBilinear } from './geometry'; +import { rotateFreeImage, skewImage, transformImage, zoomImage } from './affine'; +import { makeImage } from './test-helpers'; + +describe('sampleBilinear', () => { + it('целые координаты возвращают точный пиксель', () => { + const img = makeImage(2, 1, [ + [255, 0, 0, 255], + [0, 0, 255, 255] + ]); + expect(sampleBilinear(img, 0, 0)).toEqual([255, 0, 0, 255]); + expect(sampleBilinear(img, 1, 0)).toEqual([0, 0, 255, 255]); + }); + + it('дробная координата интерполирует', () => { + const img = makeImage(2, 1, [ + [0, 0, 0, 255], + [200, 200, 200, 255] + ]); + const [r] = sampleBilinear(img, 0.5, 0); + expect(r).toBeCloseTo(100, 0); + }); + + it('координаты за краем клампятся', () => { + const img = makeImage(1, 1, [[7, 7, 7, 255]]); + expect(sampleBilinear(img, -10, -10)).toEqual([7, 7, 7, 255]); + }); +}); + +describe('rotateFreeImage', () => { + it('поворот на 180° даёт размеры не меньше исходных и непустой результат', () => { + const img = makeImage(4, 3, [ + [255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255], + [128, 128, 128, 255], [64, 64, 64, 255], [32, 32, 32, 255], [200, 200, 200, 255], + [10, 20, 30, 255], [40, 50, 60, 255], [70, 80, 90, 255], [100, 100, 100, 255] + ]); + const out = rotateFreeImage(img, 180); + expect(out.width).toBeGreaterThanOrEqual(img.width); + expect(out.height).toBeGreaterThanOrEqual(img.height); + let opaque = 0; + const total = out.width * out.height; + for (let i = 3; i < out.data.length; i += 4) { + if (out.data[i] > 0) opaque++; + } + expect(opaque / total).toBeGreaterThan(0.8); + }); + + it('поворот квадрата на 360° близок к оригиналу', () => { + const img = makeImage( + 5, + 5, + new Array(25).fill([200, 100, 50, 255]) + ); + const out = rotateFreeImage(img, 360); + expect(out.width).toBeGreaterThanOrEqual(5); + expect(out.height).toBeGreaterThanOrEqual(5); + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 5; x++) { + const di = (y * out.width + x) * 4; + for (let ch = 0; ch < 4; ch++) { + expect(Math.abs(out.data[di + ch] - img.data[(y * 5 + x) * 4 + ch])).toBeLessThanOrEqual(4); + } + } + } + }); +}); + +describe('skewImage', () => { + it('наклон X=45° сдвигает верхний правый угол', () => { + const img = makeImage(2, 2, [ + [255, 0, 0, 255], [0, 255, 0, 255], + [0, 0, 255, 255], [128, 128, 128, 255] + ]); + const out = skewImage(img, 45, 0); + expect(out.width).toBeGreaterThan(2); + expect(out.data[3]).toBe(255); + }); + + it('углы 0° — тождественное преобразование', () => { + const img = makeImage(2, 2, [ + [255, 0, 0, 255], [0, 255, 0, 255], + [0, 0, 255, 255], [128, 128, 128, 255] + ]); + const out = skewImage(img, 0, 0); + expect(out.width).toBe(2); + expect([...out.data]).toEqual([...img.data]); + }); +}); diff --git a/web/src/lib/core/affine.ts b/web/src/lib/core/affine.ts new file mode 100644 index 0000000..f2357f2 --- /dev/null +++ b/web/src/lib/core/affine.ts @@ -0,0 +1,121 @@ +import { clonePixelImage, createPixelImage, type PixelImage } from './types'; +import { sampleBilinear } from './geometry'; + +export type AffineMatrix = [number, number, number, number, number, number]; + +export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix { + const det = a * d - b * c; + if (Math.abs(det) < 1e-12) { + throw new Error('Вырожденная матрица трансформации'); + } + const ia = d / det; + const ib = -b / det; + const ic = -c / det; + const id = a / det; + return [ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]; +} + +function mulAffine(m1: AffineMatrix, m2: AffineMatrix): AffineMatrix { + return [ + m1[0] * m2[0] + m1[2] * m2[1], + m1[1] * m2[0] + m1[3] * m2[1], + m1[0] * m2[2] + m1[2] * m2[3], + m1[1] * m2[2] + m1[3] * m2[3], + m1[0] * m2[4] + m1[2] * m2[5] + m1[4], + m1[1] * m2[4] + m1[3] * m2[5] + m1[5] + ]; +} + +export function transformImage( + img: PixelImage, + dstToSrc: AffineMatrix, + outWidth: number, + outHeight: number +): PixelImage { + const [a, b, c, d, e, f] = invertAffine(dstToSrc); + const out = createPixelImage(outWidth, outHeight); + for (let y = 0; y < outHeight; y++) { + for (let x = 0; x < outWidth; x++) { + const sx = a * x + c * y + e; + const sy = b * x + d * y + f; + const di = (y * outWidth + x) * 4; + if (sx < -1 || sy < -1 || sx > img.width || sy > img.height) continue; + const [r, g, bl, al] = sampleBilinear(img, sx, sy); + out.data[di] = r; + out.data[di + 1] = g; + out.data[di + 2] = bl; + out.data[di + 3] = al; + } + } + return out; +} + +function transformCorners( + img: PixelImage, + m: AffineMatrix +): { minX: number; minY: number; outW: number; outH: number } { + const pts = [ + [0, 0], + [img.width, 0], + [0, img.height], + [img.width, img.height] + ].map(([x, y]) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]); + const xs = pts.map((p) => p[0]); + const ys = pts.map((p) => p[1]); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + minX, + minY, + outW: Math.ceil(Math.max(...xs) - minX), + outH: Math.ceil(Math.max(...ys) - minY) + }; +} + +export function skewImage(img: PixelImage, degX: number, degY: number): PixelImage { + const kx = Math.tan((degX * Math.PI) / 180); + const ky = Math.tan((degY * Math.PI) / 180); + if (!Number.isFinite(kx) || !Number.isFinite(ky)) { + throw new Error('Углы наклона не могут быть 90° или -90°'); + } + const forward: AffineMatrix = [1, ky, kx, 1, 0, 0]; + const bounds = transformCorners(img, forward); + const toOrigin: AffineMatrix = [1, 0, 0, 1, -bounds.minX, -bounds.minY]; + return transformImage(img, mulAffine(invertAffine(forward), invertAffine(toOrigin)), bounds.outW, bounds.outH); +} + +export function rotateFreeImage(img: PixelImage, degrees: number): PixelImage { + const rad = (degrees * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const forward: AffineMatrix = [cos, sin, -sin, cos, 0, 0]; + const bounds = transformCorners(img, forward); + const toOrigin: AffineMatrix = [1, 0, 0, 1, -bounds.minX, -bounds.minY]; + return transformImage( + img, + mulAffine(invertAffine(forward), invertAffine(toOrigin)), + bounds.outW, + bounds.outH + ); +} + +export function zoomImage(img: PixelImage, scalePercent: number): PixelImage { + const scale = Math.min(Math.max(scalePercent, 100), 1000) / 100; + if (scale === 1) return clonePixelImage(img); + const cx = (img.width - 1) / 2; + const cy = (img.height - 1) / 2; + const out = createPixelImage(img.width, img.height); + for (let y = 0; y < out.height; y++) { + for (let x = 0; x < out.width; x++) { + const sx = cx + (x - cx) / scale; + const sy = cy + (y - cy) / scale; + const [r, g, b, a] = sampleBilinear(img, sx, sy); + const di = (y * out.width + x) * 4; + out.data[di] = r; + out.data[di + 1] = g; + out.data[di + 2] = b; + out.data[di + 3] = a; + } + } + return out; +} diff --git a/web/src/lib/core/color.test.ts b/web/src/lib/core/color.test.ts index 9fda8d8..254eea6 100644 --- a/web/src/lib/core/color.test.ts +++ b/web/src/lib/core/color.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + autoContrast, brightnessContrast, changeHue, extractChannel, + gammaCorrection, grayscale, invert, posterize, @@ -10,6 +12,8 @@ import { sepia, setOpacity, swapChannels, + temperature, + tint, thresholdBlackWhite, twoColors } from './color'; @@ -183,3 +187,48 @@ describe('brightnessContrast', () => { expect(out.data[3]).toBe(64); }); }); + +describe('gammaCorrection', () => { + it('гамма 1 — идентичность', () => { + const img = makeImage(1, 1, [[64, 128, 192, 255]]); + expect([...gammaCorrection(img, 1).data]).toEqual([64, 128, 192, 255]); + }); + it('гамма 2 удваивает яркость (64 → 128)', () => { + const out = gammaCorrection(makeImage(1, 1, [[64, 64, 64, 255]]), 2); + expect(out.data[0]).toBe(128); + }); +}); + +describe('autoContrast', () => { + it('растягивает диапазон [10..200] до [0..255]', () => { + const out = autoContrast(makeImage(2, 1, [ + [10, 10, 10, 255], + [200, 200, 200, 255] + ])); + expect(out.data[0]).toBe(0); + expect(out.data[4]).toBe(255); + }); +}); + +describe('temperature', () => { + it('положительная — теплее (красный ↑, синий ↓)', () => { + const out = temperature(makeImage(1, 1, [[128, 128, 128, 255]]), 50); + expect(out.data[0]).toBeGreaterThan(128); + expect(out.data[2]).toBeLessThan(128); + }); + it('нулевая температура не меняет', () => { + const img = makeImage(1, 1, [[128, 128, 128, 255]]); + expect([...temperature(img, 0).data]).toEqual([...img.data]); + }); +}); + +describe('tint', () => { + it('сила 100 на белом даёт чистый цвет', () => { + const out = tint(makeImage(1, 1, [[255, 255, 255, 255]]), '#ff0000', 100); + expect([...out.data]).toEqual([255, 0, 0, 255]); + }); + it('сила 0 — идентичность', () => { + const img = makeImage(1, 1, [[100, 150, 200, 255]]); + expect([...tint(img, '#ff0000', 0).data]).toEqual([...img.data]); + }); +}); diff --git a/web/src/lib/core/color.ts b/web/src/lib/core/color.ts index 5170809..1a857f3 100644 --- a/web/src/lib/core/color.ts +++ b/web/src/lib/core/color.ts @@ -223,3 +223,84 @@ export function rgbToHex(r: number, g: number, b: number): string { function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } + +export function gammaCorrection(img: PixelImage, value: number): PixelImage { + const g = clamp(value, 0.1, 5); + const lut = new Uint8ClampedArray(256); + for (let v = 0; v < 256; v++) { + lut[v] = 255 * Math.pow(v / 255, 1 / g); + } + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = lut[img.data[i]]; + out.data[i + 1] = lut[img.data[i + 1]]; + out.data[i + 2] = lut[img.data[i + 2]]; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function autoContrast(img: PixelImage): PixelImage { + const lo = [255, 255, 255]; + const hi = [0, 0, 0]; + for (let i = 0; i < img.data.length; i += 4) { + for (let ch = 0; ch < 3; ch++) { + if (img.data[i + ch] < lo[ch]) lo[ch] = img.data[i + ch]; + if (img.data[i + ch] > hi[ch]) hi[ch] = img.data[i + ch]; + } + } + const luts: Uint8ClampedArray[] = []; + for (let ch = 0; ch < 3; ch++) { + const lut = new Uint8ClampedArray(256); + const range = hi[ch] - lo[ch]; + for (let v = 0; v < 256; v++) { + lut[v] = range > 0 ? ((v - lo[ch]) * 255) / range : v; + } + luts.push(lut); + } + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = luts[0][img.data[i]]; + out.data[i + 1] = luts[1][img.data[i + 1]]; + out.data[i + 2] = luts[2][img.data[i + 2]]; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function temperature(img: PixelImage, percent: number): PixelImage { + const k = clamp(percent, -100, 100) / 100; + const rFactor = 1 + 0.25 * k; + const bFactor = 1 - 0.25 * k; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = img.data[i] * rFactor; + out.data[i + 1] = img.data[i + 1]; + out.data[i + 2] = img.data[i + 2] * bFactor; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function tint( + img: PixelImage, + colorHex: string, + strengthPercent: number +): PixelImage { + const s = clamp(strengthPercent, 0, 100) / 100; + const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim()); + if (!match) throw new Error(`Некорректный HEX-цвет: "${colorHex}"`); + const d = match[1]; + const tr = parseInt(d.slice(0, 2), 16) / 255; + const tg = parseInt(d.slice(2, 4), 16) / 255; + const tb = parseInt(d.slice(4, 6), 16) / 255; + const factors = [1 + (tr - 1) * s, 1 + (tg - 1) * s, 1 + (tb - 1) * s]; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + for (let ch = 0; ch < 3; ch++) { + out.data[i + ch] = img.data[i + ch] * factors[ch]; + } + out.data[i + 3] = img.data[i + 3]; + } + return out; +} diff --git a/web/src/lib/core/effects.test.ts b/web/src/lib/core/effects.test.ts new file mode 100644 index 0000000..2ca9630 --- /dev/null +++ b/web/src/lib/core/effects.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { vignette } from './effects'; +import { makeImage } from './test-helpers'; + +describe('vignette', () => { + it('сила 0 — идентичное преобразование', () => { + const img = makeImage(3, 3, new Array(9).fill([200, 100, 50, 255])); + expect([...vignette(img, 0).data]).toEqual([...img.data]); + }); + + it('углы темнее центра', () => { + const img = makeImage(7, 7, new Array(49).fill([200, 200, 200, 255])); + const out = vignette(img, 80); + const centerR = out.data[(3 * 7 + 3) * 4]; + const cornerR = out.data[0]; + expect(cornerR).toBeLessThan(centerR); + expect(centerR).toBeGreaterThan(150); + }); +}); diff --git a/web/src/lib/core/effects.ts b/web/src/lib/core/effects.ts new file mode 100644 index 0000000..ba501ec --- /dev/null +++ b/web/src/lib/core/effects.ts @@ -0,0 +1,24 @@ +import { clonePixelImage, createPixelImage, type PixelImage } from './types'; + +export function vignette(img: PixelImage, strengthPercent: number): PixelImage { + const strength = Math.min(Math.max(strengthPercent, 0), 100) / 100; + if (strength === 0) return clonePixelImage(img); + const cx = (img.width - 1) / 2; + const cy = (img.height - 1) / 2; + const maxDist = Math.sqrt(cx * cx + cy * cy); + const out = createPixelImage(img.width, img.height); + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const dx = x - cx; + const dy = y - cy; + const t = Math.sqrt(dx * dx + dy * dy) / maxDist; + const factor = 1 - strength * t * t; + const di = (y * img.width + x) * 4; + out.data[di] = img.data[di] * factor; + out.data[di + 1] = img.data[di + 1] * factor; + out.data[di + 2] = img.data[di + 2] * factor; + out.data[di + 3] = img.data[di + 3]; + } + } + return out; +} diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts index 07b9d36..0f0d5b8 100644 --- a/web/src/lib/core/geometry.ts +++ b/web/src/lib/core/geometry.ts @@ -181,6 +181,34 @@ function copyPixel(src: PixelImage, sx: number, sy: number, dst: PixelImage, dx: dst.data[di + 3] = src.data[si + 3]; } +export function sampleBilinear( + img: PixelImage, + fx: number, + fy: number +): [number, number, number, number] { + const maxX = img.width - 1; + const maxY = img.height - 1; + const cx = Math.min(Math.max(fx, 0), maxX); + const cy = Math.min(Math.max(fy, 0), maxY); + const x0 = Math.floor(cx); + const y0 = Math.floor(cy); + const tx = cx - x0; + const ty = cy - y0; + const x1 = Math.min(x0 + 1, maxX); + const y1 = Math.min(y0 + 1, maxY); + const i00 = (y0 * img.width + x0) * 4; + const i10 = (y0 * img.width + x1) * 4; + const i01 = (y1 * img.width + x0) * 4; + const i11 = (y1 * img.width + x1) * 4; + const result: [number, number, number, number] = [0, 0, 0, 0]; + for (let ch = 0; ch < 4; ch++) { + const top = (1 - tx) * img.data[i00 + ch] + tx * img.data[i10 + ch]; + const bottom = (1 - tx) * img.data[i01 + ch] + tx * img.data[i11 + ch]; + result[ch] = (1 - ty) * top + ty * bottom; + } + return result; +} + function clampInt(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, Math.trunc(value))); }