Sei interessato ai nostri servizi di consulenza?

1 Clicca nella sezione contatti
2 Compila il form
3 Ti ricontattiamo

Se hai bisogno urgente del nostro intervento puoi contattarci al numero 370 148 9430

RENOR & Partners

I nostri orari
Lun-Ven 9:00AM - 18:PM

Invisible Watermark in JPEG with PHP 8

by Simone Renzi / May 11, 2025
Post Image

This post is also available in: Italiano (Italian)

Protect your images by modifying the coefficients, without damaging them

Publishing photographs online has become essential for photographers, e-commerce sites, and bloggers, but according to the latest studies on image theft, over 70% of visual content is reshared without credit or authorization. A visible watermark certainly protects the author, but to make it truly effective against intellectual property theft, it often ruins the aesthetic of the shot with an unsightly “patch”!

In contrast, an “invisible” watermark embedded in the coefficients of the Discrete Cosine Transform (DCT), on which the JPEG format is based, preserves perceived quality and can withstand resizing or light compression.

By encoding the information in the sign of specific low-frequency coefficients, it is possible to ensure an effective compromise between invisibility and robustness.

In this open-source project, available in my GitHub repository: https://github.com/thesimon82/php-dct-invisible-watermark, written in PHP 8.3 and based on GD/Imagick for pixel manipulation, we will demonstrate step by step how to embed a textual signature into the low-frequency (AC) coefficients of each 8×8 block, how to retrieve its fingerprint, and why using the coefficient’s sign represents a robust strategy against common JPEG compressions, even at 80% quality or lower.

The goal is not purely educational: the resulting microservice can be cloned from the repository and integrated, via CLI or API, into any image publishing workflow.

Technical foundations

Before writing a single line of code, it is worth clarifying how the JPEG compression algorithm processes the images it needs to compress.

The JPEG format divides the image into a grid of 8×8 pixel blocks; each block is transformed using the Discrete Cosine Transform (DCT), which decomposes the visual information into 64 coefficients ordered from the lowest frequency (the top-left corner, representing the average intensity) to the highest frequency (the finest details).

The mid-to-low frequency coefficients are the ideal spot for our watermark: altering the zero-frequency (DC) coefficient would produce visible halos, while modifying only the highest frequencies would lead to their removal during the very first JPEG compression.

By instead acting on intermediate coefficients, we can encode individual bits by modifying the sign of the coefficient (positive = 0, negative = 1), thus obtaining a marker that is invisible to the eye but robust enough to survive resizing and JPEG compressions down to 80% quality.

This mathematical principle will guide the entire implementation.

We will read each 8×8 block, compute the DCT using a native PHP function, modify the sign of one or more selected coefficients, and save the new JPEG using GD or Imagick.

In other words, the watermark will be hidden precisely in the coefficients that the JPEG compression algorithm tends to preserve: it will be invisible to the eye but difficult to remove, even in the presence of light recompressions.

The mathematics that makes the watermark invisible

Each 8×8 block of intensities f_{x,y} (where x,y \in \{0,\dots,7\}) is converted into 64 coefficients C_{u,v} (with u,v \in \{0,\dots,7\}) using:

(1)   \[ C_{u,v}= \frac{1}{4}\,\alpha(u)\,\alpha(v)\! \sum_{x=0}^{7}\,\sum_{y=0}^{7} f_{x,y}\; \cos\!\Bigl[\frac{(2x+1)u\pi}{16}\Bigr]\, \cos\!\Bigl[\frac{(2y+1)v\pi}{16}\Bigr] \]

where

    \[ \alpha(k)= \begin{cases} \dfrac1{\sqrt{2}}, & k=0,\\[6pt] 1, & k\ge 1. \end{cases} \]

C_{0,0} (the DC coefficient) represents the average luminance of the block. The AC coefficients C_{u,v} with u, v \neq 0 describe increasingly fine details as either u or v increase.

Simplified numerical example

Let’s suppose a block where all values are 128, a medium gray. Then:

C_{0,0} = 8 \cdot 8 \cdot 128 \cdot \frac{1}{4} \cdot \alpha(0)^2 = 64 \cdot 128 \cdot \frac{1}{4} \cdot \frac{1}{2} = 1024 while C_{u,v} = 0 \quad \text{for } (u,v) \neq (0,0)
because there are no frequency variations.

