Subtract / Composite one image from another in PHP (GDImage and ImageMagick)
Tags: php, gdimage, imagemagick, imagick
Published: 2022-12-14 04:53:54

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z8ao3doexgdmmpte856v.png)

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.

```php
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()
    ];
}
```

Let's break this function down:

\1. We get the dimensions of both images
```php
list($imageWidth, $imageHeight) = getimagesize($imageFile);
list($puzzleWidth, $puzzleHeight) = getimagesize($puzzleFile);
```

\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.
```php
$positionX = rand(20, $outputWidth - $puzzleWidth - 20);
$positionY = rand(20, $outputHeight - $puzzleHeight - 20);
```

\3. We create our first Imagick instance using the background image (+ force PNG as output image format).
```php
$image = new \Imagick($imageFile);
$image->setImageFormat('png');
```

\4. We downsize the image with consideration for the aspect ratio before we crop it to achieve the desired output dimensions.
```php
$imageRatio = $imageWidth / $imageHeight;
$fixedImageHeight = intval(floor($outputWidth / $imageRatio));
$image->resizeImage(
    $outputWidth, 
    $fixedImageHeight, 
    \Imagick::FILTER_POINT, 
    0
);
$image->cropImage($outputWidth, $outputHeight, 0, 0);
```

\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.
```php
$puzzle = new \Imagick($puzzleFile);
$puzzle->compositeImage(
    $image, 
    \Imagick::COMPOSITE_ATOP, 
    -$positionX, 
    -$positionY
);
```

\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.
```php
$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);
```

\7. Return the blobs.
```php
return [
    'image' => $image->getImageBlob(),
    'puzzle' => $puzzle->getImageBlob()
];
```

**Example Results:**
![ImageMagick Test-Image 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ac6r4q93sjh1ey9ughvx.png)
![ImageMagick Test-Image 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m0wbkq9vo7syw0hf63t6.png)

## 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:

```php
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
    ];
}
```

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:
```php
$image = imagecreatetruecolor($outputWidth, $outputHeight);
imagefilledrectangle(
    $image,
    0,
    0,
    $outputWidth,
    $outputHeight,
    imagecolorallocate($image, 255, 255, 255)
);
```

\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):

```php
$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
);
```

\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.
```php
$tempPiece = imagecreatefrompng($puzzleFile);
imagealphablending($tempPiece, false);
imagesavealpha($tempPiece, true);
imagecopyresampled(
    $image,
    $tempPiece,
    $positionX,
    $positionY,
    0, 0,
    $puzzleWidth,
    $puzzleHeight,
    $puzzleWidth,
    $puzzleHeight
);
```

\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:
```php
$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);
    }
}
```

\7. Blobbing & Clean Up
```php
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
];
```

**Example Results:**

![GD Test-Image 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v6cyeeokqx6t40c6x276.png)


![GD Test-Image 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5s6givcbnx2fdu9zdvlz.png)

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!