Речь пойдет о двух крейтах: imageproc и image. imageproc - библиотека обработки изображений, основанная на библиотеке image.
При рендере текста в imageproc я столкнулся с багом: алгоритм корректно работал для RGB, но ломался для RGBA.
Попытка исправить его привела к неожиданному результату - фикс оказался невозможен без изменения API image-rs.
Разберём, почему так произошло.
Где и как проявился баг?
Проблема проявилась при рендере полупрозрачного текста.
Примеры:
Обнаружена проблема отрисовки текста на изображениях с альфа-каналом (RGBA, LumaA).
Проблема проявляется только при наличии альфа-канала.
Ключевое наблюдение:
-
в RGB всё работает корректно
-
в RGBA появляются артефакты
На изображении выше:
-
Пример 1 (низкая альфа): текст практически не виден и отображается некорректно
-
Пример 2 (альфа = 255): всё работает корректно
-
Пример 3 (полупрозрачный цвет): появляются артефакты
Это указывает на то, что алгоритм отрисовки корректно работает только в случае отсутствия прозрачности.
Реализация text_draw_mut
pub fn draw_text_mut<C>(
canvas: &mut C,
color: C::Pixel,
x: i32,
y: i32,
scale: impl Into<PxScale> + Copy,
font: &impl Font,
text: &str,
) where
C: Canvas,
<C::Pixel as Pixel>::Subpixel: Into<f32> + Clamp<f32>,
{
let image_width = canvas.width() as i32;
let image_height = canvas.height() as i32;
layout_glyphs(scale, font, text, |g, bb| {
let x_shift = x + bb.min.x.round() as i32;
let y_shift = y + bb.min.y.round() as i32;
g.draw(|gx, gy, gv| {
let image_x = gx as i32 + x_shift;
let image_y = gy as i32 + y_shift;
if (0..image_width).contains(&image_x) && (0..image_height).contains(&image_y) {
let image_x = image_x as u32;
let image_y = image_y as u32;
let pixel = canvas.get_pixel(image_x, image_y);
let gv = gv.clamp(0.0, 1.0);
let weighted_color = weighted_sum(pixel, color, 1.0 - gv, gv);
canvas.draw_pixel(image_x, image_y, weighted_color);
}
})
});
}
Разбор текущей реализации
Изначально мы понимаем, что проблема в отрисовке пикселя, значит, ключевая проблема находится здесь:
// результирующий цвет пикселя при отрисовке текста
let weighted_color = weighted_sum(pixel, color, 1.0 - gv, gv);
Функция weighted_sum() использует gv для изменения RGB-компонент, но полностью игнорирует корректную обработку альфа-канала.
По документации метода draw() становится понятно, что gv (glyph visible) - отвечает за долю видимости, где значение 1.0 - полностью непрозрачно, 0.0 - полностью прозрачно.
/// Draw this glyph outline using a pixel & coverage handling function.
///
/// The callback will be called for each `(x, y)` pixel coordinate inside the bounds
/// with a coverage value indicating how much the glyph covered that pixel.
///
/// A coverage value of `0.0` means the pixel is totally uncovered by the glyph.
/// A value of `1.0` or greater means fully covered.
g.draw(|gx, gy, gv| {
...
})
weighted_sum() смешивает цвета между фоном и текстом следующим образом: чем ближе gv к 1.0, тем виднее пиксель глифа.
Функция weighted_sum() решает задачу смешивания цветов для пикселей без альфа-канала.
Она корректна для RGB, но:
-
не учитывает альфа-канал
-
интерпретирует
gvкак вес RGB-компонент
В результате для RGBA игнорируется альфа-канал и это неверно.
Поиск решения
В крейте image уже есть метод, который делает именно то, что нужно:Pixel::blend(&mut self, other)
Согласно документации:/// Blend the color of a given pixel into ourself, taking into account alpha channels.
Это означает, что проблема не в отсутствии логики, а в том, что она не используется в текущем алгоритме — и её нужно встроить в алгоритм отрисовки текста. Оказалось, что семантика целевой функции уже ожидает trait Pixel, поэтому легко можно вызвать метод blend.
Математическая часть. Случай RGBA
gv — это коэффициент покрытия глифа (0.0–1.0).
В данный момент gv используется для изменения итогового цвета (воздействие на RGB каналы), но нам нужно воздействовать только на alpha-канал.
Для RGBA корректно применять gv к альфа-каналу:
alpha' = alpha * gv
Таким образом gv должен влиять на прозрачность (только на alpha-канал), а не на цвет.
У Pixel есть метод map_with_alpha(), позволяющий отдельно управлять альфа-каналом.
/// Apply the function ```f``` to each channel except the alpha channel.
/// Apply the function ```g``` to the alpha channel.
fn map_with_alpha<F, G>(&self, f: F, g: G) -> Self
Это позволяет применить gv только к alpha:
// получить цвет с корректным альфа-каналом!
let color = color.map_with_alpha(|f| f, |g| g * gv);
// Есть проблема с типами: g - Subpixel, а gv - f32.
Но тут сталкиваемся с проблемой типобезопасности, которую удаётся удачно решить. Замечаю, что у нас в семантике функции есть ограничение по трейту Clamp<f32>, который может вернуть тот же тип Subpixel. И остается итоговое решение:
let color = color.map_with_alpha(|f| f, |g| Clamp::clamp(g.into() * gv));
// Clamp вернет нужный тип. Не нужны костыли
Два случая: с альфа-каналом и без альфа-канала
В итоге алгоритм должен учитывать два случая:
-
пиксели с альфа-каналом => использовать blend()
-
пиксели без альфа-канала => использовать weighted_sum().
Вот тут я долго искал нужный API. Но не нашел метод типа такого:has_alpha() -> bool. Вот только на что можно было опираться:
pub trait Pixel: Copy + Clone {
...
/// A string that can help to interpret the meaning each channel
/// See [gimp babl](http://gegl.org/babl/).
const COLOR_MODEL: &'static str;
...
}
В качестве временного решения можно было определить наличие альфа-канала через COLOR_MODEL:
let has_alpha = match C::Pixel::COLOR_MODEL {
"RGBA" | "YA" => true,
_ => false,
};
Только этот вариант супер спорный. Хардкорно матчим строку со строкой из исходников другой библиотеки, которые могут поменяться несогласованно...
Таким образом, корректное решение невозможно без изменения API image-rs.
Изменение API в image-rs
Понятно, что это зона ответственности должна быть в крейте image, на базе которого написан imageproc, поэтому я сделал PR в image-rs с добавлением в трейт Pixel новой константы HAS_ALPHA: bool.
pub trait Pixel: Copy + Clone {
...
const HAS_ALPHA: bool;
...
}
В image типы пикселей генерируются через макрос, который уже знает количество каналов.
Это позволяет добавить константу HAS_ALPHA без дублирования логики:
macro_rules! define_colors {
...
const HAS_ALPHA: bool = $alphas > 0;
...
}
Это переносит ответственность за тип пикселя туда, где ей и место — в image-rs.
PR в image-rs (изменение API): https://github.com/image-rs/image/pull/2535
Итоговое решение
pub fn draw_text_mut<C>(
canvas: &mut C,
color: C::Pixel,
x: i32,
y: i32,
scale: impl Into<PxScale> + Copy,
font: &impl Font,
text: &str,
) where
C: Canvas,
<C::Pixel as Pixel>::Subpixel: Into<f32> + Clamp<f32>,
{
let image_width = canvas.width() as i32;
let image_height = canvas.height() as i32;
layout_glyphs(scale, font, text, |g, bb| {
let x_shift = x + bb.min.x.round() as i32;
let y_shift = y + bb.min.y.round() as i32;
g.draw(|gx, gy, gv| {
let image_x = gx as i32 + x_shift;
let image_y = gy as i32 + y_shift;
if (0..image_width).contains(&image_x) && (0..image_height).contains(&image_y) {
let image_x = image_x as u32;
let image_y = image_y as u32;
let mut pixel = canvas.get_pixel(image_x, image_y);
let gv = gv.clamp(0.0, 1.0);
if C::Pixel::HAS_ALPHA {
// случай для альфа-канала
let color = color.map_with_alpha(|f| f, |g| Clamp::clamp(g.into() * gv));
pixel.blend(&color);
} else {
// случай без альфа-канала
pixel = weighted_sum(pixel, color, 1.0 - gv, gv);
}
canvas.draw_pixel(image_x, image_y, pixel);
}
})
});
}
PR в imageproc (фикс алгоритма)
Итог
-
проблема была не в формуле смешивания
-
проблема была в отсутствии информации о типе пикселя
Фикс потребовал:
-
изменения алгоритма в imageproc
-
расширения API в image
Проблема оказалась не в реализации, а в ограничениях API.
Локальный баг привёл к изменению контракта библиотеки.
Сейчас открыт к предложениям по backend-разработке (Rust / Go).
Интересны задачи, связанные с:
-
highload
-
concurrency
-
distributed systems
-
performance
GitHub: https://github.com/var4yn
Telegram: https://t.me/var4yn
Автор: icpc_master_coding
