mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 12:08:00 +01:00
wip
This commit is contained in:
parent
516f4a98fd
commit
295ddd5103
4 changed files with 449 additions and 0 deletions
|
@ -9,6 +9,8 @@
|
||||||
import './ujs';
|
import './ujs';
|
||||||
import './when-ready';
|
import './when-ready';
|
||||||
|
|
||||||
|
import './vision/chroma-subsample';
|
||||||
|
|
||||||
// When developing CSS, include the relevant CSS you're working on here
|
// When developing CSS, include the relevant CSS you're working on here
|
||||||
// in order to enable HMR (live reload) on it.
|
// in order to enable HMR (live reload) on it.
|
||||||
// Would typically be either the theme file, or any additional file
|
// Would typically be either the theme file, or any additional file
|
||||||
|
|
286
assets/js/vision/chroma-subsample.ts
Normal file
286
assets/js/vision/chroma-subsample.ts
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
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;
|
36
assets/js/vision/load.ts
Normal file
36
assets/js/vision/load.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
export interface ResultImage {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: ImageBitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextLowestDimension(dimension: number): number {
|
||||||
|
return Math.min(Math.pow(2, Math.floor(Math.log2(dimension))), 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadImageCroppedPowerOfTwo(url: string): Promise<ResultImage> {
|
||||||
|
const image = document.createElement('img');
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
image.onload = () => resolve();
|
||||||
|
image.onerror = () => reject();
|
||||||
|
image.crossOrigin = '';
|
||||||
|
image.style.width = '1px';
|
||||||
|
image.style.height = '1px';
|
||||||
|
image.src = url;
|
||||||
|
|
||||||
|
body.insertAdjacentElement('beforeend', image);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cropWidth = nextLowestDimension(image.naturalWidth);
|
||||||
|
const cropHeight = nextLowestDimension(image.naturalHeight);
|
||||||
|
const data = await createImageBitmap(image, 0, 0, cropWidth, cropHeight);
|
||||||
|
body.removeChild(image);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: cropWidth,
|
||||||
|
height: cropHeight,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
125
assets/js/vision/webgl.ts
Normal file
125
assets/js/vision/webgl.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
export function compileShader(gl: WebGL2RenderingContext, shaderSource: string, shaderType: GLenum) {
|
||||||
|
const shader = gl.createShader(shaderType);
|
||||||
|
if (!shader) {
|
||||||
|
throw new Error(`failed to create shader of type ${shaderType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.shaderSource(shader, shaderSource);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
throw new Error(`Shader compilation failed: ${gl.getShaderInfoLog(shader)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProgramFromSources(
|
||||||
|
gl: WebGL2RenderingContext,
|
||||||
|
vertexShaderSource: string,
|
||||||
|
fragmentShaderSource: string,
|
||||||
|
) {
|
||||||
|
const vertexShader = compileShader(gl, vertexShaderSource, gl.VERTEX_SHADER);
|
||||||
|
const fragmentShader = compileShader(gl, fragmentShaderSource, gl.FRAGMENT_SHADER);
|
||||||
|
|
||||||
|
const program = gl.createProgram();
|
||||||
|
if (!program) {
|
||||||
|
throw new Error('failed to create vertex + fragment program');
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.attachShader(program, vertexShader);
|
||||||
|
gl.attachShader(program, fragmentShader);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOffscreenCanvasRenderingContext(): WebGL2RenderingContext {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = canvas.getContext('webgl2');
|
||||||
|
|
||||||
|
if (!gl) {
|
||||||
|
throw new Error('failed to create WebGL2 context');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gl.getExtension('EXT_color_buffer_float')) {
|
||||||
|
throw new Error('failed to enable EXT_color_buffer_float extension');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextureParameters {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
internalFormat: GLenum;
|
||||||
|
format: GLenum;
|
||||||
|
type: GLenum;
|
||||||
|
pixels?: ImageBitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Texture extends TextureParameters {
|
||||||
|
object: WebGLTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Framebuffer {
|
||||||
|
object: WebGLFramebuffer;
|
||||||
|
texture: Texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNonMippedLinearTexture(gl: WebGL2RenderingContext, params: TextureParameters): Texture {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
if (!texture) {
|
||||||
|
throw new Error('failed to create texture');
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = 0;
|
||||||
|
const internalFormat = params.internalFormat;
|
||||||
|
const border = 0;
|
||||||
|
const format = params.format;
|
||||||
|
const type = params.type;
|
||||||
|
const data = params.pixels;
|
||||||
|
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
if (data) {
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, format, type, data);
|
||||||
|
} else {
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, params.width, params.height, border, format, type, null);
|
||||||
|
}
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
|
||||||
|
return { object: texture, ...params };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNonMippedLinearTextureFbo(gl: WebGL2RenderingContext, params: TextureParameters): Framebuffer {
|
||||||
|
const texture = createNonMippedLinearTexture(gl, params);
|
||||||
|
const fbo = gl.createFramebuffer();
|
||||||
|
if (!fbo) {
|
||||||
|
throw new Error('failed to create framebuffer object');
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||||
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture.object, 0);
|
||||||
|
|
||||||
|
return { object: fbo, texture };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUniformLocation(
|
||||||
|
gl: WebGL2RenderingContext,
|
||||||
|
program: WebGLProgram,
|
||||||
|
name: string,
|
||||||
|
): WebGLUniformLocation {
|
||||||
|
const location = gl.getUniformLocation(program, name);
|
||||||
|
if (!location) {
|
||||||
|
throw new Error('failed to get uniform location');
|
||||||
|
}
|
||||||
|
|
||||||
|
return location;
|
||||||
|
}
|
Loading…
Reference in a new issue