Se hai bisogno urgente del nostro intervento puoi contattarci al numero 370 148 9430
RENOR & Partners è una società di consulenza d'impresa con alti standard qualitativi e con i prezzi più bassi del mercato.
RENOR & Partners S.r.l.
Via Cicerone, 15 - Ariccia (RM)
info@renor.it
This post is also available in:
English (Inglese)
Pubblicare fotografie online è diventato indispensabile per fotografi, e-commerce e blogger, ma secondo gli studi più recenti sul furto d’immagini, oltre il 70% dei contenuti visivi viene ricondiviso senza credito o autorizzazione. Una filigrana visibile protegge certamente l’autore, ma per renderla effettivamente valida ai fini del furto della proprietà intellettuale, spesso rovina l’estetica dello scatto con una “pecetta” tutt’altro che piacevole!
Al contrario, un watermark “invisibile” inscritto nei coefficienti della Trasformata Coseno Discreta (DCT), su cui si basa il formato JPEG, preserva la qualità percepita e può resistere a ridimensionamenti o compressioni leggere.
Codificando l’informazione nel segno di specifici coefficienti a bassa frequenza, è possibile garantire un compromesso efficace tra invisibilità e robustezza.
In questo progetto open-source, disponibile nella mia repository GitHub: https://github.com/thesimon82/php-dct-invisible-watermark, scritto in PHP 8.3 e basato su GD/Imagick per la manipolazione dei pixel, mostreremo passo passo come incorporare una firma testuale nei coefficienti a bassa frequenza (AC) di ciascun blocco 8×8, come recuperarne l’impronta e perché il segno del coefficiente rappresenta una strategia robusta contro le comuni compressioni JPEG, anche a qualità 80% o inferiore.
L’obiettivo non è solo didattico: il micro-servizio risultante può essere clonato dal repository e integrato, via CLI o API, in qualunque workflow di pubblicazione immagini.
Prima di scrivere una sola riga di codice conviene chiarire come l’algoritmo di compressione JPEG agisce sulle immagini che deve comprimere.
Il formato JPEG divide l’immagine in una griglia di blocchi 8×8 pixel; ciascun blocco viene trasformato tramite la Discrete Cosine Transform (DCT), che scompone l’informazione visiva in 64 coefficienti ordinati dalla frequenza più bassa (l’angolo in alto a sinistra, responsabile dell’intensità media) a quella più alta (i dettagli più fini).
I coefficienti a media-bassa frequenza sono il punto ideale per il nostro watermark: toccare il coefficiente a frequenza zero (DC) produrrebbe aloni visibili, mentre alterare solo le frequenze più alte porterebbe a una loro eliminazione già alla prima compressione JPEG.
Intervenendo invece su coefficienti intermedi possiamo codificare singoli bit modificando il segno del coefficiente (positivo = 0, negativo = 1), ottenendo così un marcatore invisibile all’occhio ma sufficientemente robusto da sopravvivere a ridimensionamenti e compressioni JPEG fino a qualità 80.
Questo principio matematico guiderà l’intera implementazione.
Leggeremo ogni blocco 8×8, calcoleremo la DCT tramite una funzione nativa in PHP, modificheremo il segno di uno o più coefficienti scelti e salveremo il nuovo JPEG tramite GD o Imagick.
In altre parole, il watermark sarà nascosto proprio nei coefficienti che l’algoritmo di compressione JPEG tende a preservare: sarà invisibile all’occhio ma difficile da rimuovere, anche in presenza di ricompressioni leggere.
Ogni blocco di intensità
(dove
) viene convertito in 64 coefficienti
(con
) tramite:
(1)
dove
(coefficiente DC) è la media luminosa del blocco. I coefficientiAC
descrivono contenuti sempre più fini man mano che
o
crescono.
Supponiamo un blocco che abbia tutto un valore di 128, un grigio medio. Allora:
, mentre
per
perché non ci sono variazioni di frequenza.
L’immagine compressa conserva i coefficienti più bassi, quelli più alti sono quantizzati in modo aggressivo, spesso fino a 0.
Prima di essere salvati in JPEG, i coefficienti DCT vengono divisi per una matrice di quantizzazione , specifica per ogni frequenza:
(2)
Più alto è il valore di , maggiore sarà la perdita di precisione, con la possibilità che i coefficienti vengano ridotti a zero.
Per questo motivo, nel nostro algoritmo operiamo su un sottoinsieme a bassa frequenza dei coefficienti AC, in posizioni dove la quantizzazione è moderata e i valori sopravvivono meglio alla compressione.
Evitiamo inoltre i coefficienti troppo bassi (DC incluso), che potrebbero introdurre artefatti visibili se modificati.
Per garantire la sopravvivenza del bit dopo la compressione JPEG, invece di usare la parità, imponiamo il segno del coefficiente in base al bit da scrivere:
(3′)
Dove:
• garantisce un valore sufficientemente lontano da zero (tipicamente ≥ 80),
• (es. 64) aumenta la distanza dal centro per evitare l’annullamento in fase di quantizzazione.
In questo modo, anche dopo trasformazioni lossy, il segno resta inalterato e il bit può essere recuperato in lettura.
Se successivamente desideriamo leggere il bit, ci basterà valutare il segno del coefficiente DCT quantizzato:
(4′)
In altre parole, un coefficiente positivo indica bit 0, uno negativo indica bit 1.
Questo metodo è più robusto, perché il segno di un coefficiente sopravvive a molte trasformazioni lossy, a differenza del singolo bit meno significativo.
Dopo aver modificato i coefficienti, invertiamo la trasformazione per ottenere il blocco d’immagine marcato:
(5)
L’errore massimo introdotto da una variazione di ±1 su un coefficiente di media frequenza è inferiore a 1 quantum in luminanza, sotto la soglia di percezione umano-visiva per fotografie reali.
Per fronteggiare compressioni o ritagli, codifichiamo il messaggio M duplicando ciascun bit k volte e applicando un voto di maggioranza in lettura. Con k=3:
(6)
Il recupero corretto è garantito finché almeno due copie su tre sopravvivono.
Ora che abbiamo formalizzato l’intero impianto matematico, possiamo tradurre l’equazione (1) e la sua inversa direttamente in PHP.
Per mantenere il progetto in puro PHP, senza estensioni native né dipendenze esterne, implementiamo la DCT manualmente su blocchi 8×8.
Questo approccio è sufficientemente veloce per immagini fino a qualche megapixel e, soprattutto, è trasparente e facile da comprendere e modificare.
Il progetto è organizzato con Composer e autoload PSR-4, ma la classe Dct è completamente autonoma e non richiede librerie esterne.
Se in futuro vorrai estendere il sistema con analisi matriciali (es. box counting o misure di distorsione), potrai integrare MathPHP, ma non è necessaria per la DCT.
{
"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/"
}
}
}
Dopo aver definito il nostro file composer.json procediamo all’installazione. Andiamo su terminale e digitiamo il comando:
composer install
A questo punto composer creerà il file composer.lock e la cartella vendor che però non installerà pacchetti da Packagist perché non stiamo includendo dipendenze.
Ora possiamo finalmente scrivere la classe Dct. Ricordo che è preferibile inserire i commenti al codice, come standard in inglese:
<?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()
{
}
}
Questa classe è il cuore del progetto. Racchiude l’algoritmo della Trasformata Coseno Discreta bidimensionale applicata a blocchi 8×8 pixel, lo stesso schema impiegato dallo standard JPEG. All’avvio del metodo forward, la classe controlla che l’array ricevuto contenga esattamente otto righe e otto colonne; in caso contrario, lancia un’eccezione per evitare che un errore di dimensionamento si propaghi silenziosamente.
Una volta verificata la forma, per ogni coppia di indici di frequenza , calcola la doppia somma definita dalla formula canonica della DCT: prende ciascun valore di luminosità
, lo moltiplica per due coseni — uno in funzione di
e della coordinata
, l’altro in funzione di
e della coordinata
— e accumula il risultato. Terminata la scansione del blocco, applica il fattore di normalizzazione
, dove
e
per
, così che il coefficiente
conservi la media luminosa e tutti gli altri rappresentino, con la corretta scala, le componenti di dettaglio via via più fini. Il metodo restituisce quindi una matrice 8×8 di coefficienti in virgola mobile: è su questa rappresentazione in frequenza che più avanti incideremo i bit del watermark.
Il percorso inverso è affidato al metodo inverse. Anche qui, prima di qualsiasi elaborazione, la classe impone la verifica della forma del blocco. Per ogni coppia di coordinate di pixel , ricostruisce la luminanza sommando le 64 frequenze, ciascuna ponderata dal prodotto dei coseni e dai fattori di normalizzazione
. Il valore finale viene moltiplicato per
, arrotondato all’intero più vicino e limitato all’intervallo 0–255, così da evitare che eventuali errori numerici facciano uscire il risultato dallo spazio colore.
L’implementazione segue fedelmente la definizione matematica, senza ricorrere ad accelerazioni FFT o SIMD, privilegiando la chiarezza didattica rispetto alle prestazioni. Il costruttore è privato, la classe non può essere istanziata, e tutti i metodi sono statici e privi di stato interno: in questo modo la DCT si comporta come una funzione pura, facilmente testabile e riutilizzabile. Quando, nei prossimi paragrafi, la useremo per incastonare un bit modificando il segno di un coefficiente a bassa frequenza, potremo essere certi che la trasformazione diretta e inversa siano numericamente coerenti e che l’alterazione resti confinata ai valori che intendiamo manipolare.
La classe Watermarker è la regista che orchestra la fase più delicata dell’intero progetto. Riceve in ingresso o in uscita una sequenza di blocchi 8×8 di coefficienti DCT già calcolati dalla classe Dct e, senza occuparsi della lettura fisica del file JPEG, si limita a manipolare quei numeri per nascondere o recuperare un testo ASCII.
<?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() {}
}
Quando viene invocato il metodo embed, il chiamante le passa l’array dei blocchi DCT e il messaggio da inserire. Il metodo trasforma ogni carattere in 8 bit, e ciascun bit viene replicato 25 volte (come specificato dalla costante REDUNDANCY), così da poter essere letto con un voto di maggioranza e resistere ad alterazioni dovute alla compressione JPEG. Il flusso così ridondato scorre blocco dopo blocco e si appoggia su 8 coordinate a bassa frequenza, definite nell’array costante EMBED_COORDS. Queste posizioni sono state scelte perché non intaccano la percezione visiva ma tendono a sopravvivere anche a compressioni JPEG moderate (qualità 60–80).
Per ogni coefficiente selezionato, la funzione privata forceSign() impone un segno al coefficiente: positivo per rappresentare 0, negativo per rappresentare 1. Questo approccio, più robusto rispetto all’uso della parità, garantisce che l’informazione rimanga leggibile anche dopo un passaggio di quantizzazione JPEG. Se l’immagine non contiene abbastanza blocchi per rappresentare l’intero messaggio, viene lanciata un’eccezione con un messaggio esplicito.
Il percorso inverso è affidato al metodo extract. Anche qui, la classe legge il segno degli stessi 8 coefficienti in ogni blocco, ricostruendo un lungo vettore di zeri e uno. I bit vengono poi raggruppati secondo la ridondanza e passano attraverso una funzione di voto di maggioranza (majority()), che recupera il singolo bit originario. Ogni gruppo di 8 bit restituisce un carattere, fino a incontrare il terminatore NUL che segnala la fine del messaggio. L’insieme dei caratteri così ottenuti forma il messaggio originale.
Le funzioni di supporto stringToBitStream, bitsToInt e majority incapsulano i dettagli binari, lasciando i metodi pubblici brevi e leggibili. Come la classe Dct, anche Watermarker è priva di stato interno e non può essere istanziata: tutti i metodi sono statici. Questo approccio funzionale facilita i test automatici e l’uso in ambienti concorrenti. In sintesi, la classe Watermarker realizza concretamente i principi matematici descritti in precedenza: ridondanza ciclica, codifica del bit tramite segno, terminazione esplicita del messaggio, in un flusso compatto e robusto pensato per sopravvivere alle trasformazioni tipiche delle immagini pubblicate online.
La classe ImageIO è il ponte tra la teoria matematica e il file JPEG concreto.
<?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()
{
}
}
Quando si invoca il metodo statico embed, il codice apre l’immagine con l’estensione GD e ricava la componente Y di luminanza — l’unica coinvolta nel watermarking — lasciando invariati i canali cromatici Cb e Cr per garantire che la firma resti invisibile all’occhio umano. La matrice Y viene poi suddivisa, riga per riga, in una griglia di blocchi 8×8. Ciascun blocco viene trasformato in frequenza con Dct::forward, così da permettere alla classe Watermarker di incidere i bit del messaggio forzando il segno di 8 coefficienti a bassa frequenza scelti appositamente.
Terminata l’incisione, ImageIO riconverte ogni blocco in pixel con Dct::inverse, ricostruendo una nuova matrice Y marcata. A questo punto la classe ricalcola i valori RGB combinando la luminanza modificata con le crominanze originali: per ogni pixel applica le formule inverse dello spazio colore secondo lo standard ITU-R BT.601, assicurandosi che i risultati restino nell’intervallo 0–255.
Il bitmap completo viene infine salvato in JPEG alla qualità desiderata, utilizzando Imagick per disattivare il subsampling cromatico e massimizzare la fedeltà. Il risultato è un file visivamente identico all’originale ma contenente, nei suoi coefficienti DCT, una firma testuale invisibile ma resistente.
Il percorso inverso, affidato al metodo extract, compie l’operazione opposta: ricarica l’immagine, isola la luminanza, la suddivide in blocchi, calcola la DCT di ciascuno e passa la sequenza di coefficienti a Watermarker::extract. Questo metodo legge il segno dei coefficienti, applica il voto di maggioranza tra le ripetizioni di ciascun bit, e ricostruisce la stringa originaria fino al carattere NUL di terminazione.
Tutta la logica di I/O, apertura dei file, conversione colore e salvataggio JPEG è confinata nella classe ImageIO, mentre Dct e Watermarker restano funzioni matematiche pure. Questa divisione chiara delle responsabilità favorisce la modularità del codice, la testabilità e l’integrazione in qualunque pipeline di elaborazione immagini.
Inserimento di un watermark in un’immagine
#!/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 questo script CLI (embed.php) definiamo, attraverso l’opzione –msg, il messaggio da imprimere nell’immagine e invochiamo il metodo statico ImageIO::embed() della classe ImageIO. Lo script accetta i seguenti parametri:
–q (facoltativo): la qualità JPEG per il salvataggio (default: 90).
–src: il percorso dell’immagine JPEG sorgente (es. original.jpg);
–out: il percorso del file JPEG di destinazione che conterrà il watermark;
–msg (facoltativo): il testo ASCII da inserire (max 10 caratteri);
php embed.php --src=original.jpg --out=watermarked.jpg --msg="RENOR" --q=95
ci tornerà:
Watermark "RENOR" embedded successfully into watermarked.jpg at quality 95
Da qui estraiamo il testo del Watermark contenuto nell’immagine watermarked.jpg appena creata da 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);
}
Lo script extract.php completa il ciclo di utilizzo del watermark invisibile permettendo l’estrazione del messaggio precedentemente incorporato. Si tratta di un semplice script da linea di comando che, dato un file JPEG marcato, ne legge il contenuto nascosto decodificando il segno di specifici coefficienti DCT modificati. Il messaggio, recuperato in modo robusto anche dopo compressioni JPEG con qualità medio-alta, viene ricostruito tramite voto di maggioranza sui bit ripetuti. Basta eseguire il comando con l’opzione –src per indicare l’immagine da analizzare, e lo script restituirà a terminale la stringa originariamente incorporata.
Possiamo landiare il comando:
php extract.php --src=watermarked.jpg
otterremo:
Recovered message: "RENOR"
Vale il caso anche di analizzare le fotografie:
A sinistra l’immagine originale, a destra quella watermarked.
Possiamo notare che effettivamente, come abbiamo ampiamente descritto non ci sono elementi visibili ad occhi nudo e riconducibili all’applicazione di un Watermark che resta tuttavia ancora decifrabile utilizzando l’extract.
Chiaramente il progetto è puramente dimostrativo ed utilizzarlo a livello enterprise o per progetti in produzione richiederebbe ulteriore raffinamento.