The compressed image preserves the lower-frequency coefficients, while the higher ones are aggressively quantized—often down to 0.

Quantization and selection of safe frequencies

Before being saved in JPEG format, the DCT coefficients are divided by a quantization matrix Q_{u,v}, specific to each frequency:

(2)   \[ \tilde{C}{u,v}= \operatorname{round}\!\Bigl(\frac{C{u,v}}{Q_{u,v}}\right)\Big). \]

The higher the value of Q_{u,v}, the greater the loss of precision, with the possibility that the coefficients will be reduced to zero.
For this reason, in our algorithm we operate on a low-frequency subset of the AC coefficients, in positions where quantization is moderate and the values are more likely to survive compression.
We also avoid coefficients that are too low (including DC), as modifying them could introduce visible artifacts.

Bit encoding in the coefficient

To ensure the bit survives JPEG compression, instead of using parity, we enforce the sign of the coefficient based on the bit to be written:

If the bit is 0, we make the coefficient positive

(3′)   \[ \operatorname{embed}(c, b) = \begin{cases} +\text{minAmp} + \text{boost}, & b = 0 \\\\ -\text{minAmp} - \text{boost}, & b = 1 \end{cases} \]

Where:

\text{minAmp} ensures a value sufficiently far from zero (typically ≥ 80),
\text{boost} (e.g., 64) increases the distance from zero to prevent nullification during quantization.

In this way, even after lossy transformations, the sign remains unchanged and the bit can be recovered during reading.

If we later wish to read the bit, we simply need to evaluate the sign of the quantized DCT coefficient:

(4′)   \[ b = \begin{cases} 0, & \tilde{C}{u,v} \ge 0 \\\\ 1, & \tilde{C}{u,v} < 0 \end{cases} \]

In other words, a positive coefficient indicates bit 0, a negative coefficient indicates bit 1.

This method is more robust because the sign of a coefficient survives many lossy transformations, unlike the least significant bit.

Reconstruction (Inverse DCT)

After modifying the coefficients, we invert the transformation to obtain the watermarked image block:

(5)   \[ f_{x,y}= \frac{1}{4}\! \sum_{u=0}^{7}\sum_{v=0}^{7} \alpha(u)\,\alpha(v)\, \tilde{C}_{u,v}\, \cos\!\Bigl[\frac{(2x+1)u\pi}{16}\Bigr]\, \cos\!\Bigl[\frac{(2y+1)v\pi}{16}\Bigr]. \]

The maximum error introduced by a ±1 variation on a mid-frequency coefficient is less than 1 quantum in luminance, which is below the human visual perception threshold for real photographs.

Cyclic redundancy to induce robustness

To withstand compression or cropping, we encode the message M by duplicating each bit k times and applying a majority vote during reading. With k = 3:

(6)   \[ M = b_1b_2\dots b_n \;\longrightarrow\; b_1b_1b_1\,b_2b_2b_2\dots b_nb_nb_n \]

Correct recovery is guaranteed as long as at least two out of three copies survive.

From formulas to code: the DCT class

Now that we have formalized the entire mathematical framework, we can translate equation (1) and its inverse directly into PHP.

To keep the project in pure PHP, without native extensions or external dependencies, we implement the DCT manually on 8×8 blocks.

This approach is sufficiently fast for images up to a few megapixels and, most importantly, it is transparent and easy to understand and modify.

The project is organized with Composer and PSR-4 autoloading, but the Dct class is completely self-contained and does not require any external libraries.

If in the future you wish to extend the system with matrix analyses (e.g., box counting or distortion metrics), you can integrate MathPHP, but it is not required for the DCT.

composer.json

{
    "name": "renornad/php-dct-invisible-watermark",
    "description": "Pure-PHP DCT watermarking library for JPEG images.",
    "type": "project",
    "license": "MIT",
    "require": {
      "php": ">=8.3"
    },
    "autoload": {
      "psr-4": {
        "Renor\\Watermark\\": "src/"
      }
    }
  }

After defining our composer.json file, we proceed with the installation. Open the terminal and type the command:

composer install

