philomena/assets/js/vision/chroma-subsample.ts
2024-09-03 20:05:02 -04:00

286 lines
8.1 KiB
TypeScript

import { loadImageCroppedPowerOfTwo } from './load';
import {
createNonMippedLinearTexture,
createNonMippedLinearTextureFbo,
createOffscreenCanvasRenderingContext,
createProgramFromSources,
Framebuffer,
getUniformLocation,
Texture,
} from './webgl';
const vert = `#version 300 es
void main() {
float x = float((gl_VertexID & 1) << 2);
float y = float((gl_VertexID & 2) << 1);
gl_Position = vec4(x - 1.0, -1.0 * (y - 1.0), 0.0, 1.0);
}
`;
const mapFragment = `#version 300 es
precision highp float;
uniform highp sampler2D image;
uniform highp uvec2 windowResolution;
uniform highp ivec2 dynamicOffset;
out vec3 outCov;
vec2 linearRGBToPbPr(vec3 rgb) {
float rPrime = rgb.r;
float gPrime = rgb.g;
float bPrime = rgb.b;
float pb = - (0.168736 * rPrime) - (0.331264 * gPrime) + (0.5 * bPrime);
float pr = (0.5 * rPrime) - (0.418688 * gPrime) - (0.081312 * bPrime);
return vec2(pb, pr);
}
float coefficientOfVariation(vec4 values) {
float mean = dot(values, vec4(1.0)) / 4.0;
float stddev = length(values - mean) / sqrt(4.0);
if (abs(mean) > 1e-4) {
return abs(stddev / mean);
} else {
return 0.0;
}
}
vec4 fetchWithOffset(ivec2 coord, ivec2 size) {
int x = min(coord.x + dynamicOffset.x, size.x - 1);
int y = min(coord.y + dynamicOffset.y, size.y - 1);
return texelFetch(image, ivec2(x, y), 0);
}
void main() {
ivec2 windowCoord = ivec2(vec2(gl_FragCoord.x, float(windowResolution.y) - gl_FragCoord.y) - 0.5);
ivec2 size = ivec2(textureSize(image, 0));
vec4 nw = fetchWithOffset(windowCoord * 2 + ivec2(0, 0), size);
vec4 ne = fetchWithOffset(windowCoord * 2 + ivec2(0, 1), size);
vec4 sw = fetchWithOffset(windowCoord * 2 + ivec2(1, 0), size);
vec4 se = fetchWithOffset(windowCoord * 2 + ivec2(1, 1), size);
vec2 chromaNw = linearRGBToPbPr(nw.rgb);
vec2 chromaNe = linearRGBToPbPr(ne.rgb);
vec2 chromaSw = linearRGBToPbPr(sw.rgb);
vec2 chromaSe = linearRGBToPbPr(se.rgb);
float covPb = coefficientOfVariation(vec4(chromaNw.x, chromaNe.x, chromaSw.x, chromaSe.x));
float covPr = coefficientOfVariation(vec4(chromaNw.y, chromaNe.y, chromaSw.y, chromaSe.y));
bvec4 nonOpaqueAlpha = lessThan(vec4(nw.a, ne.a, sw.a, se.a), vec4(1.0));
float alphaAny = dot(vec4(nonOpaqueAlpha), vec4(1.0));
outCov = vec3(covPb, covPr, alphaAny);
}
`;
const reduceFragment = `#version 300 es
precision highp float;
uniform highp sampler2D image;
uniform highp uvec2 windowResolution;
#define REDUCE_HORIZONTAL 0
#define REDUCE_VERTICAL 1
uniform uint reduceDimension;
out vec3 outCov;
void main() {
ivec2 windowCoord = ivec2(vec2(gl_FragCoord.x, float(windowResolution.y) - gl_FragCoord.y) - 0.5);
if (windowCoord[reduceDimension] > 0) {
discard;
}
vec3 result = vec3(0.0);
ivec2 offset = ivec2(0);
int n = textureSize(image, 0)[reduceDimension];
for (int i = 0; i < n; i++) {
offset[reduceDimension] += 1;
result += texelFetch(image, windowCoord + offset, 0).xyz;
}
outCov = result;
}
`;
const reduceHorizontal = 0;
const reduceVertical = 1;
function getDynamicOffset(round: number): [GLint, GLint] {
switch (round) {
case 0:
return [0, 0];
case 1:
return [1, 0];
case 2:
return [0, 1];
default:
return [1, 1];
}
}
function coefficientOfVariation(values: number[]): number {
const mean = values.reduce((a, n) => a + n, 0) / values.length;
const stddev = Math.sqrt(values.reduce((a, n) => a + (n - mean) * (n - mean), 0) / values.length);
if (Math.abs(mean) > 1e-4) {
return Math.abs(stddev / mean);
}
return 0;
}
type CovCovPb = number;
type CovCovPr = number;
type SumAlpha = number;
async function detectChromaSubsampling(imageUrl: string): Promise<[CovCovPb, CovCovPr, SumAlpha]> {
const bitmap = await loadImageCroppedPowerOfTwo(imageUrl);
const gl = createOffscreenCanvasRenderingContext();
const mapProgram = createProgramFromSources(gl, vert, mapFragment);
const mapImage = getUniformLocation(gl, mapProgram, 'image');
const mapWindowResolution = getUniformLocation(gl, mapProgram, 'windowResolution');
const mapDynamicOffset = getUniformLocation(gl, mapProgram, 'dynamicOffset');
const reduceProgram = createProgramFromSources(gl, vert, reduceFragment);
const reduceImage = getUniformLocation(gl, reduceProgram, 'image');
const reduceWindowResolution = getUniformLocation(gl, reduceProgram, 'windowResolution');
const reduceReduceDimension = getUniformLocation(gl, reduceProgram, 'reduceDimension');
const sourceFormat = {
internalFormat: gl.RGBA,
format: gl.RGBA,
type: gl.UNSIGNED_BYTE,
};
const targetFormat = {
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
};
const sourceTexture = createNonMippedLinearTexture(gl, {
width: bitmap.width,
height: bitmap.height,
pixels: bitmap.data,
...sourceFormat,
});
const mapFbo = createNonMippedLinearTextureFbo(gl, {
width: Math.max(1, bitmap.width >> 1),
height: Math.max(1, bitmap.height >> 1),
...targetFormat,
});
const reduceHorizontalFbo = createNonMippedLinearTextureFbo(gl, {
width: 1,
height: Math.max(1, bitmap.height >> 1),
...targetFormat,
});
const reduceVerticalFbo = createNonMippedLinearTextureFbo(gl, {
width: 1,
height: 1,
...targetFormat,
});
function configure(
program: WebGLProgram,
srcLocation: WebGLUniformLocation,
srcTex: Texture,
dstResLocation: WebGLUniformLocation,
dstFramebuffer: Framebuffer,
) {
// Set up program
gl.useProgram(program);
// Bind FBO for offscreen rendering
gl.bindFramebuffer(gl.FRAMEBUFFER, dstFramebuffer.object);
// Configure sampler
gl.uniform1i(srcLocation, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, srcTex.object);
// Configure resolution
gl.uniform2ui(dstResLocation, dstFramebuffer.texture.width, dstFramebuffer.texture.height);
// Set viewport and scissor
gl.viewport(0, 0, dstFramebuffer.texture.width, dstFramebuffer.texture.height);
gl.disable(gl.SCISSOR_TEST);
gl.disable(gl.DEPTH_TEST);
// Discard existing contents
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
}
function generateSumCoeffs(round: number): [number, number, number] {
// Map pixels into the given format
configure(mapProgram, mapImage, sourceTexture, mapWindowResolution, mapFbo);
gl.uniform2i(mapDynamicOffset, ...getDynamicOffset(round));
gl.drawArrays(gl.TRIANGLES, 0, 3);
// Horizontal reduction
configure(reduceProgram, reduceImage, mapFbo.texture, reduceWindowResolution, reduceHorizontalFbo);
gl.uniform1ui(reduceReduceDimension, reduceHorizontal);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// Vertical reduction
configure(reduceProgram, reduceImage, reduceHorizontalFbo.texture, reduceWindowResolution, reduceVerticalFbo);
gl.uniform1ui(reduceReduceDimension, reduceVertical);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// Output
const sumCoeffs = new Float32Array(4);
gl.readPixels(0, 0, 1, 1, targetFormat.format, targetFormat.type, sumCoeffs);
return [sumCoeffs[0], sumCoeffs[1], sumCoeffs[2]];
}
const allCovPb: number[] = [];
const allCovPr: number[] = [];
let sumAlpha: number = 0;
for (let i = 0; i < 4; i++) {
const [covPb, covPr, alpha] = generateSumCoeffs(i);
allCovPb.push(covPb);
allCovPr.push(covPr);
sumAlpha += alpha;
}
const covCovPb = coefficientOfVariation(allCovPb);
const covCovPr = coefficientOfVariation(allCovPr);
return [covCovPb, covCovPr, sumAlpha];
}
export type SubsampleClassification = 'probablyNotSubsampled' | 'probablySubsampled' | 'hasTransparency';
export async function classifyChromaSubsampling(imageUrl: string): Promise<SubsampleClassification> {
const [covCovPb, covCovPr, sumAlpha] = await detectChromaSubsampling(imageUrl);
if (sumAlpha > 0) {
// Regardless of whether it was subsampled, this image has transparency
// and so classifications about its quality are no longer relevant
return 'hasTransparency';
}
if (covCovPb * covCovPr > 1e-3) {
return 'probablySubsampled';
}
return 'probablyNotSubsampled';
}
(window as any).detectChromaSubsampling = detectChromaSubsampling;