Rewrite Emboss and Solarize filters for improved performance and usability and better license handling. close #1375

This commit is contained in:
Anton Lavrevov
2025-08-23 03:17:30 -05:00
parent a15dd097b4
commit 5b06b93724
3 changed files with 328 additions and 190 deletions

View File

@@ -236,9 +236,24 @@
<!-- Default Solarize Controls -->
<div id="solarize-controls-default" style="display: none">
<p style="color: #666; margin: 0; font-size: 12px">
No parameters for default Solarize filter
</p>
<div class="control-group">
<label for="solarize-threshold-default"
>Threshold:
<span
id="solarize-threshold-value-default"
class="value-display"
>127</span
></label
>
<input
type="range"
id="solarize-threshold-default"
min="0"
max="255"
value="127"
step="1"
/>
</div>
</div>
</div>
</div>
@@ -332,34 +347,137 @@
return imageData;
}
function embossImageData(imageData, strength = 1) {
const { data, width, height } = imageData;
function embossImageData(imageData) {
const data = imageData.data;
const w = imageData.width;
const h = imageData.height;
debugger;
// Inputs from Konva node
const strength01 = Math.min(
1,
Math.max(0, this.embossStrength?.() ?? 0.5)
); // [0..1]
const whiteLevel01 = Math.min(
1,
Math.max(0, this.embossWhiteLevel?.() ?? 0.5)
); // [0..1]
// Convert string direction to degrees
const directionMap = {
'top-left': 315,
top: 270,
'top-right': 225,
right: 180,
'bottom-right': 135,
bottom: 90,
'bottom-left': 45,
left: 0,
};
const directionDeg =
directionMap[this.embossDirection?.() ?? 'top-left'] ?? 315; // degrees
const blend = !!(this.embossBlend?.() ?? false);
// Internal mapping:
// - Pixastic "strength" was 0..10; we honor your 0..1 API and scale accordingly.
// - Sobel directional response is roughly in [-1020..1020] for 8-bit luminance; scale to ~±128.
const strength = strength01 * 10;
const bias = whiteLevel01 * 255;
const dirRad = (directionDeg * Math.PI) / 180;
const cx = Math.cos(dirRad);
const cy = Math.sin(dirRad);
const SCALE = (128 / 1020) * strength; // ≈0.1255 * strength
// Precompute luminance (Rec.709)
const src = new Uint8ClampedArray(data); // snapshot
const k = [-2, -1, 0, -1, 1, 1, 0, 1, 2]; // sum = 1
const lum = new Float32Array(w * h);
for (let p = 0, i = 0; i < data.length; i += 4, p++) {
lum[p] = 0.2126 * src[i] + 0.7152 * src[i + 1] + 0.0722 * src[i + 2];
}
const clamp = (v) => (v < 0 ? 0 : v > 255 ? 255 : v);
// Sobel kernels (flattened)
const Gx = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
const Gy = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
// leave a 1px border unchanged for simplicity
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let acc = 0;
// grayscale luminance convolution for the emboss look
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const idx = ((y + ky) * width + (x + kx)) * 4;
const lum =
0.2126 * src[idx] +
0.7152 * src[idx + 1] +
0.0722 * src[idx + 2];
acc += lum * k[(ky + 1) * 3 + (kx + 1)];
}
// neighbor offsets around center pixel in lum space
const OFF = [-w - 1, -w, -w + 1, -1, 0, 1, w - 1, w, w + 1];
// Helpers
const clamp8 = (v) => (v < 0 ? 0 : v > 255 ? 255 : v);
// Process: leave a 1px border unchanged (faster/cleaner)
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const p = y * w + x;
// Directional derivative = (cosθ * Gx + sinθ * Gy) • neighborhood(lum)
let sx = 0,
sy = 0;
// unroll loop for speed
sx += lum[p + OFF[0]] * Gx[0];
sy += lum[p + OFF[0]] * Gy[0];
sx += lum[p + OFF[1]] * Gx[1];
sy += lum[p + OFF[1]] * Gy[1];
sx += lum[p + OFF[2]] * Gx[2];
sy += lum[p + OFF[2]] * Gy[2];
sx += lum[p + OFF[3]] * Gx[3];
sy += lum[p + OFF[3]] * Gy[3];
// center has 0 weights in both Sobel masks; can skip if desired
sx += lum[p + OFF[5]] * Gx[5];
sy += lum[p + OFF[5]] * Gy[5];
sx += lum[p + OFF[6]] * Gx[6];
sy += lum[p + OFF[6]] * Gy[6];
sx += lum[p + OFF[7]] * Gx[7];
sy += lum[p + OFF[7]] * Gy[7];
sx += lum[p + OFF[8]] * Gx[8];
sy += lum[p + OFF[8]] * Gy[8];
const r = cx * sx + cy * sy; // directional response
const outGray = clamp8(bias + r * SCALE); // biased, scaled, clamped
const o = p * 4;
if (blend) {
// Add the emboss "relief" around chosen bias to original RGB
const delta = outGray - bias; // symmetric around whiteLevel
data[o] = clamp8(src[o] + delta);
data[o + 1] = clamp8(src[o + 1] + delta);
data[o + 2] = clamp8(src[o + 2] + delta);
data[o + 3] = src[o + 3];
} else {
// Grayscale embossed output
data[o] = data[o + 1] = data[o + 2] = outGray;
data[o + 3] = src[o + 3];
}
const out = clamp(acc * strength + 128); // bias to mid-gray
const o = (y * width + x) * 4;
data[o] = data[o + 1] = data[o + 2] = out;
data[o + 3] = src[o + 3]; // keep alpha
}
}
// Copy border (untouched) to keep edges clean
// top & bottom rows
for (let x = 0; x < w; x++) {
let oTop = x * 4,
oBot = ((h - 1) * w + x) * 4;
data[oTop] = src[oTop];
data[oTop + 1] = src[oTop + 1];
data[oTop + 2] = src[oTop + 2];
data[oTop + 3] = src[oTop + 3];
data[oBot] = src[oBot];
data[oBot + 1] = src[oBot + 1];
data[oBot + 2] = src[oBot + 2];
data[oBot + 3] = src[oBot + 3];
}
// left & right columns
for (let y = 1; y < h - 1; y++) {
let oL = y * w * 4,
oR = (y * w + (w - 1)) * 4;
data[oL] = src[oL];
data[oL + 1] = src[oL + 1];
data[oL + 2] = src[oL + 2];
data[oL + 3] = src[oL + 3];
data[oR] = src[oR];
data[oR + 1] = src[oR + 1];
data[oR + 2] = src[oR + 2];
data[oR + 3] = src[oR + 3];
}
return imageData;
}
@@ -421,15 +539,19 @@
draggable: true,
});
customImage.cache({ pixelRatio: 2 });
customImage.filters([
(imageData) =>
embossImageData(
imageData,
parseFloat(
document.getElementById('emboss-strength-custom').value
)
),
]);
customImage.embossStrength(
parseFloat(document.getElementById('emboss-strength-default').value)
);
customImage.embossWhiteLevel(
parseFloat(document.getElementById('emboss-white-default').value)
);
customImage.embossDirection(
document.getElementById('emboss-direction-default').value
);
customImage.embossBlend(
document.getElementById('emboss-blend-default').checked
);
customImage.filters([embossImageData]);
customLayer.add(customImage);
customLayer.draw();
@@ -458,22 +580,32 @@
document.getElementById('emboss-blend-default').checked
);
} else {
// Use custom solarize function for default filter to add threshold control
defaultImage.filters([Konva.Filters.Solarize]);
}
defaultLayer.batchDraw();
updateCustomFilter();
}
function updateCustomFilter() {
console.log(currentCustomFilter);
if (currentCustomFilter === 'emboss') {
customImage.filters([
(imageData) =>
embossImageData(
imageData,
parseFloat(
document.getElementById('emboss-strength-custom').value
)
),
]);
customImage.embossStrength(
parseFloat(
document.getElementById('emboss-strength-default').value
)
);
customImage.embossWhiteLevel(
parseFloat(document.getElementById('emboss-white-default').value)
);
customImage.embossDirection(
document.getElementById('emboss-direction-default').value
);
customImage.embossBlend(
document.getElementById('emboss-blend-default').checked
);
customImage.filters([embossImageData]);
} else {
customImage.filters([
(imageData) =>
@@ -502,6 +634,7 @@
document.getElementById('solarize-controls-default').style.display =
'none';
updateDefaultFilter();
updateCustomFilter();
});
document
@@ -517,6 +650,7 @@
document.getElementById('emboss-controls-default').style.display =
'none';
updateDefaultFilter();
updateCustomFilter();
});
// Custom filter button controls
@@ -556,6 +690,7 @@
'emboss-strength-value-default'
).textContent = e.target.value;
updateDefaultFilter();
updateCustomFilter;
});
document
@@ -564,14 +699,32 @@
document.getElementById('emboss-white-value-default').textContent =
e.target.value;
updateDefaultFilter();
updateCustomFilter;
});
document
.getElementById('emboss-direction-default')
.addEventListener('change', updateDefaultFilter);
.addEventListener('change', () => {
updateDefaultFilter();
updateCustomFilter();
});
document
.getElementById('emboss-blend-default')
.addEventListener('change', updateDefaultFilter);
.addEventListener('change', () => {
updateDefaultFilter();
updateCustomFilter();
});
// Default solarize controls
document
.getElementById('solarize-threshold-default')
.addEventListener('input', (e) => {
document.getElementById(
'solarize-threshold-value-default'
).textContent = e.target.value;
updateDefaultFilter();
updateCustomFilter();
});
// Custom filter controls
document