You can edit the below JavaScript code to customize the image tool.
Apply Changes
async function processImage(originalImg,
centerXPercent = 50,
centerYPercent = 50,
radiusPercent = 30,
refraction = 0.7, // Values < 1.0 cause minification (usual for crystal ball), > 1.0 magnification. 1.0 = no refraction.
zoom = 1.0, // Overall zoom of the image inside the ball. >1 magnifies, <1 minifies.
invertImage = "true", // Whether the image inside the ball is inverted
highlightIntensity = 0.6, // Opacity of the specular highlight (0-1)
highlightXOffsetPercent = -15, // Highlight X offset from ball center, as % of radius
highlightYOffsetPercent = -20, // Highlight Y offset from ball center, as % of radius
highlightSizePercent = 25, // Highlight size, as % of radius
addShading = "true" // Whether to add 3D-like shading to the ball
) {
const W = originalImg.naturalWidth;
const H = originalImg.naturalHeight;
if (W === 0 || H === 0) {
// Handle cases where image might not be loaded or has no dimensions
const emptyCanvas = document.createElement('canvas');
emptyCanvas.width = 1;
emptyCanvas.height = 1;
return emptyCanvas;
}
// Parse parameters to numbers and booleans
const parsedCenterX = W * (parseFloat(String(centerXPercent)) / 100.0);
const parsedCenterY = H * (parseFloat(String(centerYPercent)) / 100.0);
// Ensure radius is at least 1 pixel to avoid division by zero or empty effects
const parsedRadius = Math.max(1, Math.min(W, H) * (parseFloat(String(radiusPercent)) / 100.0));
const parsedRefraction = parseFloat(String(refraction));
const parsedZoom = Math.max(0.01, parseFloat(String(zoom))); // Zoom should not be zero
const shouldInvert = String(invertImage).toLowerCase() === 'true';
const parsedHighlightIntensity = Math.max(0, Math.min(1, parseFloat(String(highlightIntensity))));
const parsedHighlightXOffset = parsedRadius * (parseFloat(String(highlightXOffsetPercent)) / 100.0);
const parsedHighlightYOffset = parsedRadius * (parseFloat(String(highlightYOffsetPercent)) / 100.0);
const parsedHighlightSize = Math.max(0, parsedRadius * (parseFloat(String(highlightSizePercent)) / 100.0));
const shouldAddShading = String(addShading).toLowerCase() === 'true';
// Source canvas for reading original image pixels
const srcCanvas = document.createElement('canvas');
srcCanvas.width = W;
srcCanvas.height = H;
const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
srcCtx.drawImage(originalImg, 0, 0, W, H);
const sourceImageData = srcCtx.getImageData(0, 0, W, H);
const sourceData = sourceImageData.data;
// Destination canvas for drawing the result
const destCanvas = document.createElement('canvas');
destCanvas.width = W;
destCanvas.height = H;
const destCtx = destCanvas.getContext('2d');
const outputImageData = destCtx.createImageData(W, H);
const outputData = outputImageData.data;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = (y * W + x) * 4;
const dX = x - parsedCenterX; // Vector from current pixel to ball center X
const dY = y - parsedCenterY; // Vector from current pixel to ball center Y
const distSq = dX * dX + dY * dY; // Squared distance from center
if (distSq <= parsedRadius * parsedRadius) {
// Pixel is inside the ball
const distance = Math.sqrt(distSq);
// Refraction calculation (based on a common simplified sphere mapping model)
// z_norm_on_sphere_surface: normalized z-coordinate on the sphere (0 at edge, 1 at center of ball's hemisphere)
const z_norm_on_sphere_surface = Math.sqrt(Math.max(0, parsedRadius * parsedRadius - distSq)) / parsedRadius;
// refractionFactor determines magnitude and direction of pixel displacement
// (1.0 / parsedRefraction - 1.0):
// If parsedRefraction = 1.0 (no optical refraction), factor = 0, so no displacement.
// If parsedRefraction < 1.0 (e.g., 0.7, typical for minifying crystal ball), factor is positive.
// If parsedRefraction > 1.0 (e.g., 1.5, for a magnifying lens effect), factor is negative.
const refractionFactor = (1.0 / parsedRefraction - 1.0);
// (z_norm_on_sphere_surface - 1.0): varies from 0 (at center) to -1 (at edge).
// xShift/yShift: how much to shift the source coordinates.
// This makes displacement 0 at center, max towards edges.
const xShift = dX * (z_norm_on_sphere_surface - 1.0) * refractionFactor;
const yShift = dY * (z_norm_on_sphere_surface - 1.0) * refractionFactor;
let displacedX = dX + xShift;
let displacedY = dY + yShift;
// Apply zoom
displacedX /= parsedZoom;
displacedY /= parsedZoom;
let sourceX, sourceY;
if (shouldInvert) {
sourceX = parsedCenterX - displacedX;
sourceY = parsedCenterY - displacedY;
} else {
sourceX = parsedCenterX + displacedX;
sourceY = parsedCenterY + displacedY;
}
// Clamp source coordinates to image bounds
sourceX = Math.max(0, Math.min(W - 1, sourceX));
sourceY = Math.max(0, Math.min(H - 1, sourceY));
// Get pixel from source (using nearest neighbor)
const srcPixelIdx = (Math.floor(sourceY) * W + Math.floor(sourceX)) * 4;
outputData[idx] = sourceData[srcPixelIdx];
outputData[idx + 1] = sourceData[srcPixelIdx + 1];
outputData[idx + 2] = sourceData[srcPixelIdx + 2];
outputData[idx + 3] = sourceData[srcPixelIdx + 3];
} else {
// Pixel is outside the ball, copy original
outputData[idx] = sourceData[idx];
outputData[idx + 1] = sourceData[idx + 1];
outputData[idx + 2] = sourceData[idx + 2];
outputData[idx + 3] = sourceData[idx + 3];
}
}
}
destCtx.putImageData(outputImageData, 0, 0);
// Add shading and highlights if the ball is visible
if (parsedRadius > 0) {
if (shouldAddShading) {
// Inner shadow for 3D effect (darkens edges of the ball)
// Gradient from transparent center to dark semi-transparent edge
const shadowGradient = destCtx.createRadialGradient(
parsedCenterX, parsedCenterY, parsedRadius * 0.7, // Inner circle (more transparent)
parsedCenterX, parsedCenterY, parsedRadius // Outer circle (darker edge)
);
shadowGradient.addColorStop(0, 'rgba(0,0,0,0)');
shadowGradient.addColorStop(0.8, 'rgba(0,0,0,0.1)');
shadowGradient.addColorStop(1, 'rgba(0,0,0,0.3)');
destCtx.fillStyle = shadowGradient;
destCtx.beginPath();
destCtx.arc(parsedCenterX, parsedCenterY, parsedRadius, 0, 2 * Math.PI);
destCtx.fill();
// Rim light (subtle brightening on edges, typically opposite to main highlight)
// Position gradient center slightly towards the main light source, making opposite edge brighter.
const rimLightCenterX = parsedCenterX + parsedHighlightXOffset * 0.6;
const rimLightCenterY = parsedCenterY + parsedHighlightYOffset * 0.6;
const rimGradient = destCtx.createRadialGradient(
rimLightCenterX, rimLightCenterY, parsedRadius * 0.5, // Inner, mostly transparent
parsedCenterX, parsedCenterY, parsedRadius // Outer, where light catches edge
);
const baseRimOpacity = parsedHighlightIntensity * 0.4;
rimGradient.addColorStop(0, 'rgba(255,255,255,0)');
rimGradient.addColorStop(0.7, `rgba(255,255,255,${baseRimOpacity * 0.5})`);
rimGradient.addColorStop(1, `rgba(255,255,255,${baseRimOpacity})`);
destCtx.fillStyle = rimGradient;
destCtx.fill(); // Uses the same arc path as the shadow gradient
}
if (parsedHighlightIntensity > 0 && parsedHighlightSize > 0) {
// Specular highlight
const highlightX = parsedCenterX + parsedHighlightXOffset;
const highlightY = parsedCenterY + parsedHighlightYOffset;
destCtx.fillStyle = `rgba(255, 255, 255, ${parsedHighlightIntensity})`;
destCtx.beginPath();
// Elliptical highlight for a more natural look
destCtx.ellipse(highlightX, highlightY, parsedHighlightSize, parsedHighlightSize * 0.8, 0, 0, 2 * Math.PI);
destCtx.fill();
}
}
return destCanvas;
}
Apply Changes