DEV Community

Sam
Sam

Posted on

Subtract / Composite one image from another in PHP (GDImage and ImageMagick)

A new day a new interesting task / riddle to be solved: I've two pictures, a simple background image in any size and a smaller puzzle piece. The second one (provided as PNG with alpha channel) should be placed randomly on the first one (JPEG or PNG without alpha channel). So far so good, but now the exciting part begins: This composition must result in 2 new images: 1. the background image where the puzzle piece has been "cut out" or shown heavily darken (with care about the alpha channel, of course) and 2. the puzzle piece which took over the correspondig part of the background image.

Image description

Well, that's not really difficult when you're already familiar with ImageMagick. Thus, in order to increase the fun and since using PHP was the requirement (for this real project) anyway, I added GDImage as fallback.

Let's start!

1. The easy ImageMagick (imagick) way

Let's handle this task as basic as possible by writing a function which supports 4 arguments: The source background and puzzle piece
images as well as the desired output width and height.

function imagickPuzzle(
  string $imageFile, 
  string $puzzleFile, 
  int $outputWidth, 
  int $outputHeight
): array {
    list($imageWidth, $imageHeight) = getimagesize($imageFile);
    list($puzzleWidth, $puzzleHeight) = getimagesize($puzzleFile);

    // Get random position
    $positionX = rand(20, $outputWidth - $puzzleWidth - 20);
    $positionY = rand(20, $outputHeight - $puzzleHeight - 20);

    // Create main image
    $image = new \Imagick($imageFile);
    $image->setImageFormat('png');

    // Downsize main image to output size
    $imageRatio = $imageWidth / $imageHeight;
    $fixedImageHeight = intval(floor($outputWidth / $imageRatio));
    $image->resizeImage(
        $outputWidth, 
        $fixedImageHeight, 
        \Imagick::FILTER_POINT, 
        0
    );
    $image->cropImage($outputWidth, $outputHeight, 0, 0);

    // Create and composite puzzle image
    $puzzle = new \Imagick($puzzleFile);
    $puzzle->compositeImage(
        $image, 
        \Imagick::COMPOSITE_ATOP, 
        -$positionX, 
        -$positionY
    );

    // Extract and composite puzzle from main image
    $extractPuzzle = new \Imagick($puzzleFile);
    $extractPuzzle->transparentPaintImage('black', 0.8, 100, false);
    $drawer = new \ImagickDraw();
    $drawer->composite(
        \Imagick::COMPOSITE_DARKEN, 
        $positionX, 
        $positionY, 
        $puzzleWidth, 
        $puzzleHeight, 
        $extractPuzzle
    );
    $image->drawImage($drawer);

    // Return Blobs
    return [
        'image' => $image->getImageBlob(),
        'puzzle' => $puzzle->getImageBlob()
    ];
}
Enter fullscreen mode Exit fullscreen mode

Let's break this function down:

\1. We get the dimensions of both images

list($imageWidth, $imageHeight) = getimagesize($imageFile);
list($puzzleWidth, $puzzleHeight) = getimagesize($puzzleFile);
Enter fullscreen mode Exit fullscreen mode

\2. We calculate a random X and Y position of the puzzle image by considering the dimensions of both files + adding a virtual border (of 20px) to prevent placing the puzzle piece directly on the edge of the image.

$positionX = rand(20, $outputWidth - $puzzleWidth - 20);
$positionY = rand(20, $outputHeight - $puzzleHeight - 20);
Enter fullscreen mode Exit fullscreen mode

\3. We create our first Imagick instance using the background image (+ force PNG as output image format).

$image = new \Imagick($imageFile);
$image->setImageFormat('png');
Enter fullscreen mode Exit fullscreen mode

\4. We downsize the image with consideration for the aspect ratio before we crop it to achieve the desired output dimensions.

$imageRatio = $imageWidth / $imageHeight;
$fixedImageHeight = intval(floor($outputWidth / $imageRatio));
$image->resizeImage(
    $outputWidth, 
    $fixedImageHeight, 
    \Imagick::FILTER_POINT, 
    0
);
$image->cropImage($outputWidth, $outputHeight, 0, 0);
Enter fullscreen mode Exit fullscreen mode

\5. We can directly finish our puzzle piece first by creating a new Imagick instance with the desired source file and placing the cropped background image, using the "ATop" composite mode as well as the pre-calculated positions, on top.

$puzzle = new \Imagick($puzzleFile);
$puzzle->compositeImage(
    $image, 
    \Imagick::COMPOSITE_ATOP, 
    -$positionX, 
    -$positionY
);
Enter fullscreen mode Exit fullscreen mode

\6. We create a third Imagick instance, using the puzzle piece as source file, and turn the black color into black color with 20% alpha transparency (optional step). Now, let's create a new ImagickDraw instance and composite the new Imagick instance, using "Darken" as composite mode, on the previously calculated position. Finally, draw the drawer on the original image.

