dragonflight/services/editor/apps/image/src/adjustments/histogram.ts
Zac b68f0c6aba feat(editor): integrate openreel-video as services/editor with MAM hooks
Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
2026-05-17 21:44:37 -04:00

305 lines
8.1 KiB
TypeScript

export interface HistogramData {
red: Uint32Array;
green: Uint32Array;
blue: Uint32Array;
luminosity: Uint32Array;
}
export interface HistogramStatistics {
mean: number;
stdDev: number;
median: number;
min: number;
max: number;
pixelCount: number;
shadowsClipped: number;
highlightsClipped: number;
}
export interface HistogramResult {
data: HistogramData;
statistics: {
red: HistogramStatistics;
green: HistogramStatistics;
blue: HistogramStatistics;
luminosity: HistogramStatistics;
};
}
export interface ColorInfo {
rgb: { r: number; g: number; b: number };
hsb: { h: number; s: number; b: number };
hsl: { h: number; s: number; l: number };
lab: { l: number; a: number; b: number };
cmyk: { c: number; m: number; y: number; k: number };
hex: string;
}
function calculateStatistics(histogram: Uint32Array, totalPixels: number): HistogramStatistics {
let sum = 0;
let min = 255;
let max = 0;
let pixelCount = 0;
for (let i = 0; i < 256; i++) {
const count = histogram[i];
if (count > 0) {
sum += i * count;
pixelCount += count;
if (i < min) min = i;
if (i > max) max = i;
}
}
const mean = pixelCount > 0 ? sum / pixelCount : 0;
let varianceSum = 0;
for (let i = 0; i < 256; i++) {
const count = histogram[i];
if (count > 0) {
varianceSum += count * Math.pow(i - mean, 2);
}
}
const stdDev = pixelCount > 0 ? Math.sqrt(varianceSum / pixelCount) : 0;
let medianCount = 0;
let median = 0;
const halfCount = pixelCount / 2;
for (let i = 0; i < 256; i++) {
medianCount += histogram[i];
if (medianCount >= halfCount) {
median = i;
break;
}
}
const shadowsClipped = (histogram[0] / totalPixels) * 100;
const highlightsClipped = (histogram[255] / totalPixels) * 100;
return {
mean,
stdDev,
median,
min: pixelCount > 0 ? min : 0,
max: pixelCount > 0 ? max : 0,
pixelCount,
shadowsClipped,
highlightsClipped,
};
}
export function calculateHistogram(imageData: ImageData): HistogramResult {
const { data } = imageData;
const histogramData: HistogramData = {
red: new Uint32Array(256),
green: new Uint32Array(256),
blue: new Uint32Array(256),
luminosity: new Uint32Array(256),
};
const totalPixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
histogramData.red[r]++;
histogramData.green[g]++;
histogramData.blue[b]++;
const luminosity = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
histogramData.luminosity[luminosity]++;
}
return {
data: histogramData,
statistics: {
red: calculateStatistics(histogramData.red, totalPixels),
green: calculateStatistics(histogramData.green, totalPixels),
blue: calculateStatistics(histogramData.blue, totalPixels),
luminosity: calculateStatistics(histogramData.luminosity, totalPixels),
},
};
}
export function getColorInfo(r: number, g: number, b: number): ColorInfo {
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
const delta = max - min;
let h = 0;
if (delta !== 0) {
if (max === rNorm) {
h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6;
} else if (max === gNorm) {
h = ((bNorm - rNorm) / delta + 2) / 6;
} else {
h = ((rNorm - gNorm) / delta + 4) / 6;
}
}
const l = (max + min) / 2;
const sHsl = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
const sBrightness = max === 0 ? 0 : delta / max;
const k = 1 - max;
const c = max === 0 ? 0 : (1 - rNorm - k) / (1 - k);
const m = max === 0 ? 0 : (1 - gNorm - k) / (1 - k);
const y = max === 0 ? 0 : (1 - bNorm - k) / (1 - k);
const xyzR = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
const xyzG = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
const xyzB = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
const x = (xyzR * 0.4124564 + xyzG * 0.3575761 + xyzB * 0.1804375) / 0.95047;
const yVal = xyzR * 0.2126729 + xyzG * 0.7151522 + xyzB * 0.0721750;
const z = (xyzR * 0.0193339 + xyzG * 0.1191920 + xyzB * 0.9503041) / 1.08883;
const f = (t: number) => t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116;
const labL = 116 * f(yVal) - 16;
const labA = 500 * (f(x) - f(yVal));
const labB = 200 * (f(yVal) - f(z));
const hex = '#' +
r.toString(16).padStart(2, '0') +
g.toString(16).padStart(2, '0') +
b.toString(16).padStart(2, '0');
return {
rgb: { r, g, b },
hsb: {
h: Math.round(h * 360),
s: Math.round(sBrightness * 100),
b: Math.round(max * 100),
},
hsl: {
h: Math.round(h * 360),
s: Math.round(sHsl * 100),
l: Math.round(l * 100),
},
lab: {
l: Math.round(labL),
a: Math.round(labA),
b: Math.round(labB),
},
cmyk: {
c: Math.round(c * 100),
m: Math.round(m * 100),
y: Math.round(y * 100),
k: Math.round(k * 100),
},
hex,
};
}
export function renderHistogram(
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
histogram: Uint32Array,
color: string,
width: number,
height: number,
logarithmic: boolean = false
): void {
const maxValue = Math.max(...histogram);
if (maxValue === 0) return;
ctx.fillStyle = color;
ctx.globalAlpha = 0.7;
const barWidth = width / 256;
for (let i = 0; i < 256; i++) {
let normalizedValue: number;
if (logarithmic && histogram[i] > 0) {
normalizedValue = Math.log10(histogram[i] + 1) / Math.log10(maxValue + 1);
} else {
normalizedValue = histogram[i] / maxValue;
}
const barHeight = normalizedValue * height;
ctx.fillRect(i * barWidth, height - barHeight, barWidth, barHeight);
}
ctx.globalAlpha = 1;
}
export function autoLevels(imageData: ImageData, clipPercent: number = 0.1): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const histogram = calculateHistogram(imageData);
const totalPixels = data.length / 4;
const clipPixels = Math.round(totalPixels * (clipPercent / 100));
const findClipPoint = (hist: Uint32Array, fromStart: boolean): number => {
let count = 0;
if (fromStart) {
for (let i = 0; i < 256; i++) {
count += hist[i];
if (count > clipPixels) return i;
}
return 0;
} else {
for (let i = 255; i >= 0; i--) {
count += hist[i];
if (count > clipPixels) return i;
}
return 255;
}
};
const channels = ['red', 'green', 'blue'] as const;
const adjustments = channels.map((channel) => {
const hist = histogram.data[channel];
const inputBlack = findClipPoint(hist, true);
const inputWhite = findClipPoint(hist, false);
return { inputBlack, inputWhite };
});
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const { inputBlack, inputWhite } = adjustments[c];
const range = inputWhite - inputBlack || 1;
const value = data[i + c];
const adjusted = ((value - inputBlack) / range) * 255;
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}
export function autoContrast(imageData: ImageData): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
let minLum = 255;
let maxLum = 0;
for (let i = 0; i < data.length; i += 4) {
const lum = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
if (lum < minLum) minLum = lum;
if (lum > maxLum) maxLum = lum;
}
const range = maxLum - minLum || 1;
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const adjusted = ((data[i + c] - minLum) / range) * 255;
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}