Animate a frame by using a transition between main image colors
Goran Hrženjak
Posted on February 21, 2022
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:
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;
}
}
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%;
}
}
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>
As you can notice, background is added as inline CSS.
Final result (size decreased for preview purposes):
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)));
}
}
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>
Photos for testing purposes downloaded from https://www.parkovihrvatske.hr/parkovi
Posted on February 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.