Речь пойдет о двух крейтах: 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
ссылка на оригинал статьи https://habr.com/ru/articles/1023514/