At this point, Composer will create the composer.lock file and the vendor directory, but it won’t install any packages from Packagist because we are not including any dependencies.

src/Dct.php

We can now finally write the Dct class. Remember that it is preferable to include comments in the code, following the standard in English:

<?php

declare(strict_types=1);

namespace Renor\Watermark;

/**
 * 2-D Discrete Cosine Transform (DCT) and its inverse (IDCT) for 8x8 blocks.
 */
final class Dct
{
    private const N = 8;
    private const SQRT_2_INV = 0.7071067811865476; // 1 / sqrt(2)

    /**
     * Performs the forward DCT on an 8x8 block of pixel intensities (0-255).
     */
    public static function forward(array $block): array
    {
        self::assertBlock($block);
        $C = [];

        for ($u = 0; $u < self::N; ++$u) {
            $alphaU = ($u === 0) ? self::SQRT_2_INV : 1.0;
            for ($v = 0; $v < self::N; ++$v) {
                $alphaV = ($v === 0) ? self::SQRT_2_INV : 1.0;
                $sum = 0.0;
                for ($x = 0; $x < self::N; ++$x) {
                    for ($y = 0; $y < self::N; ++$y) {
                        $cosX = cos((2 * $x + 1) * $u * M_PI / (2 * self::N));
                        $cosY = cos((2 * $y + 1) * $v * M_PI / (2 * self::N));
                        $sum += $block[$x][$y] * $cosX * $cosY;
                    }
                }
                // The DCT formula includes a factor of 1/4
                $C[$u][$v] = 0.25 * $alphaU * $alphaV * $sum;
            }
        }
        return $C;
    }

    /**
     * Performs the inverse DCT on an 8x8 block of coefficients, 
     * returning pixel values in the range [0..255].
     */
    public static function inverse(array $coeff): array
    {
        self::assertBlock($coeff);
        $f = [];

        for ($x = 0; $x < self::N; ++$x) {
            for ($y = 0; $y < self::N; ++$y) {
                $sum = 0.0;
                for ($u = 0; $u < self::N; ++$u) {
                    $alphaU = ($u === 0) ? self::SQRT_2_INV : 1.0;
                    for ($v = 0; $v < self::N; ++$v) {
                        $alphaV = ($v === 0) ? self::SQRT_2_INV : 1.0;
                        $cosX = cos((2 * $x + 1) * $u * M_PI / (2 * self::N));
                        $cosY = cos((2 * $y + 1) * $v * M_PI / (2 * self::N));
                        $sum += $alphaU * $alphaV * $coeff[$u][$v] * $cosX * $cosY;
                    }
                }
                $pixel = (int) round(0.25 * $sum);
                $f[$x][$y] = max(0, min(255, $pixel));
            }
        }
        return $f;
    }

    /**
     * Checks that the array is exactly 8x8.
     */
    private static function assertBlock(array $block): void
    {
        if (count($block) !== self::N) {
            throw new \InvalidArgumentException('Block must have 8 rows.');
        }
        foreach ($block as $row) {
            if (count($row) !== self::N) {
                throw new \InvalidArgumentException('Each row must have 8 columns.');
            }
        }
    }

    /**
     * Disallow instantiation.
     */
    private function __construct()
    {
    }
}

This class is the core of the project. It encapsulates the algorithm of the two-dimensional Discrete Cosine Transform applied to 8×8 pixel blocks, the same scheme used by the JPEG standard.
At the start of the forward method, the class checks that the input array contains exactly eight rows and eight columns; otherwise, it throws an exception to prevent a sizing error from propagating silently.

Once the shape has been verified, for each pair of frequency indices u, v, it computes the double summation defined by the canonical DCT formula: it takes each luminance value f_{x,y}, multiplies it by two cosine terms — one as a function of u and coordinate x, the other as a function of v and coordinate y — and accumulates the result. Once the entire block has been scanned, it applies the normalization factor \tfrac{1}{4} \alpha(u)\alpha(v), where \alpha(0) = \frac{1}{\sqrt{2}} and \alpha(k) = \frac{1}{2} for k \geq 1, so that the coefficient C_{0,0} preserves the average luminance, and all the others represent, at the correct scale, the increasingly finer detail components. The method therefore returns an 8×8 matrix of floating-point coefficients: it is on this frequency representation that we will later embed the watermark bits.

