Skip to content

[6.x] Image brightness detection#13975

Open
jaygeorge wants to merge 20 commits into6.xfrom
image-brightness-detection
Open

[6.x] Image brightness detection#13975
jaygeorge wants to merge 20 commits into6.xfrom
image-brightness-detection

Conversation

@jaygeorge
Copy link
Contributor

@jaygeorge jaygeorge commented Feb 17, 2026

Description of the Problem

As discussed in #13927, transparent assets can be primarily light or dark.

Under certain conditions, it can be difficult to discern an image against a checkerboard background—for example, when the logo is white or black.

What this PR Does

  • Instead of trying to pick a light or dark checkerboard, this PR detects if an image/SVG is predominantly dark or light by sampling pixels using built-in browser APIs (Canvas 2D + Image). This is computed server-side on upload using Intervention Image (works with both GD and Imagick drivers) and stored in the asset's .meta/*.yaml file alongside existing metadata like width, height, and duration.
  • Rounded the corners of the checkerbox background slightly while I was here, I think it looks prettier.
  • Closes Checkerboard background in asset editor should always be dark #13927

(Updated since initial PR):

  • Light/Dark badge for the image shows on the editor now
    2026-02-19 at 10 15 49@2x
  • Remove checkerboard on hover, now that we're typically using a toggle button to show transparency
    2026-02-19 at 10 46 29@2x
  • Added a "show transparency" button for asset field modals too
  • Add some fallbacks for understanding whether SVGs are light or dark
    • when the Imagick branch finds zero non-transparent pixels (meaning it couldn't meaningfully rasterize the SVG), it now falls through to the XML color-parsing fallback instead of returning null. Same for when Imagick throws an exception.

How it works

  • On upload (single, bulk, reupload, CLI), the image is scaled to 64px and sampled for average luminance. The result (light or dark) is written to meta as tone.
  • SVG tone detection uses native Imagick (when the PHP extension is available) with a transparent background, so it measures actual content rather than a white canvas. If Imagick isn't installed, SVGs get tone: null.
  • No extra endpoints, no browser-side processing, no JS compilation required -- it piggybacks on the existing generateMeta() pipeline.

Exposed to developers in Antlers templates

  • {{ tone }} -- returns light, dark, or null
  • {{ is_light_tone }} / {{ is_dark_tone }} -- boolean helpers

Works the same way as focus_css and other meta-driven asset values.

Before

2026-02-17 at 17 40 26@2x

(and you'd have a similar problem if you had a dark logo with a dark checkerboard background)

After

White and black logos are now much easier to discern:

(ignore the aspect ratio issue here, that's an existing issue that I'll fix separately)

2026-02-17 at 17 33 36@2x

2026-02-17 at 17 37 05@2x

How to Reproduce

  1. Go to /cp/assets and upload a transparent white or black logo

@godismyjudge95
Copy link
Contributor

It might be worthwhile looking into calculating this upon upload and stored in the meta vs on the fly in browser.
I could also see this being useful to expose to devs similar to the focus_css meta for dynamic frontend rendering.

It could be something as simple as:

use Intervention\Image\ImageManagerStatic as Image;

$image = Image::make($path)->resize(1, 1, function ($constraint) {
    $constraint->aspectRatio();
});
$averageColor = $image->pickColor(0, 0); // [R, G, B, A]

// Normalized to 0-1
$meanR = $averageColor[0] / 255;
$meanG = $averageColor[1] / 255;
$meanB = $averageColor[2] / 255;

$luminance = (0.2126 * $meanR) + (0.7152 * $meanG) + (0.0722 * $meanB);
$isBright = $luminance > 0.5;

(not tested AI generated - but looks correct)

@daun
Copy link
Contributor

daun commented Feb 18, 2026

The brightness/luminance would indeed be interesting on the frontend as well, now that everything is glass and backdrop filters :) With the additional benefit of front-loading the calculation at the time of upload.

…r uses the server-provided tone directly, falling back to client-side Canvas detection only for SVGs.
… with a transparent background, so it can skip transparent pixels and only measure the actual content.

This works independently of the user's configured image driver -- if the Imagick PHP extension is installed, SVGs get tone detection; if not, they get null gracefully. Raster image detection is unchanged (still uses Intervention Image).
@jaygeorge
Copy link
Contributor Author

OK, I've given it a go! I've updated the PR description to note what's happening.

{{ asset.tone }} would output the raw value: light when the image is detected as light, dark when it’s detected as dark.

You could use it like this:

{{ if asset:tone == "light" }}
    <div class="preview preview-dark">
{{ elseif asset:tone == "dark" }}
    <div class="preview preview-light">
{{ /if }}
    <img src="{{ asset:url }}" alt="" />
</div>

Or with the booleans:

{{ if asset:is_light_tone }}
    <div class="bg-dark"></div>
{{ elseif asset:is_dark_tone }}
    <div class="bg-light"></div>
{{ /if }}

@jaygeorge jaygeorge requested a review from jasonvarga February 18, 2026 12:30
@daun
Copy link
Contributor

daun commented Feb 18, 2026

This is really nice. Great solution to a tricky situation.

@jasonvarga
Copy link
Member

Did you push up the antlers stuff? I don't see anything.

when the Imagick branch finds zero non-transparent pixels (meaning it couldn't meaningfully rasterize the SVG), it now falls through to the XML color-parsing fallback instead of returning null. Same for when Imagick throws an exception.
@jaygeorge
Copy link
Contributor Author

I added some more things while I was playing:

  • Light/Dark badge for the image shows on the editor now
    2026-02-19 at 10 15 49@2x
  • Remove checkerboard on hover for the asset fieldtype, now that we're typically using a toggle button to show transparency
    2026-02-19 at 10 46 29@2x
  • Added a "show transparency" button for asset field modals too
  • Add some fallbacks for understanding whether SVGs are light or dark
    • when the Imagick branch finds zero non-transparent pixels (meaning it couldn't meaningfully rasterize the SVG), it now falls through to the XML color-parsing fallback instead of returning null. Same for when Imagick throws an exception.

@jaygeorge
Copy link
Contributor Author

@jasonvarga the Antlers syntax should "just work" because we're referencing saved meta data.

Here's some Antlers and the browser rendering it in the background:

2026-02-19 at 12 51 39@2x

jaygeorge and others added 4 commits February 19, 2026 14:42
…Vue file when server generation has not taken place yet
# Conflicts:
#	resources/js/components/assets/Editor/Editor.vue
#	src/Imaging/Attributes.php
Copy link
Member

@jasonvarga jasonvarga left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've resolved the merge conflicts for you.

This looks almost done but something I noticed was that the tone isn't properly picked up for transparent PNGs.

Image

✅ The black HBO logo SVG gets correctly picked up as "dark".
❌ The black HBO logo PNG gets picked up as "light".
❌ The black dog PNG gets picked up as "light".
🤷‍♂️ The golden dog PNG gets correctly picked up as "light", but this might just be by fluke.
✅ JPGs work fine. (not in this screenshot)

@daun
Copy link
Contributor

daun commented Mar 5, 2026

@jasonvarga Are you using gd in your setup by chance? I remember coming across some issue where GD only reads/writes alpha channels between 0 and 127, ignoring anything above.

@godismyjudge95
Copy link
Contributor

I think you could solve the transparent issue by ignoring transparent pixels below a certain threshold. Then for any semi-transparent pixels doing a double comparison of blending the transparent pixels with white and black and comparing to see which yields a higher contrast:

$sum_white = 0.0;
$sum_black = 0.0;
$count = 0;

// Sample ~256 pixels for speed
$step = max(1, (int) ceil(($w * $h) / 256));
$i = 0;

for ($y = 0; $y < $h; $y++) {
    for ($x = 0; $x < $w; $x++) {
        if ($i++ % $step !== 0) {
            continue;
        }

        $color = $image->pickColor($x, $y);

        // Intervention Image v3: alpha is 0.0 (transparent) to 1.0 (opaque)
        $alpha = $color->alpha()->toFloat();  // or ->value() / ->float() depending on exact version

        if ($alpha < 0.02) {  // skip fully/nearly transparent pixels
            continue;
        }

        // Colors are already 0–1 in Intervention v3
        $r = $color->red()->toFloat();
        $g = $color->green()->toFloat();
        $b = $color->blue()->toFloat();

        // Blend against WHITE background (1,1,1)
        $r_white = $r * $alpha + (1 - $alpha) * 1.0;
        $g_white = $g * $alpha + (1 - $alpha) * 1.0;
        $b_white = $b * $alpha + (1 - $alpha) * 1.0;
        $l_white = 0.299 * $r_white + 0.587 * $g_white + 0.114 * $b_white;

        // Blend against BLACK background (0,0,0)
        $r_black = $r * $alpha + (1 - $alpha) * 0.0;
        $g_black = $g * $alpha + (1 - $alpha) * 0.0;
        $b_black = $b * $alpha + (1 - $alpha) * 0.0;
        $l_black = 0.299 * $r_black + 0.587 * $g_black + 0.114 * $b_black;

        $sum_white += $l_white;
        $sum_black += $l_black;
        $count++;
    }
}

if ($count === 0) {
    return null; // fully transparent → no decision possible
}

$avg_white = $sum_white / $count;
$avg_black = $sum_black / $count;

// Option 1: Simple & effective for most logos/icons
// If it looks medium-bright or brighter when on white → recommend dark background
if ($avg_white >= 0.52) {  // ← tune this threshold: 0.5–0.6
    return 'dark';         // better contrast on black
}

return 'light';            // better/safer on white

// Option 2: More explicit contrast comparison (uncomment if you prefer)
// $contrast_white = max($avg_white, 1 - $avg_white);
// $contrast_black = max($avg_black, 1 - $avg_black);
// return ($contrast_black > $contrast_white) ? 'dark' : 'light';

AI generated code - idea is mine :)

@jasonvarga
Copy link
Member

Good call guys. 🎉

@jasonvarga jasonvarga dismissed their stale review March 5, 2026 20:11

Changes were made

@jasonvarga
Copy link
Member

I have a handful of things needing a designers feedback:


Sticking out
In listings, the tone logic makes things stick out. The two "light" images have black backgrounds now. It almost looks like they are "selected". Someone might wonder what's different about those two images.

CleanShot 2026-03-05 at 15 15 34

Opposite behavior in dark mode:

CleanShot 2026-03-05 at 15 15 55

Checkboards always visible
This PR makes the checkerboards always visible. Previously they only became visible on hover. I think it was like that so there'd be much less noise on the page at a glance. For example, this is how it looks currently on the 6.x branch:

CleanShot 2026-03-05 at 15 20 36

Checkerboard & background inconsistency

Looking at my first screenshot above - the golden retriever image is "light" and has transparency, so it gets a black background with a checkerboard. However there are other "light" images, but since they don't have transparency, they still get the white backgrounds.


Tone inconsistency

Looking at the golden retriever image - yes it's correctly considered "light" but it would easily be visible on a white background.


General usefulness

Even with this new light/dark checkerboard logic, it's still arguable whether it's solves the underlying issue. For example, here's the ITV logo in the editor:

CleanShot 2026-03-05 at 15 24 43

The image is considered light because most of it is lightly colored, so it gets a dark checkerboard. But the darker parts of the logo are hard to see.


Examples from other services

I looked at how Dropbox and Box.com handles it. (Arbitrary asset management solutions I happened to have accounts with). They put the images on a solid background. The background (on Dropbox) is a different brightness in dark mode.

If you have an image with the same color as the background, they have the same problem in that you can't see it.

CleanShot 2026-03-05 at 15 37 55 CleanShot 2026-03-05 at 15 38 25 CleanShot 2026-03-05 at 15 38 50 CleanShot 2026-03-05 at 15 39 01

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checkerboard background in asset editor should always be dark

4 participants