Animate a frame by using  a transition between main image colors
Tags: engineeringmonday, css, php, design
Published: 2022-02-21 11:34:49

A disclaimer right away - this is not really about animating image frames; it's just animating the background behind the image and adding some padding.

I wanted to draw more attention to some images in a grid of uploaded images in one of my projects. Instead of having a plain _card_ design or having to tweak borders and shadows around those images, I came up with this technique. 

The idea is to extract the main image colors and compose a gradient with transitions between them. This gradient will "circle around" the image making it look more lively and vibrant. 
At the same time, each image will have its own unique animated gradient which, in my opinion, will make the transition between the image, image frame and container background color more seamless. 

To achieve that, main image colors are extracted for each image. This "palette" of colors is used in composing the aforementioned gradient.

A couple of examples:

![Palette 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tcm00ybc0tiee9dkefbi.png)

![Palette 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xajusrbhpzojzjv634b6.png)

![Palette 3](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wjuj8oft3ir8ufz3fv8t.png)

![Palette 4](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rwsutix5e45t71z8q5o5.png)

The code was mainly influenced by this script by Kepler Gelotte:
[Image Color Extract
](http://www.coolphptools.com/color_extract)
Input parameters are path to the **image**, number of **colors** to extract and **delta** - the amount of gap when quantizing color values (1-255). The smaller the delta, the more accurate the color, but also - number of similar colors increases.

```php
<?php

declare(strict_types=1);

namespace App\Service;

class ColorPaletteExtractor
{
    private const PREVIEW_WIDTH = 250;
    private const PREVIEW_HEIGHT = 250;

    public function extractMainImgColors(string $imagePath, int $colorsCount, int $delta): array
    {
        $halfDelta = 0;
        if ($delta > 2) {
            $halfDelta = $delta / 2 - 1;
        }

        $size = getimagesize($imagePath);
        $scale = 1;
        if ($size[0] > 0) {
            $scale = min(self::PREVIEW_WIDTH / $size[0], self::PREVIEW_HEIGHT / $size[1]);
        }

        $width = (int) $size[0];
        $height = (int) $size[1];
        if ($scale < 1) {
            $width = (int) floor($scale * $size[0]);
            $height = (int) floor($scale * $size[1]);
        }

        $imageResized = imagecreatetruecolor($width, $height);

        $imageType = $size[2];

        $imageOriginal = null;
        if (IMG_JPEG === $imageType) {
            $imageOriginal = imagecreatefromjpeg($imagePath);
        }
        if (IMG_GIF === $imageType) {
            $imageOriginal = imagecreatefromgif($imagePath);
        }
        if (IMG_PNG === $imageType) {
            $imageOriginal = imagecreatefrompng($imagePath);
        }

        imagecopyresampled($imageResized, $imageOriginal, 0, 0, 0, 0, $width, $height, $size[0], $size[1]);

        $img = $imageResized;

        $imgWidth = imagesx($img);
        $imgHeight = imagesy($img);

        $totalPixelCount = 0;
        $hexArray = [];
        for ($y = 0; $y < $imgHeight; $y++) {
            for ($x = 0; $x < $imgWidth; $x++) {
                $totalPixelCount++;

                $index = imagecolorat($img, $x, $y);
                $colors = imagecolorsforindex($img, $index);

                if ($delta > 1) {
                    $colors['red'] = intval((($colors['red']) + $halfDelta) / $delta) * $delta;
                    $colors['green'] = intval((($colors['green']) + $halfDelta) / $delta) * $delta;
                    $colors['blue'] = intval((($colors['blue']) + $halfDelta) / $delta) * $delta;

                    if ($colors['red'] >= 256) {
                        $colors['red'] = 255;
                    }
                    if ($colors['green'] >= 256) {
                        $colors['green'] = 255;
                    }
                    if ($colors['blue'] >= 256) {
                        $colors['blue'] = 255;
                    }
                }

                $hex = substr('0' . dechex($colors['red']), -2)
                    . substr('0' . dechex($colors['green']), -2)
                    . substr('0' . dechex($colors['blue']), -2);

                if (!isset($hexArray[$hex])) {
                    $hexArray[$hex] = 0;
                }
                $hexArray[$hex]++;
            }
        }

        // Reduce gradient colors
        arsort($hexArray, SORT_NUMERIC);

        $gradients = [];
        foreach ($hexArray as $hex => $num) {
            if (!isset($gradients[$hex])) {
                $newHexValue = $this->findAdjacent((string) $hex, $gradients, $delta);
                $gradients[$hex] = $newHexValue;
            } else {
                $newHexValue = $gradients[$hex];
            }

            if ($hex != $newHexValue) {
                $hexArray[$hex] = 0;
                $hexArray[$newHexValue] += $num;
            }
        }

        // Reduce brightness variations
        arsort($hexArray, SORT_NUMERIC);

        $brightness = [];
        foreach ($hexArray as $hex => $num) {
            if (!isset($brightness[$hex])) {
                $newHexValue = $this->normalize((string) $hex, $brightness, $delta);
                $brightness[$hex] = $newHexValue;
            } else {
                $newHexValue = $brightness[$hex];
            }

            if ($hex != $newHexValue) {
                $hexArray[$hex] = 0;
                $hexArray[$newHexValue] += $num;
            }
        }

        arsort($hexArray, SORT_NUMERIC);

        // convert counts to percentages
        foreach ($hexArray as $key => $value) {
            $hexArray[$key] = (float) $value / $totalPixelCount;
        }

        if ($colorsCount > 0) {
            return array_slice($hexArray, 0, $colorsCount, true);
        } else {
            return $hexArray;
        }
    }

    private function normalize(string $hex, array $hexArray, int $delta): string
    {
        $lowest = 255;
        $highest = 0;
        $colors['red'] = hexdec(substr($hex, 0, 2));
        $colors['green'] = hexdec(substr($hex, 2, 2));
        $colors['blue'] = hexdec(substr($hex, 4, 2));

        if ($colors['red'] < $lowest) {
            $lowest = $colors['red'];
        }
        if ($colors['green'] < $lowest) {
            $lowest = $colors['green'];
        }
        if ($colors['blue'] < $lowest) {
            $lowest = $colors['blue'];
        }

        if ($colors['red'] > $highest) {
            $highest = $colors['red'];
        }
        if ($colors['green'] > $highest) {
            $highest = $colors['green'];
        }
        if ($colors['blue'] > $highest) {
            $highest = $colors['blue'];
        }

        // Do not normalize white, black, or shades of grey unless low delta
        if ($lowest == $highest) {
            if ($delta > 32) {
                return $hex;
            }

            if ($lowest == 0 || $highest >= (255 - $delta)) {
                return $hex;
            }
        }

        for (; $highest < 256; $lowest += $delta, $highest += $delta) {
            $newHexValue = substr('0' . dechex($colors['red'] - $lowest), -2)
                . substr('0' . dechex($colors['green'] - $lowest), -2)
                . substr('0' . dechex($colors['blue'] - $lowest), -2);

            if (isset($hexArray[$newHexValue])) {
                // same color, different brightness - use it instead
                return $newHexValue;
            }
        }

        return $hex;
    }

    private function findAdjacent(string $hex, array $gradients, int $delta)
    {
        $red = hexdec(substr($hex, 0, 2));
        $green = hexdec(substr($hex, 2, 2));
        $blue = hexdec(substr($hex, 4, 2));

        if ($red > $delta) {
            $newHexValue = substr('0' . dechex($red - $delta), -2)
                . substr('0' . dechex($green), -2)
                . substr('0' . dechex($blue), -2);

            if (isset($gradients[$newHexValue])) {
                return $gradients[$newHexValue];
            }
        }

        if ($green > $delta) {
            $newHexValue = substr('0' . dechex($red), -2)
                . substr('0' . dechex($green - $delta), -2)
                . substr('0' . dechex($blue), -2);

            if (isset($gradients[$newHexValue])) {
                return $gradients[$newHexValue];
            }
        }

        if ($blue > $delta) {
            $newHexValue = substr('0' . dechex($red), -2)
                . substr('0' . dechex($green), -2)
                . substr('0' . dechex($blue - $delta), -2);

            if (isset($gradients[$newHexValue])) {
                return $gradients[$newHexValue];
            }
        }

        if ($red < (255 - $delta)) {
            $newHexValue = substr('0' . dechex($red + $delta), -2)
                . substr('0' . dechex($green), -2)
                . substr('0' . dechex($blue), -2);

            if (isset($gradients[$newHexValue])) {
                return $gradients[$newHexValue];
            }
        }

        if ($green < (255 - $delta)) {
            $newHexValue = substr('0' . dechex($red), -2)
                . substr('0' . dechex($green + $delta), -2)
                . substr('0' . dechex($blue), -2);

            if (isset($gradients[$newHexValue])) {
                return $gradients[$newHexValue];
            }
        }

        if ($blue < (255 - $delta)) {
            $newHexValue = substr('0' . dechex($red), -2)
                . substr('0' . dechex($green), -2)
                . substr('0' . dechex($blue + $delta), -2);

            if (isset($gradients[$newHexValue])) {
                return $gradients[$newHexValue];
            }
        }

        return $hex;
    }
}


```

I was getting the best results when working with 6-10 colors and delta between 20 and 35.


CSS is pretty simple:
```css
.gradient-background-animation {
  background-size: 400% 400% !important;
  animation: BackgroundGradient 10s ease infinite;
}
.card-picture-container {
  padding: 0.875rem; // This value determines how big the "frame" will be
}
.card-picture-container img {
  object-fit: contain;
  max-width: 100%;
}

@keyframes BackgroundGradient {
  0% {
    background-position: 0 50%;
  }
  50% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0 50%;
  }
}
```
You can adjust the layout and the rest of the styling per your liking.

HTML:
```html
<div class="container">
  <div class="card-picture-container gradient-background-animation" 
       style="background-image: linear-gradient(-45deg, #78785a,#5a783c,#3c5a1e,#969678,#b4b4b4,#1e1e1e,#d2d2f0)">
    <picture>
      <img src="/images/source/image.jpeg" alt="">
    </picture>
  </div>
</div>
```
As you can notice, background is added as inline CSS.





Final result (size decreased for preview purposes):
![Grid layout](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7l6on5o7vetj48tkaj72.png)

I've created a Twig extension (filter) and included it in my Symfony project.
```php
<?php

declare(strict_types=1);

namespace App\Twig;

use App\Service\ColorPaletteExtractor;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class GradientExtractorExtension extends AbstractExtension
{
    public function __construct(
        private ColorPaletteExtractor $gradientExtractor,
    ) {
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('get_gradient', [$this, 'getGradient']),
        ];
    }

    public function getGradient(string $imagePath, int $colorsCount, int $delta): string
    {
        $colors = $this->gradientExtractor->extractMainImgColors($imagePath, $colorsCount, $delta);

        $filteredColors = array_filter($colors, fn (float $percentage) => $percentage > 0);

        return join(',', array_map(fn (string $color) => '#' . $color, array_keys($filteredColors)));
    }
}
```

`image-grid.html.twig`
```html
<div class="container">
    {% for image in images %}
        <div class="card-picture-container gradient-background-animation"
             style="background-image: linear-gradient(-45deg, {{ asset(image)|get_gradient(7,30) }}); animation-duration: 8s;">
            <picture>
                <img src="{{ asset(image) }}" alt="">
            </picture>
        </div>
    {% endfor %}
</div>
```


{% codepen https://codepen.io/gh0c/pen/mdqPMoY %}


Photos for testing purposes downloaded from [https://www.parkovihrvatske.hr/parkovi](https://www.parkovihrvatske.hr/parkovi)