Водяные знаки для интернет-магазина: кейс пакетной обработки сотен тысяч изображений

2014-05-25

← Назад в блог

Это старый, но показательный кейс из практики: нужно было наладить обработку нескольких сотен тысяч изображений для интернет-магазинов, где на выходе требовались водяные знаки, ресайз и предсказуемый массовый процесс без ручной работы.

1. Кейс с точки зрения бизнеса

Задача была не в "красивой картинке в фотошопе", а в воспроизводимом пайплайне для каталога:

  • обрабатывать большие объёмы изображений (сотни тысяч файлов),
  • накладывать водяной знак так, чтобы он читался на разных фонах,
  • делать ресайз для нужных размеров карточек/каталога,
  • складывать готовые результаты в AWS S3,
  • обойтись без оконного интерфейса и ручной работы дизайнеров.

Отдельное ограничение: магазинов было много, поэтому водяные знаки нужно было генерировать под разные названия и использовать дальше в автоматическом режиме.

В итоге получился batch-процесс, который можно было запускать на больших массивах данных и получать однотипный результат без "человеческого фактора".

Отправить бриф

2. Поиск решения: какой водяной знак вообще работает

Ниже - путь подбора решения на примерах. Цель была практичная: сделать знак заметным, но не убить читаемость изображения и текста на нём.

Почему обычная полупрозрачная надпись работает плохо

Белая полупрозрачная надпись теряется на светлом фоне, тёмная - на тёмном. Один цвет не даёт стабильного результата.

Пример плохой читаемости однослойного водяного знака на изображении

Рабочий вариант: светлая надпись + тёмная полупрозрачная тень

Лучше сработала комбинация из белой полупрозрачной надписи и чёрной полупрозрачной тени. Такой знак заметен и на светлых, и на тёмных участках.

Водяной знак с белой надписью и тёмной тенью

Если на изображении есть текст, важно не перекрыть его полностью. Комбинация прозрачности надписи и тени помогает сохранить читаемость.

Пример водяного знака поверх изображения с текстом

Почему один знак по центру - слабая защита

Если знак один и всегда в одном месте (обычно по центру), его сравнительно легко убрать локальной обработкой и наложить новый поверх.

Пример уязвимости одиночного водяного знака в центре

Итоговый подход: замощение изображения

Чтобы усложнить удаление, знак размещался не точечно, а сеткой по всей площади изображения. Центральный элемент служил точкой привязки, остальные раскладывались вокруг. Поворот надписи делал результат визуально аккуратнее.

Замощение изображения повёрнутыми водяными знаками

Автор картинок с ягодами и апельсином - Кирилл Краснов. Shutterstock: http://www.shutterstock.com/cat.mhtml?gallery_id=419308.

3. Технические детали

Это реализация 2014 года. На тот момент Cloudflare Images ещё не существовал, а сегодня для многих подобных задач логично брать решение "из коробки" (хранение, ресайз/трансформации, доставка). В этом кейсе собственная реализация остаётся в презентативных целях: показать подход к batch-обработке, когда нужно было собрать всё вручную.

Тогда пайплайн выглядел так:

  • сгенерировать PNG-водяной знак для конкретного магазина,
  • прочитать исходное изображение,
  • сделать ресайз под нужные размеры,
  • наложить плитку водяных знаков,
  • сохранить результат и выгрузить в AWS S3.

3.1. Генерация водяного знака (Perl + ImageMagick)

Ниже - скрипт генерации PNG-водяного знака с тенью и поворотом. Это основа для дальнейшего замощения.

#!/usr/bin/perl -w

use strict;
use Image::Magick;

die `pod2text $0` unless @ARGV;

# Создаём холст с прозрачным фоном
my $image = Image::Magick->new(size=>'1000x70');
$image->ReadImage('canvas:transparent');

# Наносим надпись чёрным цветом с прозрачностью 30%
$image->Annotate(
	text      => $ARGV[0],
	geometry  => "+50+50",
	pen       => $image->QueryColorname('rgba(0,0,0,0.3)'),
	font      => 'Bookman-Demi',
	pointsize => 40,
	kerning   => 3,
);

# Размываем - это будет тень
$image->Blur(
	radius  => 0,
	sigma   => 6,
	channel => 'RGBA'
);

# Создаём маску под саму надпись
my $mask = Image::Magick->new(size=>'1000x70');
$mask->ReadImage('canvas:transparent');
$mask->Annotate(
	text      => $ARGV[0],
	geometry  => "+50+50",
	pen       =>  $image->QueryColorname('rgba(255,255,255,1)'),
	font      => 'Bookman-Demi',
	pointsize => 40,
	kerning   => 3,
);