The inverse path is handled by the inverse method. Here too, before any processing, the class enforces a check on the shape of the block. For each pair of pixel coordinates x, y, it reconstructs the luminance by summing the 64 frequency components, each weighted by the product of cosine terms and the normalization factors \alpha(u)\alpha(v). The final value is then multiplied by \tfrac{1}{4}, rounded to the nearest integer, and clamped to the 0–255 range, to prevent any numerical errors from pushing the result outside the valid color space.

The implementation closely follows the mathematical definition, without relying on FFT or SIMD accelerations, favoring educational clarity over performance. The constructor is private, the class cannot be instantiated, and all methods are static and stateless: in this way, the DCT behaves like a pure function—easily testable and reusable. When, in the following sections, we use it to embed a bit by modifying the sign of a low-frequency coefficient, we can be confident that the forward and inverse transformations are numerically consistent and that the alteration remains confined to the values we intend to manipulate.

src/Watermarker.php

The Watermarker class is the director that orchestrates the most delicate phase of the entire project. It receives as input or output a sequence of 8×8 DCT coefficient blocks already computed by the Dct class and, without dealing with the physical reading of the JPEG file, simply manipulates those numbers to hide or retrieve an ASCII text.

<?php

declare(strict_types=1);

namespace Renor\Watermark;

/**
 * Watermarker class
 *
 * Embeds/extracts a short ASCII message (max 10 characters + terminator)
 * into low-frequency DCT coefficients. Instead of relying on the single
 * parity bit – too fragile after rounding and quantization – here
 * we encode the information into the **sign** of the coefficient:
 *
 *   bit 0  → positive coefficient
 *   bit 1  → negative coefficient
 *
 * The sign is never affected during:
 *   1. IDCT → integer rounding,
 *   2. division by JPEG quantization tables,
 *   3. further rounding or transformations.
 */
final class Watermarker
{
    /** Low-frequency coordinates (survive even at q≈90–100). */
    private const EMBED_COORDS = [
        [0, 1], [1, 0], [1, 1], [0, 2],
        [2, 0], [1, 2], [2, 1], [2, 2]
    ];

    /** Redundancy: each bit is repeated 25 times. */
    private const REDUNDANCY = 25;

    private const TERMINATOR = "\0";

    /* ------------------------------------------------------------------
       Public API
    -------------------------------------------------------------------*/

    public static function embed(array &$blocks, string $msg): void
    {
        if (strlen($msg) > 10) {
            throw new \RuntimeException('Message too long (max 10).');
        }

        $bits   = self::stringToBitStream($msg . self::TERMINATOR);
        $cursor = 0;

        foreach ($blocks as &$block) {
            foreach (self::EMBED_COORDS as [$u, $v]) {
                if ($cursor >= count($bits)) {
                    return;
                }
                $bit           = $bits[$cursor++];
                $block[$u][$v] = self::forceSign($block[$u][$v], $bit);
            }
        }
        if ($cursor < count($bits)) {
            throw new \RuntimeException('Image too small for the message.');
        }
    }

    public static function extract(array $blocks): string
    {
        $bits = [];
        foreach ($blocks as $block) {
            foreach (self::EMBED_COORDS as [$u, $v]) {
                $bits[] = ( (int) round($block[$u][$v]) < 0 ) ? 1 : 0;
            }
        }

        $filtered   = self::majority($bits, self::REDUNDANCY);
        $byteChunks = array_chunk($filtered, 8);

        $out = '';
        foreach ($byteChunks as $chunk) {
            if (count($chunk) < 8) break;
            $ch = chr(self::bitsToInt($chunk));
            if ($ch === self::TERMINATOR) break;
            $out .= $ch;
        }
        return $out;
    }

    /* ------------------------------------------------------------------
       Private helpers
    -------------------------------------------------------------------*/

    private static function stringToBitStream(string $s): array
    {
        $bits = [];
        foreach (str_split($s) as $c) {
            $byte = ord($c);
            for ($i = 7; $i >= 0; --$i) {
                $bit = ($byte >> $i) & 1;
                $bits = array_merge($bits, array_fill(0, self::REDUNDANCY, $bit));
            }
        }
        return $bits;
    }

