Почему баг в imageproc потребовал изменения API в image-rs

от автора

Речь пойдет о двух крейтах: 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() для RGB

Что будет в случае, если использовать только метод blend() для RGB

В итоге алгоритм должен учитывать два случая:

  • пиксели с альфа-каналом => использовать 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/