$extractPuzzle = new \Imagick($puzzleFile);
$extractPuzzle->transparentPaintImage('black', 0.8, 100, false);
$drawer = new \ImagickDraw();
$drawer->composite(
    \Imagick::COMPOSITE_DARKEN, 
    $positionX, 
    $positionY, 
    $puzzleWidth, 
    $puzzleHeight, 
    $extractPuzzle
);
$image->drawImage($drawer);
Enter fullscreen mode Exit fullscreen mode

\7. Return the blobs.

return [
    'image' => $image->getImageBlob(),
    'puzzle' => $puzzle->getImageBlob()
];
Enter fullscreen mode Exit fullscreen mode

Example Results:
ImageMagick Test-Image 1
ImageMagick Test-Image 2

2. The not-so-easy GDImage (gd) way

Our ImageMagick solution works pretty good so let's try to rewrite these almost 30 lines with GD instead. I mean... how hard can it be? And just 8 nerve-wracking hours later I came up with the following function:

function gdPuzzle(
    string $imageFile, 
    string $puzzleFile, 
    int $outputWidth, 
    int $outputHeight
): array {
    list($imageWidth, $imageHeight) = getimagesize($imageFile);
    list($puzzleWidth, $puzzleHeight) = getimagesize($puzzleFile);

    // Get random position
    $positionX = rand(20, $outputWidth - $puzzleWidth - 20);
    $positionY = rand(20, $outputHeight - $puzzleHeight - 20);

    // Create main image
    $image = imagecreatetruecolor($outputWidth, $outputHeight);
    imagefilledrectangle(
        $image, 
        0, 
        0, 
        $outputWidth, 
        $outputHeight, 
        imagecolorallocate($image, 255, 255, 255)
    );

    // Downsize main image to output size
    $imageRatio = $imageWidth / $imageHeight;
    $fixedImageHeight = intval(floor($outputWidth / $imageRatio));

    $temp = imagecreatefrompng($imageFile);
    $cropped = imagecreatetruecolor($outputWidth, $outputHeight);
    imagecopyresampled(
        $cropped, 
        $temp, 
        0, 0, 0, 0, 
        $outputWidth, 
        $fixedImageHeight, 
        $imageWidth, 
        $imageHeight
    );
    imagecrop(
        $cropped, 
        [
            'x' => 0, 
            'y' => 0, 
            'width' => $outputWidth, 
            'height' => $outputHeight
        ]
    );
    imagecopymerge(
        $image, 
        $cropped, 
        0, 0, 0, 0, 
        $outputWidth, 
        $outputHeight, 
        100
    );

    // Exclude Piece
    $tempPiece = imagecreatefrompng($puzzleFile);
    imagealphablending($tempPiece, false);
    imagesavealpha($tempPiece, true);
    imagecopyresampled(
        $image, 
        $tempPiece, 
        $positionX, 
        $positionY, 
        0, 0, 
        $puzzleWidth, 
        $puzzleHeight, 
        $puzzleWidth, 
        $puzzleHeight
    );

    // Piece Image
    $puzzle = imagecreatetruecolor($puzzleWidth, $puzzleHeight);
    imagealphablending($puzzle, false);
    imagesavealpha($puzzle, true);
    imagecopyresampled(
        $puzzle, 
        $cropped, 
        0, 0, 
        $positionX, 
        $positionY, 
        $puzzleWidth, 
        $puzzleHeight, 
        $puzzleWidth, 
        $puzzleHeight
    );

    // Copy alpha channels from puzzle piece to cropped source image
    for ($i = 0; $i < $puzzleWidth; $i++) {
        for ($j = 0; $j < $puzzleHeight; $j++) {
            $alpha = imagecolorsforindex(
                $tempPiece, 
                imagecolorat($tempPiece, $i, $j)
            )['alpha'];
            $original = array_values(
                imagecolorsforindex(
                    $puzzle, 
                    imagecolorat($puzzle, $i, $j)
                )
            );
            $edited = imagecolorallocatealpha(
                $puzzle, 
                $original[0], 
                $original[1], 
                $original[2], 
                $alpha ?? 0
            );
            imagesetpixel($puzzle, $i, $j, $edited);
        }
    }

    // Get BLOBs
    ob_start();
    imagepng($image, null);
    $imageBlob = ob_get_clean();

    ob_start();
    imagepng($puzzle, null);
    $puzzleBlob = ob_get_clean();

    // Destroy Images & Return
    imagedestroy($image);
    imagedestroy($temp);
    imagedestroy($cropped);
    imagedestroy($puzzle);
    imagedestroy($tempPiece);
    return [
        'image' => $imageBlob,
        'puzzle' => $puzzleBlob
    ];
}
Enter fullscreen mode Exit fullscreen mode

