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

Комментарии (0)