    private static function majority(array $bits, int $group): array
    {
        $out = [];
        foreach (array_chunk($bits, $group) as $chunk) {
            $out[] = (array_sum($chunk) > $group / 2) ? 1 : 0;
        }
        return $out;
    }

    private static function bitsToInt(array $bits): int
    {
        return array_reduce($bits, fn($v, $b) => ($v << 1) | $b, 0);
    }

    /**
     * Forces the sign of the coefficient:
     *   bit 0 → positive   (>= +minAmp)
     *   bit 1 → negative   (<= -minAmp)
     * so the quantizer cannot flip it.
     */
    private static function forceSign(
        float $coeff,
        int   $bit,
        int   $minAmp = 80,   // safe amplitude > luminance quantization divisors
        int   $boost  = 64    // further offset to move away from zero
    ): float {
        $rounded = (int) round($coeff);

        // Enforce minimum amplitude
        if (abs($rounded) < $minAmp) {
            $rounded = ($rounded >= 0) ? $minAmp : -$minAmp;
        }

        // Set the sign according to the bit
        if ($bit === 0 && $rounded < 0) $rounded = abs($rounded);
        if ($bit === 1 && $rounded > 0) $rounded = -$rounded;

        // Push further from zero: +/-boost (preserving the sign)
        $rounded += ($rounded > 0) ? $boost : -$boost;

        return (float) $rounded;
    }

    private function __construct() {}
}

When the embed method is called, the caller provides the array of DCT blocks and the message to be embedded. The method converts each character into 8 bits, and each bit is replicated 25 times (as specified by the REDUNDANCY constant), so that it can later be read using a majority vote and withstand alterations due to JPEG compression. The resulting redundant bitstream flows block by block and relies on 8 low-frequency coordinates defined in the constant array EMBED_COORDS. These positions were chosen because they do not affect visual perception but tend to survive even moderate JPEG compressions (quality 60–80).

For each selected coefficient, the private function forceSign() enforces a specific sign: positive to represent 0, negative to represent 1. This approach—more robust than using parity—ensures that the information remains readable even after JPEG quantization.
If the image does not contain enough blocks to represent the entire message, an exception is thrown with an explicit message.

The inverse process is handled by the extract method. Here too, the class reads the sign of the same 8 coefficients in each block, reconstructing a long vector of zeros and ones. The bits are then grouped according to the redundancy factor and passed through a majority vote function (majority()), which recovers the original bit. Every group of 8 bits yields one character, until a NUL terminator is encountered, signaling the end of the message. The resulting sequence of characters forms the original message.

The helper functions stringToBitStream, bitsToInt, and majority encapsulate binary-level details, keeping the public methods short and readable. Like the Dct class, Watermarker is also stateless and cannot be instantiated: all methods are static. This functional approach facilitates automated testing and use in concurrent environments.
In summary, the Watermarker class concretely implements the mathematical principles described earlier: cyclic redundancy, bit encoding via sign, explicit message termination—all in a compact and robust flow designed to survive the typical transformations of images published online.

ImageIO: from the image to DCT blocks and back

The ImageIO class is the bridge between the mathematical theory and the actual JPEG file.

<?php

declare(strict_types=1);

namespace Renor\Watermark;

use GdImage;

/**
 * Class ImageIO
 *
 * - Loads a JPEG image.
 * - Converts RGB to the Y (luminance) channel.
 * - Splits it into 8x8 blocks and applies DCT/IDCT.
 * - Overwrites the Y channel with the embedded watermark, merging with the original CbCr.
 * - Saves the final image as JPEG with a specified quality.
 */
final class ImageIO
{
    private const BLOCK_SIZE = 8;
    private const MIN_WIDTH = 200;
    private const MIN_HEIGHT = 200;

