Skip to content

[6.x] Image brightness detection#13975

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

[6.x] Image brightness detection#13975
jaygeorge wants to merge 49 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. 🎉

@godismyjudge95
Copy link
Contributor

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.

If you have 3-4 color options instead of just two you could do the background color contrast comparison I mentioned above. It could then select the best contrasting background color out of the lot. Not sure how to pass this to the frontend though.

Just an idea.

jaygeorge and others added 29 commits March 6, 2026 13:16
… it", it's too noisey, Jason's right

This reverts commit 26088ec.
…. Browser preference persisted. Slider to right of toggle in header.
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