map / src /shareHalftone.js
PH70248's picture
Add files using upload-large-folder tool
618aeaa verified
Raw
History Blame Contribute Delete
12.8 kB
export const SHARE_HALFTONE_DOT_RULE = {
step: 5,
minRadius: 0.1,
maxRadiusRatio: 0.46,
paperThreshold: 0,
sizeCurve: 0.8,
alphaCurve: 0.52,
minAlpha: 0.88,
maxAlpha: 1,
luminanceLow: 0.03,
luminanceHigh: 0.915,
contrast: 2.09,
baseInk: 0.01,
inkGamma: 0.92,
postCropZoom: 1.05,
postCropOffsetX: 0,
postCropOffsetY: 0,
radiusNoise: 0.08,
radiusNoiseFloor: 0.2,
accentWeight: 0.41,
departureWeight: 0.55,
toneWeight: 0.64,
hueDepartureWeight: 0.39,
saturationDepartureWeight: 0.09,
darkDepartureWeight: 0.39,
nearWhiteGate: 0.08,
paperLightnessGate: 0.885,
baseLightnessLift: 0.03,
dotLightnessLift: 0.08,
baseSaturationScale: 0.84,
dotSaturationScale: 1.18,
posterWeight: 0.82,
posterThreshold: 0.46,
posterSoftness: 0.15,
edgeWeight: 0.43,
edgeRadius: 3,
weaveBias: 0.08,
toneBias: 1.3,
hueBias: 0.34,
minInterleave: 0.12,
maxInterleave: 0.78,
};
export function shareHalftoneSamplePoint(x, y, width, height, sampleWidth, sampleHeight, rule = SHARE_HALFTONE_DOT_RULE) {
return {
sx: clamp(Math.floor((x / width) * sampleWidth), 0, sampleWidth - 1),
sy: clamp(Math.floor((y / height) * sampleHeight), 0, sampleHeight - 1),
};
}
export function shareHalftoneCropRect(width, height, rule = SHARE_HALFTONE_DOT_RULE) {
const zoom = Math.max(1, rule.postCropZoom || 1);
const sw = width / zoom;
const sh = height / zoom;
const centerX = (width - sw) / 2;
const centerY = (height - sh) / 2;
const sx = clamp(centerX + centerX * (rule.postCropOffsetX || 0), 0, width - sw);
const sy = clamp(centerY + centerY * (rule.postCropOffsetY || 0), 0, height - sh);
return { sx, sy, sw, sh };
}
export function shareHalftoneDotRadius(radiusTone, rule = SHARE_HALFTONE_DOT_RULE) {
const safeTone = printableTone(radiusTone, rule);
if (safeTone === 0) return 0;
const maxRadius = rule.step * rule.maxRadiusRatio;
const radiusRange = Math.max(0, maxRadius - rule.minRadius);
return rule.minRadius + Math.pow(safeTone, rule.sizeCurve) * radiusRange;
}
export function shareHalftoneDotAlpha(radiusTone, rule = SHARE_HALFTONE_DOT_RULE) {
const safeTone = printableTone(radiusTone, rule);
if (safeTone === 0) return 0;
return 1;
}
export function shareHalftoneNoisyTone(radiusTone, x, y, rule = SHARE_HALFTONE_DOT_RULE) {
const safeTone = clamp(radiusTone, 0, 1);
const midtoneWeight = 1 - Math.abs(safeTone - 0.5) * 2;
const noiseWeight = rule.radiusNoiseFloor + (1 - rule.radiusNoiseFloor) * Math.max(0, midtoneWeight);
const noise = halftoneCellNoise(x, y) * 2 - 1;
return clamp(safeTone + noise * rule.radiusNoise * noiseWeight, 0, 1);
}
export function shareHalftonePlateColors(primaryInk, secondaryInk, rule = SHARE_HALFTONE_DOT_RULE) {
return {
baseInk: normalizePlateInk(primaryInk, "base", rule),
dotInk: normalizePlateInk(secondaryInk, "dot", rule),
};
}
export function shareHalftoneFeatureColorPair(colors) {
if (!colors.length) return [];
if (colors.length === 1) return [colors[0], colors[0]];
const candidates = colors.filter(isUsableFeatureColor).slice(0, 28);
if (!candidates.length) return [colors[0], colors[0]];
const baseCandidates = candidates.slice(0, 6);
const topWeight = candidates[0]?.weight || 1;
let bestPair = [candidates[0], candidates[0]];
let bestScore = -Infinity;
baseCandidates.forEach((base, baseIndex) => {
candidates.forEach((accent) => {
if (accent === base) return;
const hueGap = hueDistance(base.h, accent.h);
if (hueGap < 12) return;
const accentArea = accent.weight / topWeight;
if (accentArea < 0.03) return;
const baseArea = base.weight / topWeight;
const accentPresence = clamp(accentArea / 0.18, 0, 1);
const hueSeparation = clamp((hueGap - 10) / 42, 0, 1);
const substrateBonus = baseIndex === 0 ? 0.12 : 0;
const lightnessContrast = Math.min(Math.abs(base.l - accent.l), 0.42) / 0.42;
const score = baseArea * 0.34
+ accentPresence * 0.24
+ hueSeparation * 0.16
+ accent.s * 0.1
+ lightnessContrast * 0.08
+ substrateBonus;
if (score > bestScore) {
bestScore = score;
bestPair = [base, accent];
}
});
});
return bestPair;
}
export function shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule = SHARE_HALFTONE_DOT_RULE) {
const hsl = rgbToHsl(r, g, b);
const baseHueFit = hueFit(hsl.h, baseInk.h);
const dotHueFit = hueFit(hsl.h, dotInk.h);
const chroma = clamp((hsl.s - 0.04) / 0.56, 0, 1);
const nearWhitePaper = hsl.l > rule.paperLightnessGate ? rule.nearWhiteGate : 1;
const accentSignal = clamp(0.5 + (dotHueFit - baseHueFit) * 1.55, 0, 1) * (0.55 + chroma * 0.45) * nearWhitePaper;
const hueDeparture = 1 - baseHueFit;
const saturationDeparture = Math.abs(hsl.s - baseInk.s);
const darkDeparture = clamp((baseInk.l - hsl.l) / 0.44, 0, 1);
const structureDeparture = clamp(
hueDeparture * rule.hueDepartureWeight
+ saturationDeparture * rule.saturationDepartureWeight
+ darkDeparture * rule.darkDepartureWeight,
0,
1,
) * nearWhitePaper;
const structureSignal = dotInk.l >= baseInk.l
? clamp(luminanceTone, 0, 1)
: 1 - clamp(luminanceTone, 0, 1);
return clamp(
accentSignal * rule.accentWeight
+ structureDeparture * rule.departureWeight
+ structureSignal * rule.toneWeight
+ rule.baseInk,
0,
1,
);
}
export function shareHalftonePosterDotToneForPixel(
pixels,
sx,
sy,
sampleWidth,
sampleHeight,
lowLum,
lumRange,
baseInk,
dotInk,
luminanceTone,
rule = SHARE_HALFTONE_DOT_RULE,
) {
const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight);
const colorTone = shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule);
const structureTone = dotInk.l >= baseInk.l ? luminanceTone : 1 - luminanceTone;
const posterTone = smoothstep(
rule.posterThreshold - rule.posterSoftness,
rule.posterThreshold + rule.posterSoftness,
structureTone,
);
const edgeTone = shareHalftonePosterEdgeTone(
pixels,
sx,
sy,
sampleWidth,
sampleHeight,
lowLum,
lumRange,
baseInk,
dotInk,
luminanceTone,
rule,
);
return clamp(
colorTone * (1 - rule.posterWeight)
+ posterTone * rule.posterWeight
+ edgeTone * rule.edgeWeight,
0,
1,
);
}
export function halftoneInkForPixel(r, g, b, imageInk, primaryInk, secondaryInk, x, y, rule = SHARE_HALFTONE_DOT_RULE) {
const hsl = rgbToHsl(r, g, b);
const ink = clamp(imageInk, 0, 1);
const secondaryHueFit = hueDistance(hsl.h, secondaryInk.h);
const primaryHueFit = hueDistance(hsl.h, primaryInk.h);
const huePreference = hsl.s < 0.055
? 0
: clamp((primaryHueFit - secondaryHueFit) / 180, -1, 1) * rule.hueBias;
const cellPreference = halftoneCellWeave(x, y, rule.step) ? rule.weaveBias : -rule.weaveBias;
const tonePreference = (0.5 - ink) * rule.toneBias;
const secondaryChance = clamp(
0.5 + cellPreference + tonePreference + huePreference,
rule.minInterleave,
rule.maxInterleave,
);
return halftoneCellNoise(x, y) < secondaryChance ? secondaryInk : primaryInk;
}
function halftoneCellWeave(x, y, step) {
const cellX = Math.floor(x / step);
const cellY = Math.floor(y / step);
return (cellX + cellY) % 2 === 0;
}
function halftoneCellNoise(x, y) {
const seed = Math.sin(Math.floor(x) * 12.9898 + Math.floor(y) * 78.233) * 43758.5453;
return seed - Math.floor(seed);
}
function shareHalftonePosterEdgeTone(
pixels,
sx,
sy,
sampleWidth,
sampleHeight,
lowLum,
lumRange,
baseInk,
dotInk,
centerLuminanceTone,
rule,
) {
const centerShape = generatedDotToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, rule);
const radius = Math.max(1, Math.round(rule.edgeRadius || 1));
const offsets = [
[-radius, 0],
[radius, 0],
[0, -radius],
[0, radius],
];
const maxEdge = offsets.reduce((peak, [dx, dy]) => {
const neighborTone = generatedLuminanceToneAt(pixels, sx + dx, sy + dy, sampleWidth, sampleHeight, lowLum, lumRange, rule);
const neighborShape = generatedDotToneAt(
pixels,
sx + dx,
sy + dy,
sampleWidth,
sampleHeight,
lowLum,
lumRange,
baseInk,
dotInk,
rule,
);
return Math.max(
peak,
Math.abs(centerLuminanceTone - neighborTone) * 0.72 + Math.abs(centerShape - neighborShape) * 0.28,
);
}, 0);
return clamp(maxEdge * 1.8, 0, 1);
}
function generatedDotToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, rule) {
const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight);
const luminanceTone = generatedLuminanceToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, rule);
return shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule);
}
function generatedLuminanceToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, rule) {
const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const normalized = clamp((luminance - lowLum) / lumRange, 0, 1);
return clamp((normalized - 0.5) * rule.contrast + 0.5, 0, 1);
}
function sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight) {
const x = clamp(Math.round(sx), 0, sampleWidth - 1);
const y = clamp(Math.round(sy), 0, sampleHeight - 1);
const offset = (y * sampleWidth + x) * 4;
return {
r: pixels[offset],
g: pixels[offset + 1],
b: pixels[offset + 2],
};
}
function normalizePlateInk(color, role, rule) {
const hsl = color.h === undefined || color.s === undefined || color.l === undefined
? rgbToHsl(color.r, color.g, color.b)
: color;
const saturation = role === "base"
? hsl.s < 0.16
? clamp(hsl.s * 1.02, 0.04, 0.16)
: clamp(hsl.s * rule.baseSaturationScale, 0.18, 0.5)
: hsl.s < 0.18
? clamp(hsl.s * rule.dotSaturationScale, 0.04, 0.18)
: clamp(hsl.s * rule.dotSaturationScale, 0.22, 0.74);
const lightness = role === "base"
? clamp(hsl.l + rule.baseLightnessLift, 0.38, 0.82)
: hsl.l < 0.2
? clamp(hsl.l + rule.dotLightnessLift * 0.22, 0.08, 0.28)
: clamp(hsl.l + rule.dotLightnessLift, 0.48, 0.92);
const [r, g, b] = hslToRgb(hsl.h, saturation, lightness);
return { r, g, b, h: hsl.h, s: saturation, l: lightness };
}
function smoothstep(edge0, edge1, value) {
if (edge0 === edge1) return value >= edge1 ? 1 : 0;
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function printableTone(radiusTone, rule) {
const safeTone = clamp(radiusTone, 0, 1);
if (safeTone < rule.paperThreshold) return 0;
return clamp((safeTone - rule.paperThreshold) / (1 - rule.paperThreshold), 0, 1);
}
function rgbToHsl(r, g, b) {
const red = r / 255;
const green = g / 255;
const blue = b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
let hue = 0;
let saturation = 0;
const lightness = (max + min) / 2;
if (max !== min) {
const delta = max - min;
saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === red) {
hue = (green - blue) / delta + (green < blue ? 6 : 0);
} else if (max === green) {
hue = (blue - red) / delta + 2;
} else {
hue = (red - green) / delta + 4;
}
hue *= 60;
}
return { h: hue, s: saturation, l: lightness };
}
function hueDistance(firstHue, secondHue) {
const difference = Math.abs(firstHue - secondHue) % 360;
return Math.min(difference, 360 - difference);
}
function hueFit(firstHue, secondHue) {
return 1 - clamp(hueDistance(firstHue, secondHue) / 90, 0, 1);
}
function isUsableFeatureColor(color) {
return color.l < 0.94;
}
function hslToRgb(hue, saturation, lightness) {
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
let red = 0;
let green = 0;
let blue = 0;
if (huePrime < 1) {
red = chroma;
green = x;
} else if (huePrime < 2) {
red = x;
green = chroma;
} else if (huePrime < 3) {
green = chroma;
blue = x;
} else if (huePrime < 4) {
green = x;
blue = chroma;
} else if (huePrime < 5) {
red = x;
blue = chroma;
} else {
red = chroma;
blue = x;
}
const match = lightness - chroma / 2;
return [
Math.round((red + match) * 255),
Math.round((green + match) * 255),
Math.round((blue + match) * 255),
];
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}