    /**
     * Embed a watermark in a JPEG image and save the result.
     *
     * @param string $src      Path to the original JPEG
     * @param string $dest     Output path for the watermarked image
     * @param string $message  ASCII watermark (up to 10 chars)
     * @param int    $quality  JPEG quality in [0..100], default 90
     */
    public static function embed(
        string $src,
        string $dest,
        string $message,
        int $quality = 90
    ): void {
        $im = self::loadJpeg($src);
        $w = imagesx($im);
        $h = imagesy($im);

        // Ensure a minimum image size
        if ($w < self::MIN_WIDTH || $h < self::MIN_HEIGHT) {
            imagedestroy($im);
            throw new \RuntimeException("Image must be at least 200x200 for robust embedding.");
        }

        // Convert RGB → Y
        $Y = self::rgbToY($im);

        // Split into 8x8 blocks, apply forward DCT
        $blocks = self::forwardBlocks($Y, $w, $h);

        // Embed the message into the DCT coefficients
        Watermarker::embed($blocks, $message);

        // Reconstruct the Y channel from the inverse DCT
        $Ywm = self::inverseBlocks($blocks, $w, $h);

        // Merge the modified Y channel and save as JPEG
        self::mergeAndSave($im, $Ywm, $dest, $quality);

        imagedestroy($im);
    }

    /**
     * Extract the watermark from a JPEG image.
     *
     * @param string $src Path to the watermarked JPEG
     * @return string     The recovered ASCII message
     */
    public static function extract(string $src): string
    {
        $im = self::loadJpeg($src);
        $w = imagesx($im);
        $h = imagesy($im);

        if ($w < self::MIN_WIDTH || $h < self::MIN_HEIGHT) {
            imagedestroy($im);
            throw new \RuntimeException("Image is too small (<200x200). It may not contain a robust watermark.");
        }

        // Convert to Y
        $Y = self::rgbToY($im);

        // Apply DCT on 8x8 blocks
        $blocks = self::forwardBlocks($Y, $w, $h);

        // Extract the message from the DCT blocks
        $msg = Watermarker::extract($blocks);

        imagedestroy($im);
        return $msg;
    }

    /* -------------------------------------------------------------------------
       INTERNAL METHODS
    -------------------------------------------------------------------------- */

    /**
     * Loads a JPEG image from disk or throws an exception if invalid.
     */
    public static function loadJpeg(string $path): GdImage
    {
        if (!is_file($path)) {
            throw new \RuntimeException("File not found: $path");
        }
        $im = @imagecreatefromjpeg($path);
        if (!$im) {
            throw new \RuntimeException("Invalid JPEG: $path");
        }

        return $im;
    }

    /**
     * Converts an RGB image to a 2D Y (luminance) matrix.
     */
    public static function rgbToY(GdImage $im): array
    {
        $w = imagesx($im);
        $h = imagesy($im);
        $Y = array_fill(0, $h, array_fill(0, $w, 0));

        for ($y = 0; $y < $h; $y++) {
            for ($x = 0; $x < $w; $x++) {
                $rgb = imagecolorat($im, $x, $y);
                $r = ($rgb >> 16) & 0xFF;
                $g = ($rgb >> 8) & 0xFF;
                $b = $rgb & 0xFF;
                // ITU-R BT.601 luma formula
                $luma = (int) round(0.299 * $r + 0.587 * $g + 0.114 * $b);
                $Y[$y][$x] = max(0, min(255, $luma));
            }
        }

        return $Y;
    }

    /**
     * Splits the Y matrix into 8x8 blocks and applies forward DCT.
     */
    public static function forwardBlocks(array $Y, int $w, int $h): array
    {
        $blocks = [];
        for ($by = 0; $by < $h; $by += self::BLOCK_SIZE) {
            for ($bx = 0; $bx < $w; $bx += self::BLOCK_SIZE) {
                $block = [];
                for ($i = 0; $i < self::BLOCK_SIZE; $i++) {
                    $row = [];
                    $srcY = min($by + $i, $h - 1);
                    for ($j = 0; $j < self::BLOCK_SIZE; $j++) {
                        $srcX = min($bx + $j, $w - 1);
                        $row[] = $Y[$srcY][$srcX];
                    }
                    $block[] = $row;
                }
                $blocks[] = Dct::forward($block);
            }
        }
        return $blocks;
    }

