Please bookmark this page to avoid losing your image tool!

Image Crystal Ball Filter Effect Tool

(Free & Supports Bulk Upload)

Drag & drop your images here or

The result will appear here...
You can edit the below JavaScript code to customize the image tool.
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;
}

Free Image Tool Creator

Can't find the image tool you're looking for?
Create one based on your own needs now!

Description

The Image Crystal Ball Filter Effect Tool allows users to apply a crystal ball effect to their images. Users can customize various parameters such as the position, size, and optical properties of the ball effect, including refraction and zoom levels. The tool is suitable for creating visually appealing effects for artwork, enhancing photographs, or adding creative touches to social media images. With options for shading and highlights, users can make their crystal ball effect look more realistic and engaging.

Leave a Reply

Your email address will not be published. Required fields are marked *