Yes, this looks like a mess, and I'm pretty sure you can further clean this up somehow... but it works. Let's break it down (the first and second part are the exact same as above, so we start with \3.).

\3. We create the main image in the desired output dimensions and draw a full-sized rectangle:

$image = imagecreatetruecolor($outputWidth, $outputHeight);
imagefilledrectangle(
    $image,
    0,
    0,
    $outputWidth,
    $outputHeight,
    imagecolorallocate($image, 255, 255, 255)
);
Enter fullscreen mode Exit fullscreen mode

\4. We downsize the main background image in the same way as on the ImageMagick solution... just in the good old GD way (I'm pretty sure there is a better solution for this):

$imageRatio = $imageWidth / $imageHeight;
$fixedImageHeight = intval(floor($outputWidth / $imageRatio));

$temp = imagecreatefrompng($imageFile);
$cropped = imagecreatetruecolor($outputWidth, $outputHeight);
imagecopyresampled(
    $cropped,
    $temp,
    0, 0, 0, 0,
    $outputWidth,
    $fixedImageHeight,
    $imageWidth,
    $imageHeight
);
imagecrop(
    $cropped,
    [
        'x' => 0,
        'y' => 0,
        'width' => $outputWidth,
        'height' => $outputHeight
    ]
);
imagecopymerge(
    $image,
    $cropped,
    0, 0, 0, 0,
    $outputWidth,
    $outputHeight,
    100
);
Enter fullscreen mode Exit fullscreen mode

\5. Now let us "cut out" the puzzle piece from the image above, by creating another GD resource with the puzzle source image and with alpha-blending and alpha-channel enabled. Last but not least, copy the puzzle piece onto the background image using the previously calculated position. Unfortunately, I couldn't achieve some alpha transparency yet, as soon as I could find a working solution I'll add it here.

$tempPiece = imagecreatefrompng($puzzleFile);
imagealphablending($tempPiece, false);
imagesavealpha($tempPiece, true);
imagecopyresampled(
    $image,
    $tempPiece,
    $positionX,
    $positionY,
    0, 0,
    $puzzleWidth,
    $puzzleHeight,
    $puzzleWidth,
    $puzzleHeight
);
Enter fullscreen mode Exit fullscreen mode

\6. Now it gets complicated: We wanna create the single puzzle piece image, as already done with ImageMagick, but GDImage does not know much about composition modes or keeping alpha-channels alive (I guess?). However, GD has some kind of overlay effect option, called imagelayereffect, but no matter how I tried to do something with it, it only got worse. Thus, after hours of playing around with the whole GD library, I decided to set EACH PIXEL on my own. That's far away from being a good solution, but... it works:

$puzzle = imagecreatetruecolor($puzzleWidth, $puzzleHeight);
imagealphablending($puzzle, false);
imagesavealpha($puzzle, true);
imagecopyresampled(
    $puzzle,
    $cropped,
    0, 0,
    $positionX,
    $positionY,
    $puzzleWidth,
    $puzzleHeight,
    $puzzleWidth,
    $puzzleHeight
);

// Copy alpha channels from puzzle piece to cropped source image
for ($i = 0; $i < $puzzleWidth; $i++) {
    for ($j = 0; $j < $puzzleHeight; $j++) {
        $alpha = imagecolorsforindex(
            $tempPiece,
            imagecolorat($tempPiece, $i, $j)
        )['alpha'];
        $original = array_values(
            imagecolorsforindex(
                $puzzle,
                imagecolorat($puzzle, $i, $j)
            )
        );
        $edited = imagecolorallocatealpha(
            $puzzle,
            $original[0],
            $original[1],
            $original[2],
            $alpha ?? 0
        );
        imagesetpixel($puzzle, $i, $j, $edited);
    }
}
Enter fullscreen mode Exit fullscreen mode

\7. Blobbing & Clean Up

ob_start();
imagepng($image, null);
$imageBlob = ob_get_clean();

ob_start();
imagepng($puzzle, null);
$puzzleBlob = ob_get_clean();

imagedestroy($image);
imagedestroy($temp);
imagedestroy($cropped);
imagedestroy($puzzle);
imagedestroy($tempPiece);
return [
    'image' => $imageBlob,
    'puzzle' => $puzzleBlob
];
Enter fullscreen mode Exit fullscreen mode

Example Results:

GD Test-Image 1

GD Test-Image 2

Job done. Noticed the difference? GDImage has a neatless cut out, while you see a small border around the puzzle piece on ImageMagick. I'm pretty sure you can achieve the same on ImageMagick as well, but that was not important for the project anyway.

HOWEVER, if you know or find a better solution, let me know!

Top comments (0)