# Очищаем центр тени по маске, чтобы не было "грязи" под белым текстом
$image->Composite(
	image   => $mask,
	mask    => $mask,
	compose => 'Clear',
);

# Наносим белую полупрозрачную надпись поверх
$image->Annotate(
	text      => $ARGV[0],
	geometry  => "+50+50",
	pen       => $image->QueryColorname('rgba(255,255,255,0.3)'),
	font      => 'Bookman-Demi',
	pointsize => 40,
	kerning   => 3,
);

$image->Trim();

# Поворачиваем надпись
$image->Rotate(
	degrees    => -45,
	background => 'transparent',
);

# Сохраняем в PNG
if ($ARGV[1]) {
	$image->Write("$ARGV[1]");
}
else {
	$image->Write("$ARGV[0].png");
}

3.2. Наложение плитки водяных знаков на изображение

Второй скрипт вычисляет сетку, центрирует её относительно исходной картинки и замащивает изображение водяными знаками.

#!/usr/bin/perl -w

use strict;
use Image::Magick;
use POSIX qw/ceil/;

die `pod2text $0` unless @ARGV;

# Исходная картинка
my $image = Image::Magick->new;
$image->Read("jpg:$ARGV[0]");
my ($image_height, $image_width) = $image->Get('base-rows', 'base-columns');

# Водяной знак
my $watermark = Image::Magick->new;
$watermark->Read("png:$ARGV[1]");
my ($watermark_height, $watermark_width) = $watermark->Get('base-rows', 'base-columns');

# Холст нужен на случай, если знак больше изображения
my $canvas_height = ( $image_height > $watermark_height ? $image_height : $watermark_height );
my $canvas_width  = ( $image_width  > $watermark_width  ? $image_width  : $watermark_width  );

my $canvas = Image::Magick->new;
$canvas->Set(size => "${canvas_width}x${canvas_height}");
$canvas->Read('NULL:');

my $tiled_layer = Image::Magick->new;
$tiled_layer->Set(size => "${canvas_width}x${canvas_height}");
$tiled_layer->Read('NULL:');

# Размер сетки
my $tile_columns = ceil($image_width / $watermark_width);
my $tile_rows    = ceil($image_height / $watermark_height);

# Делаем сетку нечётной, чтобы был центральный элемент
$tile_columns++ if $tile_columns % 2 == 0;
$tile_rows++    if $tile_rows % 2 == 0;

my $center_col = ceil($tile_columns / 2);
my $center_row = ceil($tile_rows / 2);
my $center_x = ($image_width - $watermark_width) * 0.5;
my $center_y = ($image_height - $watermark_height) * 0.5;

for my $col (1 .. $tile_columns) {
	for my $row (1 .. $tile_rows) {
		my $x = $center_x + ($col - $center_col) * $watermark_width;
		my $y = $center_y + ($row - $center_row) * $watermark_height;

		$tiled_layer->Composite(
			image   => $watermark,
			compose => 'over',
			x       => $x,
			y       => $y,
			gravity => 'NorthWest',
		);
	}
}

$canvas->Composite(
	image   => $image,
	compose => 'over',
	gravity => 'center',
);

$canvas->Composite(
	image   => $tiled_layer,
	compose => 'over',
);

$canvas->Crop(
	x      => ($canvas_width - $image_width) * 0.5,
	y      => ($canvas_height - $image_height) * 0.5,
	width  => $image_width,
	height => $image_height,
);

$canvas->Set(quality => 88);
$canvas->Write("jpg:$ARGV[2]");

3.3. Где здесь ресайз и хранение в S3

В продовом процессе эти скрипты были частью более широкого batch-пайплайна: после чтения исходного файла выполнялся ресайз под нужные размеры каталога, затем накладывался водяной знак, и уже готовые версии отправлялись в AWS S3. То есть задача решалась не как "один эффект", а как потоковая подготовка ассетов для магазина.

Ниже - упрощённый пример запуска из консоли (показан для понимания процесса):

# 1) Сгенерировать PNG-водяной знак для магазина
perl gen-watermark.pl "example-shop.ru" /tmp/example-shop-watermark.png

# 2) Подготовить версию изображения (ресайз делался в пайплайне)
# 3) Наложить водяной знак
perl watermark.pl input.jpg /tmp/example-shop-watermark.png output.jpg

# 4) Далее готовый файл выгружался в AWS S3

Финальный пример результата с замощёнными водяными знаками

Сейчас для новых проектов я бы в первую очередь смотрел в сторону Cloudflare Images или аналогичных managed-решений. Но как кейс про кастомную массовую обработку изображений, этот пример всё ещё полезен: особенно когда нужно понять логику, ограничения и компромиссы "под капотом".