Animate a frame by using a transition between main image colors

gh0c

Goran Hrženjak

Posted on February 21, 2022

Animate a frame by using  a transition between main image colors

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

Palette 2

Palette 3

Palette 4

The code was mainly influenced by this script by Kepler Gelotte:
Image 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

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;
    }
}


Enter fullscreen mode Exit fullscreen mode

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

CSS is pretty simple:

.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%;
  }
}
Enter fullscreen mode Exit fullscreen mode

You can adjust the layout and the rest of the styling per your liking.

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>
Enter fullscreen mode Exit fullscreen mode

As you can notice, background is added as inline CSS.

Final result (size decreased for preview purposes):
Grid layout

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

<?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)));
    }
}
Enter fullscreen mode Exit fullscreen mode

image-grid.html.twig

<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>
Enter fullscreen mode Exit fullscreen mode

Photos for testing purposes downloaded from https://www.parkovihrvatske.hr/parkovi

💖 💪 🙅 🚩
gh0c
Goran Hrženjak

Posted on February 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related