    /**
     * Applies inverse DCT to each 8x8 block and rebuilds the Y matrix.
     */
    private static function inverseBlocks(array $blocks, int $w, int $h): array
    {
        $Y = array_fill(0, $h, array_fill(0, $w, 0));
        $idx = 0;

        for ($by = 0; $by < $h; $by += self::BLOCK_SIZE) {
            for ($bx = 0; $bx < $w; $bx += self::BLOCK_SIZE) {
                $coeffBlock = $blocks[$idx++];
                $pixBlock = Dct::inverse($coeffBlock);

                for ($i = 0; $i < self::BLOCK_SIZE; $i++) {
                    $dstY = $by + $i;
                    if ($dstY >= $h) break;
                    for ($j = 0; $j < self::BLOCK_SIZE; $j++) {
                        $dstX = $bx + $j;
                        if ($dstX >= $w) break;
                        $Y[$dstY][$dstX] = $pixBlock[$i][$j];
                    }
                }
            }
        }

        return $Y;
    }

    /**
     * Merges the watermarked Y channel with the original CbCr, then saves the final JPEG.
     */
    private static function mergeAndSave(GdImage $orig, array $Ywm, string $dest, int $quality): void
    {
        $w = imagesx($orig);
        $h = imagesy($orig);

        // 1) Overwrite the RGB pixels of the GDImage using the new Y
        for ($y = 0; $y < $h; $y++) {
            for ($x = 0; $x < $w; $x++) {
                $rgb = imagecolorat($orig, $x, $y);
                $r = ($rgb >> 16) & 0xFF;
                $g = ($rgb >> 8) & 0xFF;
                $b = $rgb & 0xFF;

                $cb = -0.169 * $r - 0.331 * $g + 0.5 * $b + 128;
                $cr = 0.5 * $r - 0.419 * $g - 0.081 * $b + 128;

                $Yval = $Ywm[$y][$x];

                $R = $Yval + 1.402 * ($cr - 128);
                $G = $Yval - 0.344136 * ($cb - 128) - 0.714136 * ($cr - 128);
                $B = $Yval + 1.772 * ($cb - 128);

                $R = max(0, min(255, (int) round($R)));
                $G = max(0, min(255, (int) round($G)));
                $B = max(0, min(255, (int) round($B)));

                $color = imagecolorallocate($orig, $R, $G, $B);
                imagesetpixel($orig, $x, $y, $color);
            }
        }

        // 2) Convert GDImage to a PNG blob in memory (lossless)
        ob_start();
        imagepng($orig);
        $pngData = ob_get_clean();

        // 3) Create an Imagick object from the PNG blob
        $imagick = new \Imagick();
        $imagick->readImageBlob($pngData);

        // 4) Set desired JPEG parameters
        $imagick->setOption('jpeg:sampling-factor', '1x1'); // disable chroma subsampling
        $imagick->setImageCompressionQuality($quality);
        $imagick->setImageCompression(\Imagick::COMPRESSION_JPEG);
        $imagick->setImageFormat('jpeg');

        // 5) Save the file
        $imagick->writeImage($dest);

        // 6) Cleanup
        $imagick->destroy();
    }

    /**
     * Prevents instantiation.
     */
    private function __construct()
    {
    }
}

When the static embed method is invoked, the code opens the image using the GD extension and extracts the Y (luminance) component— the only one involved in watermarking—while leaving the chrominance channels Cb and Cr untouched, to ensure the watermark remains invisible to the human eye.
The Y matrix is then divided, row by row, into a grid of 8×8 blocks. Each block is transformed into the frequency domain using Dct::forward, enabling the Watermarker class to embed the message bits by forcing the sign of 8 specifically chosen low-frequency coefficients.

Once the embedding is complete, ImageIO converts each block back into pixels using Dct::inverse, reconstructing a new, watermarked Y matrix. At this point, the class recalculates the RGB values by combining the modified luminance with the original chrominance components: for each pixel, it applies the inverse color space formulas according to the ITU-R BT.601 standard, ensuring that the results stay within the 0–255 range.

The complete bitmap is finally saved as a JPEG at the desired quality, using Imagick to disable chroma subsampling and maximize fidelity. The result is a file that is visually identical to the original but contains, within its DCT coefficients, an invisible yet resilient textual signature.

The inverse process, handled by the extract method, performs the opposite operation: it reloads the image, isolates the luminance channel, divides it into blocks, computes the DCT of each block, and passes the sequence of coefficients to Watermarker::extract. This method reads the sign of the coefficients, applies majority voting across the repetitions of each bit, and reconstructs the original string up to the NUL termination character.

