mirror of
https://github.com/moodle/moodle.git
synced 2025-04-21 16:32:18 +02:00
MDL-82945 AI: Image watermark rendering
When an image is generated using AI a watermark is added. This patch examines the area of the image where the water mark is to be added and changes the font color of the mark to either black or white, so it can be read against the background color.
This commit is contained in:
parent
09e56f2d1a
commit
598c84cec9
@ -81,6 +81,143 @@ class ai_image {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the predominant color of a specific area of the image.
|
||||
*
|
||||
* This method analyzes a rectangular area of the image, calculating the
|
||||
* average color by summing up the red, green, and blue components of all pixels
|
||||
* within the area, and then dividing by the total number of pixels.
|
||||
*
|
||||
* @param int $x X coordinate of the top-left corner of the area.
|
||||
* @param int $y Y coordinate of the top-left corner of the area.
|
||||
* @param int $width Width of the area.
|
||||
* @param int $height Height of the area.
|
||||
* @return array RGB array of the predominant color, with keys 'red', 'green', and 'blue'.
|
||||
*/
|
||||
private function get_predominant_color(int $x, int $y, int $width, int $height): array {
|
||||
// If the width or height is smaller than 10 pixels, sample the entire image.
|
||||
if (imagesx($this->imgobject) < 10 || imagesy($this->imgobject) < 10) {
|
||||
$x = 0;
|
||||
$y = 0;
|
||||
$width = imagesx($this->imgobject);
|
||||
$height = imagesy($this->imgobject);
|
||||
}
|
||||
|
||||
// Initialize variables to accumulate the total red, green, and blue values.
|
||||
$redtotal = $greentotal = $bluetotal = 0;
|
||||
// Initialize a counter for the number of pixels processed.
|
||||
$pixelcount = 0;
|
||||
|
||||
// Iterate over each pixel within the specified area of the image.
|
||||
for ($i = $x; $i < $x + $width; $i++) {
|
||||
for ($j = $y; $j < $y + $height; $j++) {
|
||||
// Retrieve the color index of the current pixel.
|
||||
$rgb = imagecolorat(
|
||||
image: $this->imgobject,
|
||||
x: $i,
|
||||
y: $j);
|
||||
// Extract the red component (shift the bits 16 places to the right and mask the rest).
|
||||
$red = ($rgb >> 16) & 0xFF;
|
||||
// Extract the green component (shift the bits 8 places to the right and mask the rest).
|
||||
$green = ($rgb >> 8) & 0xFF;
|
||||
// Extract the blue component (mask directly to get the blue value).
|
||||
$blue = $rgb & 0xFF;
|
||||
|
||||
// Accumulate the red, green, and blue values.
|
||||
$redtotal += $red;
|
||||
$greentotal += $green;
|
||||
$bluetotal += $blue;
|
||||
// Increment the pixel counter.
|
||||
$pixelcount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the average red, green, and blue values by dividing the total by the number of pixels.
|
||||
return [
|
||||
'red' => $redtotal / $pixelcount,
|
||||
'green' => $greentotal / $pixelcount,
|
||||
'blue' => $bluetotal / $pixelcount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the color is dark based on its RGB values.
|
||||
*
|
||||
* This method uses a formula to calculate the luminance of a color.
|
||||
* Luminance is a weighted sum of the red, green, and blue components, with green having the highest weight
|
||||
* because the human eye is more sensitive to green.
|
||||
* A luminance value below 128 is generally considered dark.
|
||||
*
|
||||
* @param array $color RGB array with keys 'red', 'green', and 'blue'.
|
||||
* @return bool True if the color is dark, false if it is light.
|
||||
*/
|
||||
private function is_color_dark(array $color): bool {
|
||||
// Calculate the luminance using the standard formula.
|
||||
// Luminance = 0.299 * Red + 0.587 * Green + 0.114 * Blue.
|
||||
// The coefficients correspond to the human eye's sensitivity to these colors.
|
||||
$luminance = (0.299 * $color['red'] + 0.587 * $color['green'] + 0.114 * $color['blue']);
|
||||
|
||||
// Return true if the luminance is below 128 (dark), otherwise return false (light).
|
||||
return $luminance < 128;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a pill-shaped rounded rectangle.
|
||||
* The pill is composed of two half circles and a single rectangle.
|
||||
*
|
||||
* @param int $x1 Top-left X coordinate of the rectangle.
|
||||
* @param int $y1 Top-left Y coordinate of the rectangle.
|
||||
* @param int $x2 Bottom-right X coordinate of the rectangle.
|
||||
* @param int $y2 Bottom-right Y coordinate of the rectangle.
|
||||
* @param int $radius Radius of the rounded corners (half the pill height).
|
||||
* @param int $color Color for the pill background.
|
||||
*/
|
||||
private function draw_rounded_rectangle(
|
||||
int $x1,
|
||||
int $y1,
|
||||
int $x2,
|
||||
int $y2,
|
||||
int $radius,
|
||||
int $color
|
||||
): void {
|
||||
// Draw two half circles at the ends of the pill.
|
||||
// Left half circle.
|
||||
imagefilledarc(
|
||||
image: $this->imgobject,
|
||||
center_x: $x1 + $radius, // Center X coordinate.
|
||||
center_y: ($y1 + $y2) / 2, // Center Y coordinate.
|
||||
width: $radius * 2, // Width of the circle (diameter).
|
||||
height: $radius * 2, // Height of the circle (diameter).
|
||||
start_angle: 90,
|
||||
end_angle: 270,
|
||||
color: $color,
|
||||
style: IMG_ARC_PIE
|
||||
);
|
||||
|
||||
// Right half circle.
|
||||
imagefilledarc(
|
||||
image: $this->imgobject,
|
||||
center_x: $x2 - $radius,
|
||||
center_y: ($y1 + $y2) / 2,
|
||||
width: $radius * 2,
|
||||
height: $radius * 2,
|
||||
start_angle: 270,
|
||||
end_angle: 90,
|
||||
color: $color,
|
||||
style: IMG_ARC_PIE
|
||||
);
|
||||
|
||||
// Draw the rectangle joining the two half circles.
|
||||
imagefilledrectangle(
|
||||
image: $this->imgobject,
|
||||
x1: $x1 + $radius, // Start after the left half circle.
|
||||
y1: $y1, // Top of the rectangle.
|
||||
x2: $x2 - $radius, // End before the right half circle.
|
||||
y2: $y2, // Bottom of the rectangle.
|
||||
color: $color
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add watermark to image.
|
||||
*
|
||||
@ -106,25 +243,103 @@ class ai_image {
|
||||
'ttf' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$imagewidth = imagesx($this->imgobject);
|
||||
$imageheight = imagesy($this->imgobject);
|
||||
|
||||
// Determine the size of the area to analyze: 10% of the image width and height.
|
||||
$areawidth = (int)($imagewidth * 0.1);
|
||||
$areaheight = (int)($imageheight * 0.1);
|
||||
|
||||
// Dynamically calculate the bottom-left corner coordinates.
|
||||
$bottomleftcolor = $this->get_predominant_color(
|
||||
x: 0,
|
||||
y: $imageheight - $areaheight,
|
||||
width: $areawidth,
|
||||
height: $areaheight
|
||||
);
|
||||
|
||||
// Set text color based on the background color.
|
||||
if ($this->is_color_dark($bottomleftcolor)) {
|
||||
$clr = imagecolorallocate( // White for dark background.
|
||||
image: $this->imgobject,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255
|
||||
);
|
||||
$bgclr = imagecolorallocatealpha( // Black (80% transparent).
|
||||
image: $this->imgobject,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
alpha: (int)(127 * 0.2)
|
||||
);
|
||||
} else {
|
||||
$clr = imagecolorallocate( // Black for light background.
|
||||
image: $this->imgobject,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0
|
||||
);
|
||||
$bgclr = imagecolorallocatealpha( // White (80% transparent).
|
||||
image: $this->imgobject,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255,
|
||||
alpha: (int)(127 * 0.2)
|
||||
);
|
||||
}
|
||||
|
||||
// Encode the text properly.
|
||||
$text = iconv(
|
||||
from_encoding: 'ISO-8859-8',
|
||||
to_encoding: 'UTF-8',
|
||||
string: $watermark
|
||||
);
|
||||
$clr = imagecolorallocate(
|
||||
image: $this->imgobject,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255,
|
||||
);
|
||||
|
||||
// Calculate text bounding box for determining pill siz), different for TTF and non-TTF fonts.
|
||||
if (!empty($options['ttf'])) {
|
||||
// For TTF fonts, use imagettfbbox to get the text's bounding box.
|
||||
$bbox = imagettfbbox($options['fontsize'], $options['angle'], $options['font'], $text);
|
||||
$textwidth = abs($bbox[4] - $bbox[0]);
|
||||
$textheight = abs($bbox[5] - $bbox[1]);
|
||||
} else {
|
||||
// For non-TTF fonts, use imagefontwidth and imagefontheight.
|
||||
$textwidth = strlen($text) * imagefontwidth($options['fontsize']);
|
||||
$textheight = imagefontheight($options['fontsize']);
|
||||
}
|
||||
|
||||
// Pill background dimensions.
|
||||
$padding = 10;
|
||||
$pillwidth = $textwidth + $padding * 2;
|
||||
$pillheight = $textheight + $padding * 2;
|
||||
|
||||
// Position for the pill background.
|
||||
$x = $pos[0];
|
||||
$y = $imageheight - ($pos[1] + $pillheight); // Adjust Y based on the pill height.
|
||||
|
||||
// Draw the pill background.
|
||||
$this->draw_rounded_rectangle(
|
||||
x1: $x,
|
||||
y1: $y,
|
||||
x2: $x + $pillwidth,
|
||||
y2: $y + $pillheight,
|
||||
radius: $pillheight / 2,
|
||||
color: $bgclr
|
||||
);
|
||||
|
||||
// Correct the position of the text to center it inside the pill.
|
||||
$textx = $x + (($pillwidth - $textwidth) / 2); // Center text horizontally in the pill.
|
||||
$texty = $y + ((($pillheight - $textheight) / 2) * .75) + $textheight; // Center vertically, adjusting for baseline.
|
||||
|
||||
// Draw the text on top of the pill background.
|
||||
if (!empty($options['ttf'])) {
|
||||
$height = imagesy($this->imgobject);
|
||||
imagettftext(
|
||||
image: $this->imgobject,
|
||||
size: $options['fontsize'],
|
||||
angle: $options['angle'],
|
||||
x: $pos[0],
|
||||
y: $height - ($pos[1] + $options['fontsize']),
|
||||
x: (int)$textx,
|
||||
y: (int)$texty,
|
||||
color: $clr,
|
||||
font_filename: $options['font'],
|
||||
text: $text,
|
||||
@ -133,8 +348,8 @@ class ai_image {
|
||||
imagestring(
|
||||
image: $this->imgobject,
|
||||
font: $options['fontsize'],
|
||||
x: $pos[0],
|
||||
y: $pos[1],
|
||||
x: (int)$textx,
|
||||
y: (int)$texty,
|
||||
string: $text,
|
||||
color: $clr,
|
||||
);
|
||||
|
104
ai/tests/ai_image_test.php
Normal file
104
ai/tests/ai_image_test.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace core_ai;
|
||||
|
||||
/**
|
||||
* Test ai image class methods.
|
||||
*
|
||||
* @package core_ai
|
||||
* @copyright 2024 Matt Porritt <matt.porritt@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \core_ai\ai_image
|
||||
*/
|
||||
final class ai_image_test extends \advanced_testcase {
|
||||
|
||||
/**
|
||||
* Test get_predominant_color.
|
||||
*/
|
||||
public function test_get_predominant_color(): void {
|
||||
$x = 0;
|
||||
$y = 180;
|
||||
$width = 20;
|
||||
$height = 20;
|
||||
|
||||
// Test for black image.
|
||||
$imagepath = __DIR__ . '/fixtures/black.png'; // Get the test image from the fixtures file.
|
||||
$image = new ai_image($imagepath);
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($image, 'get_predominant_color');
|
||||
$color = $method->invoke($image, $x, $y, $width, $height);
|
||||
|
||||
$this->assertEquals(0, $color['red']);
|
||||
$this->assertEquals(0, $color['green']);
|
||||
$this->assertEquals(0, $color['blue']);
|
||||
|
||||
// Test for white image.
|
||||
$imagepath = __DIR__ . '/fixtures/white.png';
|
||||
$image = new ai_image($imagepath);
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($image, 'get_predominant_color');
|
||||
$color = $method->invoke($image, $x, $y, $width, $height);
|
||||
|
||||
$this->assertEquals(255, $color['red']);
|
||||
$this->assertEquals(255, $color['green']);
|
||||
$this->assertEquals(255, $color['blue']);
|
||||
|
||||
// Test for grey image.
|
||||
$imagepath = __DIR__ . '/fixtures/grey.png';
|
||||
$image = new ai_image($imagepath);
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($image, 'get_predominant_color');
|
||||
$color = $method->invoke($image, $x, $y, $width, $height);
|
||||
|
||||
$this->assertEquals(128, $color['red']);
|
||||
$this->assertEquals(128, $color['green']);
|
||||
$this->assertEquals(128, $color['blue']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test is_color_dark.
|
||||
*/
|
||||
public function test_is_color_dark(): void {
|
||||
// Load an image as it is required for class instantiation.
|
||||
// The color of the image is not important for this test.
|
||||
$imagepath = __DIR__ . '/fixtures/black.png';
|
||||
$image = new ai_image($imagepath);
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($image, 'is_color_dark');
|
||||
$color = ['red' => 0, 'green' => 0, 'blue' => 0];
|
||||
$result = $method->invoke($image, $color);
|
||||
|
||||
$this->assertTrue($result);
|
||||
|
||||
$image = new ai_image($imagepath);
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($image, 'is_color_dark');
|
||||
$color = ['red' => 255, 'green' => 255, 'blue' => 255];
|
||||
$result = $method->invoke($image, $color);
|
||||
|
||||
$this->assertFalse($result);
|
||||
|
||||
$imagepath = __DIR__ . '/fixtures/grey.png';
|
||||
$image = new ai_image($imagepath);
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($image, 'is_color_dark');
|
||||
$color = ['red' => 128, 'green' => 128, 'blue' => 128];
|
||||
$result = $method->invoke($image, $color);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
}
|
BIN
ai/tests/fixtures/black.png
vendored
Normal file
BIN
ai/tests/fixtures/black.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 216 B |
BIN
ai/tests/fixtures/grey.png
vendored
Normal file
BIN
ai/tests/fixtures/grey.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 610 B |
BIN
ai/tests/fixtures/white.png
vendored
Normal file
BIN
ai/tests/fixtures/white.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 610 B |
Loading…
x
Reference in New Issue
Block a user