lovell / sharp

High performance Node.js image processing, the fastest module to resize JPEG, PNG, WebP, AVIF and TIFF images. Uses the libvips library.
https://sharp.pixelplumbing.com
Apache License 2.0
28.55k stars 1.28k forks source link

Poor Image Quality with Rounded Corners Using sharp #4156

Closed dnKaratzas closed 2 weeks ago

dnKaratzas commented 2 weeks ago

Possible bug

Is this a possible bug in a feature of sharp, unrelated to installation?

Are you using the latest version of sharp?

What is the output of running npx envinfo --binaries --system --npmPackages=sharp --npmGlobalPackages=sharp?

  System:
    OS: macOS 14.5
    CPU: (12) arm64 Apple M2 Pro
    Memory: 1.16 GB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.15.0 - /usr/local/bin/node
    npm: 10.7.0 - /usr/local/bin/npm
  npmPackages:
    sharp: ^0.33.4 => 0.33.4 

Does this problem relate to file caching?

Does this problem relate to images appearing to have been rotated by 90 degrees?

What are the steps to reproduce?

  1. Use the below code to generate an image with rounded corners.
  2. Observe the poor quality and non-smooth corners in the generated image.

What is the expected behaviour?

The generated image should have smooth, high-quality rounded corners.

Please provide a minimal, standalone code sample, without other dependencies, that demonstrates this problem

const sharp = require('sharp');
const { Buffer } = require('safe-buffer');

const createImage = async () => {
    const width = 600;
    const height = 200;
    const lightColorHex = "#cccccc"; // Fixed light color
    const darkColorHex = "#333333";  // Fixed dark color
    const lightText = "Light";       // Fixed light text
    const darkText = "Dark";         // Fixed dark text
    const radius = 12;               // Fixed radius
    const lightTextColor = "#000000"; // Fixed light text color
    const darkTextColor = "#FFFFFF";  // Fixed dark text color

    // Create the base SVG with the gradient and border
    const svgImage = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
        <defs>
            <style type="text/css">
                <![CDATA[
                @import url('https://fonts.googleapis.com/css2?family=Barlow:wght@400;700&display=swap');
                .barlow-font { font-family: 'Barlow', sans-serif; }
                ]]>
            </style>
            <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
                <stop offset="0%" style="stop-color:${lightColorHex};stop-opacity:1" />
                <stop offset="50%" style="stop-color:${lightColorHex};stop-opacity:1" />
                <stop offset="50%" style="stop-color:${darkColorHex};stop-opacity:1" />
                <stop offset="100%" style="stop-color:${darkColorHex};stop-opacity:1" />
            </linearGradient>
        </defs>
        <rect x="0" y="0" width="100%" height="100%" fill="url(#grad1)" rx="${radius}" ry="${radius}" />
        <text x="25%" y="45%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${lightTextColor}">${lightText}</text>
        <text x="25%" y="65%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${lightTextColor}">${lightColorHex}</text>
        <text x="75%" y="45%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${darkTextColor}">${darkText}</text>
        <text x="75%" y="65%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${darkTextColor}">${darkColorHex}</text>
    </svg>`;

    const maskSvg = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
        <rect x="0" y="0" width="100%" height="100%" rx="${radius}" ry="${radius}" fill="white"/>
    </svg>`;

    const buffer = Buffer.from(svgImage);
    const maskBuffer = Buffer.from(maskSvg);

    // Composite the base SVG and the mask SVG
    return sharp(buffer)
        .composite([{ input: maskBuffer, blend: 'dest-in' }])
        .png({ quality: 100 }) // Ensure maximum quality
        .toBuffer();
};

createImage().then((buffer) => {
    const fs = require('fs');
    fs.writeFileSync('output.png', buffer);
}).catch((err) => {
    console.error(err);
});

Please provide sample image(s) that help explain this problem

image

image
lovell commented 2 weeks ago
    .png({ quality: 100 }) // Ensure maximum quality

Did you see https://sharp.pixelplumbing.com/api-output#png ?

[options.quality] use the lowest number of colours needed to achieve given quality, sets palette to true

When you provide a value for quality you are choosing to use lossy, palette-based PNG output. If you want lossless PNG output, do not provide a quality value.

dnKaratzas commented 2 weeks ago

Hey @lovell thanks for your fast response. I removed the quality and the result is the same

image

lovell commented 2 weeks ago

Are you referring to the aliasing? Did you see the shape-rendering property?

dnKaratzas commented 2 weeks ago

Updated my code with your suggestion:

const svgImage = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision">
        <defs>
            <style type="text/css">
                <![CDATA[
                @import url('https://fonts.googleapis.com/css2?family=Barlow:wght@400;700&display=swap');
                .barlow-font { font-family: 'Barlow', sans-serif; }
                ]]>
            </style>
            <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
                <stop offset="0%" style="stop-color:${lightColorHex};stop-opacity:1" />
                <stop offset="50%" style="stop-color:${lightColorHex};stop-opacity:1" />
                <stop offset="50%" style="stop-color:${darkColorHex};stop-opacity:1" />
                <stop offset="100%" style="stop-color:${darkColorHex};stop-opacity:1" />
            </linearGradient>
        </defs>
        <rect x="0" y="0" width="100%" height="100%" fill="url(#grad1)" rx="${radius}" ry="${radius}" shape-rendering="geometricPrecision" />
        <text x="25%" y="45%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${lightTextColor}" shape-rendering="geometricPrecision">${lightText}</text>
        <text x="25%" y="65%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${lightTextColor}" shape-rendering="geometricPrecision">${lightColorHex}</text>
        <text x="75%" y="45%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${darkTextColor}" shape-rendering="geometricPrecision">${darkText}</text>
        <text x="75%" y="65%" font-size="20" class="barlow-font" dominant-baseline="middle" text-anchor="middle" fill="${darkColorHex}" shape-rendering="geometricPrecision">${darkColorHex}</text>
    </svg>`;

    const maskSvg = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision">
        <rect x="0" y="0" width="100%" height="100%" rx="${radius}" ry="${radius}" fill="white"/>
    </svg>`;

But unfortunately same result

lovell commented 2 weeks ago

I think geometricPrecision is usually the default. A value of crispEdges will increase aliasing, which is my best guess as to what you're looking to achieve.

https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/shape-rendering#crispedges

dnKaratzas commented 2 weeks ago

Changed to crispEdges the corners slightly changed but still are bad both text and corners :'(

image
lovell commented 2 weeks ago

I'm unsure what your expected output is here, as this is how a vector SVG is rasterised to a bitmap PNG.

Perhaps you are comparing an SVG rendered in a web browser on an retina display to the output of a SVG rasterised to PNG? If so, you are not comparing like with like - you'll need to double or triple the output dimensions to begin to make a fair comparison.

dnKaratzas commented 2 weeks ago

@lovell you are right its the Retina thing! thank you