All I/O logic—file opening, color conversion, and JPEG saving—is confined within the ImageIO class, while Dct and Watermarker remain pure mathematical functions. This clear separation of responsibilities promotes code modularity, testability, and easy integration into any image processing pipeline.

CLI test

/embed.php

Embedding a watermark in an image

#!/usr/bin/env php
<?php

// Set memory limit to 1GB
ini_set('memory_limit', '1024M');
require __DIR__ . '/vendor/autoload.php';

use Renor\Watermark\ImageIO;

/**
 * This command-line script embeds a watermark (up to 10 characters)
 * into a JPEG and saves the result. By default, it uses quality=90.
 *
 * Example usage:
 *   php embed.php --src=original.jpg --out=watermarked.jpg --msg="HELLO" --q=95
 *
 * Or simply:
 *   php embed.php --src=original.jpg --out=watermarked.jpg
 *   (defaults to msg="DEFAULT" and quality=90)
 */

$options = getopt('', ['src:', 'out:', 'msg::', 'q::']);
$src = $options['src'] ?? null;
$out = $options['out'] ?? null;
$msg = $options['msg'] ?? 'DEFAULT';
$q   = isset($options['q']) ? (int) $options['q'] : 90;

if (!$src || !$out) {
    fwrite(STDERR, "Usage: embed --src=INPUT.jpg --out=OUTPUT.jpg [--msg=STRING] [--q=90]\n");
    exit(1);
}

try {
    ImageIO::embed($src, $out, $msg, $q);
    echo "Watermark \"$msg\" embedded successfully into $out at quality $q\n";
} catch (\Exception $e) {
    fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
    exit(1);
}

In this CLI script (embed.php), we define the message to embed in the image via the –msg option and call the static method ImageIO::embed() from the ImageIO class. The script accepts the following parameters:

–q (optional): the JPEG quality for saving (default: 90).

–src: the path to the source JPEG image (e.g., original.jpg);

–out: the path to the destination JPEG file that will contain the watermark;

–msg (optional): the ASCII text to embed (max 10 characters);

php embed.php --src=original.jpg --out=watermarked.jpg --msg="RENOR" --q=95

it will return:

Watermark "RENOR" embedded successfully into watermarked.jpg at quality 95

/extract.php

From here, we extract the watermark text contained in the watermarked.jpg image just created by embed.php.

#!/usr/bin/env php
<?php

// Set memory limit to 1GB
ini_set('memory_limit', '1024M');
require __DIR__ . '/vendor/autoload.php';

use Renor\Watermark\ImageIO;

/**
 * This command-line script extracts a watermark from a JPEG.
 *
 * Usage:
 *   php extract.php --src=watermarked.jpg
 */

$options = getopt('', ['src:']);
$src = $options['src'] ?? null;

if (!$src) {
    fwrite(STDERR, "Usage: extract --src=IMAGE.jpg\n");
    exit(1);
}

try {
    $msg = ImageIO::extract($src);
    echo "Recovered message: \"$msg\"\n";
} catch (\Exception $e) {
    fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
    exit(1);
}

The extract.php script completes the invisible watermark usage cycle by allowing the extraction of the previously embedded message. It is a simple command-line script which, given a watermarked JPEG file, reads the hidden content by decoding the sign of specific modified DCT coefficients.
The message—robustly recovered even after medium-to-high quality JPEG compressions—is reconstructed using majority voting on the repeated bits.
Just run the command with the –src option to specify the image to analyze, and the script will return the originally embedded string in the terminal.

We can run the command:

php extract.php --src=watermarked.jpg  

we’ll get:

Recovered message: "RENOR"

It is also worth analyzing photographs:

On the left, the original image; on the right, the watermarked one.

We can observe that, as thoroughly described, there are indeed no elements visible to the naked eye that can be traced back to the application of a watermark, which nevertheless remains fully decodable using the extract script.

Clearly, the project is purely demonstrative, and using it at an enterprise level or in production environments would require further refinement.

Simone Renzi
Seguimi

Scegli un'area

CONTATTACI

Ti risponderemo entro 24 ore

TORNA SU