diff options
author | kleines Filmröllchen <filmroellchen@serenityos.org> | 2022-08-17 12:18:16 +0200 |
---|---|---|
committer | Linus Groh <mail@linusgroh.de> | 2022-09-03 16:57:37 +0100 |
commit | c1c2e6f7d7a32f04603e5a0f6567c31e62910a43 (patch) | |
tree | 347e757a26d10128a5f99821aac9104bef3ba94b | |
parent | f9b08272db411f807dd203bab025b5fccbe3f494 (diff) | |
download | serenity-c1c2e6f7d7a32f04603e5a0f6567c31e62910a43.zip |
PixelPaint: Introduce a vectorscope
Vectorscopes are a standard tool in professional video/film color
grading. *Very* simply, the Vectorscope shows image colors with hue as
the angle and saturation as the radius; brightness for each point in the
scope is determined by the number of "color vectors" at that point. More
specifically, the Vectorscope shows a 2D UV histogram of the image,
where U and V are the chroma ("color") channels of the image.
Co-authored-by: MacDue <macdue@dueutil.tech>
-rw-r--r-- | Userland/Applications/PixelPaint/CMakeLists.txt | 1 | ||||
-rw-r--r-- | Userland/Applications/PixelPaint/MainWidget.cpp | 8 | ||||
-rw-r--r-- | Userland/Applications/PixelPaint/MainWidget.h | 2 | ||||
-rw-r--r-- | Userland/Applications/PixelPaint/PixelPaintWindow.gml | 13 | ||||
-rw-r--r-- | Userland/Applications/PixelPaint/VectorscopeWidget.cpp | 140 | ||||
-rw-r--r-- | Userland/Applications/PixelPaint/VectorscopeWidget.h | 131 |
6 files changed, 295 insertions, 0 deletions
diff --git a/Userland/Applications/PixelPaint/CMakeLists.txt b/Userland/Applications/PixelPaint/CMakeLists.txt index 48525211e7..7f75f18ed3 100644 --- a/Userland/Applications/PixelPaint/CMakeLists.txt +++ b/Userland/Applications/PixelPaint/CMakeLists.txt @@ -72,6 +72,7 @@ set(SOURCES Tools/Tool.cpp Tools/WandSelectTool.cpp Tools/ZoomTool.cpp + VectorscopeWidget.cpp main.cpp ) diff --git a/Userland/Applications/PixelPaint/MainWidget.cpp b/Userland/Applications/PixelPaint/MainWidget.cpp index 00c0f91051..2b75180022 100644 --- a/Userland/Applications/PixelPaint/MainWidget.cpp +++ b/Userland/Applications/PixelPaint/MainWidget.cpp @@ -46,6 +46,7 @@ MainWidget::MainWidget() m_palette_widget = *find_descendant_of_type_named<PixelPaint::PaletteWidget>("palette_widget"); m_histogram_widget = *find_descendant_of_type_named<PixelPaint::HistogramWidget>("histogram_widget"); + m_vectorscope_widget = *find_descendant_of_type_named<PixelPaint::VectorscopeWidget>("vectorscope_widget"); m_layer_list_widget = *find_descendant_of_type_named<PixelPaint::LayerListWidget>("layer_list_widget"); m_layer_list_widget->on_layer_select = [&](auto* layer) { auto* editor = current_image_editor(); @@ -74,6 +75,7 @@ MainWidget::MainWidget() m_tab_widget->remove_tab(image_editor); if (m_tab_widget->children().size() == 0) { m_histogram_widget->set_image(nullptr); + m_vectorscope_widget->set_image(nullptr); m_layer_list_widget->set_image(nullptr); m_layer_properties_widget->set_layer(nullptr); m_palette_widget->set_image_editor(nullptr); @@ -88,12 +90,14 @@ MainWidget::MainWidget() auto& image_editor = verify_cast<PixelPaint::ImageEditor>(widget); m_palette_widget->set_image_editor(&image_editor); m_histogram_widget->set_image(&image_editor.image()); + m_vectorscope_widget->set_image(&image_editor.image()); m_layer_list_widget->set_image(&image_editor.image()); m_layer_properties_widget->set_layer(image_editor.active_layer()); window()->set_modified(image_editor.is_modified()); image_editor.on_modified_change = [this](bool modified) { window()->set_modified(modified); m_histogram_widget->image_changed(); + m_vectorscope_widget->image_changed(); }; if (auto* active_tool = m_toolbox->active_tool()) image_editor.set_active_tool(active_tool); @@ -161,6 +165,7 @@ void MainWidget::initialize_menubar(GUI::Window& window) editor.undo_stack().set_current_unmodified(); m_histogram_widget->set_image(image); + m_vectorscope_widget->set_image(image); m_layer_list_widget->set_image(image); m_layer_list_widget->set_selected_layer(bg_layer); } @@ -978,15 +983,18 @@ ImageEditor& MainWidget::create_new_editor(NonnullRefPtr<Image> image) if (image_rectangle.contains(mouse_position)) { m_statusbar->set_override_text(mouse_position.to_string()); m_histogram_widget->set_color_at_mouseposition(current_image_editor()->image().color_at(mouse_position)); + m_vectorscope_widget->set_color_at_mouseposition(current_image_editor()->image().color_at(mouse_position)); } else { m_statusbar->set_override_text({}); m_histogram_widget->set_color_at_mouseposition(Color::Transparent); + m_vectorscope_widget->set_color_at_mouseposition(Color::Transparent); } }; image_editor.on_leave = [&]() { m_statusbar->set_override_text({}); m_histogram_widget->set_color_at_mouseposition(Color::Transparent); + m_vectorscope_widget->set_color_at_mouseposition(Color::Transparent); }; image_editor.on_set_guide_visibility = [&](bool show_guides) { diff --git a/Userland/Applications/PixelPaint/MainWidget.h b/Userland/Applications/PixelPaint/MainWidget.h index 565ee065e5..ce1b06818b 100644 --- a/Userland/Applications/PixelPaint/MainWidget.h +++ b/Userland/Applications/PixelPaint/MainWidget.h @@ -19,6 +19,7 @@ #include "ToolPropertiesWidget.h" #include "ToolboxWidget.h" #include "Tools/Tool.h" +#include "VectorscopeWidget.h" #include <LibGUI/Action.h> #include <LibGUI/ComboBox.h> #include <LibGUI/Forward.h> @@ -62,6 +63,7 @@ private: RefPtr<ToolboxWidget> m_toolbox; RefPtr<PaletteWidget> m_palette_widget; RefPtr<HistogramWidget> m_histogram_widget; + RefPtr<VectorscopeWidget> m_vectorscope_widget; RefPtr<LayerListWidget> m_layer_list_widget; RefPtr<LayerPropertiesWidget> m_layer_properties_widget; RefPtr<ToolPropertiesWidget> m_tool_properties_widget; diff --git a/Userland/Applications/PixelPaint/PixelPaintWindow.gml b/Userland/Applications/PixelPaint/PixelPaintWindow.gml index c4a641899d..668a3e0a60 100644 --- a/Userland/Applications/PixelPaint/PixelPaintWindow.gml +++ b/Userland/Applications/PixelPaint/PixelPaintWindow.gml @@ -77,6 +77,19 @@ } } + @GUI::GroupBox { + title: "Vectorscope" + min_height: 80 + layout: @GUI::VerticalBoxLayout { + margins: [6] + } + + @PixelPaint::VectorscopeWidget { + name: "vectorscope_widget" + preferred_height: "fit" + } + } + @PixelPaint::ToolPropertiesWidget { name: "tool_properties_widget" max_height: 144 diff --git a/Userland/Applications/PixelPaint/VectorscopeWidget.cpp b/Userland/Applications/PixelPaint/VectorscopeWidget.cpp new file mode 100644 index 0000000000..58b7ce1f83 --- /dev/null +++ b/Userland/Applications/PixelPaint/VectorscopeWidget.cpp @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "VectorscopeWidget.h" +#include "Layer.h" +#include <AK/Math.h> +#include <AK/Types.h> +#include <LibGUI/Event.h> +#include <LibGUI/Painter.h> +#include <LibGUI/Widget.h> +#include <LibGfx/AntiAliasingPainter.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Palette.h> +#include <LibGfx/SystemTheme.h> +#include <LibGfx/TextAlignment.h> +#include <LibGfx/TextElision.h> + +REGISTER_WIDGET(PixelPaint, VectorscopeWidget); + +namespace PixelPaint { + +void VectorscopeWidget::image_changed() +{ + (void)rebuild_vectorscope_data(); + rebuild_vectorscope_image(); + update(); +} + +ErrorOr<void> VectorscopeWidget::rebuild_vectorscope_data() +{ + if (!m_image) + return {}; + + m_vectorscope_data.fill({}); + VERIFY(AK::abs(m_vectorscope_data[0][0]) < 0.01f); + auto full_bitmap = TRY(m_image->try_compose_bitmap(Gfx::BitmapFormat::BGRA8888)); + + for (size_t x = 0; x < static_cast<size_t>(full_bitmap->width()); ++x) { + for (size_t y = 0; y < static_cast<size_t>(full_bitmap->height()); ++y) { + auto yuv = full_bitmap->get_pixel(x, y).to_yuv(); + auto u_index = u_v_to_index(yuv.u); + auto v_index = u_v_to_index(yuv.v); + m_vectorscope_data[u_index][v_index]++; + } + } + + auto maximum = full_bitmap->width() * full_bitmap->height() * pixel_percentage_for_max_brightness * pixel_percentage_for_max_brightness; + + for (size_t i = 0; i < m_vectorscope_data.size(); ++i) { + for (size_t j = 0; j < m_vectorscope_data[i].size(); ++j) { + m_vectorscope_data[i][j] = AK::sqrt(m_vectorscope_data[i][j]) / maximum; + } + } + + return {}; +} + +void VectorscopeWidget::rebuild_vectorscope_image() +{ + m_vectorscope_image = MUST(Gfx::Bitmap::try_create(Gfx::BitmapFormat::BGRA8888, size())); + m_vectorscope_image->fill(Color::Transparent); + + Gfx::Painter base_painter(*m_vectorscope_image); + Gfx::AntiAliasingPainter painter(base_painter); + + auto const scope_size = min(height(), width()); + auto const min_scope_size = parent_widget()->min_height().as_int(); + auto const color_vector_scale = scope_size / static_cast<float>(min_scope_size); + auto const size_1x1 = Gfx::FloatSize { 2.5f, 2.5f } * static_cast<float>(color_vector_scale); + + base_painter.translate(width() / 2, height() / 2); + painter.translate(static_cast<float>(width()) / 2.0f, static_cast<float>(height()) / 2.0f); + for (size_t u_index = 0; u_index < u_v_steps; ++u_index) { + for (size_t v_index = 0; v_index < u_v_steps; ++v_index) { + auto const color_vector = ColorVector::from_indices(u_index, v_index); + auto const brightness = m_vectorscope_data[u_index][v_index]; + if (brightness < 0.0001f) + continue; + auto const pseudo_rect = Gfx::FloatRect::centered_at(color_vector.to_vector(scope_size) * 2.0f, size_1x1); + auto color = Color::from_yuv(0.6f, color_vector.u, color_vector.v); + color = color.saturated_to(1.0f - min(brightness, 1.0f)); + color.set_alpha(static_cast<u8>(min(AK::sqrt(brightness), alpha_range) * NumericLimits<u8>::max() / alpha_range)); + painter.fill_rect(pseudo_rect, color); + } + } +} + +void VectorscopeWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Painter base_painter(*this); + Gfx::AntiAliasingPainter painter(base_painter); + base_painter.add_clip_rect(event.rect()); + // From this point on we're working with 0,0 as the scope center to make things easier. + base_painter.translate(width() / 2, height() / 2); + painter.translate(static_cast<float>(width()) / 2.0f, static_cast<float>(height()) / 2.0f); + + auto const graticule_color = Color::White; + auto const scope_size = min(height(), width()); + auto const graticule_size = scope_size / 6; + auto const graticule_thickness = graticule_size / 12; + auto const entire_scope_rect = Gfx::FloatRect::centered_at({ 0, 0 }, { scope_size, scope_size }); + + painter.fill_ellipse(entire_scope_rect.to_rounded<int>().shrunken(graticule_thickness * 2, graticule_thickness * 2), Color::Black); + + // Main scope data + if (m_image) { + if (m_vectorscope_image->size() != this->size()) + rebuild_vectorscope_image(); + + base_painter.blit({ -width() / 2, -height() / 2 }, *m_vectorscope_image, m_vectorscope_image->rect()); + } + + // Graticule(s) + painter.draw_ellipse(entire_scope_rect.to_rounded<int>(), graticule_color, graticule_thickness); + + // FIXME: Translation calls to the painters don't appear to work correctly, and I figured out a combination of calls through trial and error that do what I want, but I don't know how they do that. + // Translation does work correctly with things like rectangle and text drawing, so it's very strange. + painter.translate(-static_cast<float>(width()) / 2.0f, -static_cast<float>(height()) / 2.0f); + // We intentionally draw the skin tone line much further than the actual color we're using for it. + painter.draw_line({ 0, 0 }, skin_tone_color.to_vector(scope_size) * 2.0, graticule_color); + painter.translate(-static_cast<float>(width()) / 2.0f, -static_cast<float>(height()) / 2.0f); + + for (auto const& primary_color : primary_colors) { + // FIXME: Only draw the rectangle corners for a more classical oscilloscope look (& less obscuring of color data) + auto graticule_rect = Gfx::FloatRect::centered_at(primary_color.to_vector(scope_size), { graticule_size, graticule_size }).to_rounded<int>(); + base_painter.draw_rect_with_thickness(graticule_rect, graticule_color, graticule_thickness); + auto text_rect = graticule_rect.translated(graticule_size / 2, graticule_size / 2); + base_painter.draw_text(text_rect, StringView { &primary_color.symbol, 1 }, Gfx::TextAlignment::TopLeft, graticule_color); + } + + if (m_color_at_mouseposition != Color::Transparent) { + auto color_vector = ColorVector { m_color_at_mouseposition }; + painter.draw_ellipse(Gfx::FloatRect::centered_at(color_vector.to_vector(scope_size) * 2.0, { graticule_size, graticule_size }).to_rounded<int>(), graticule_color, graticule_thickness); + } +} + +} diff --git a/Userland/Applications/PixelPaint/VectorscopeWidget.h b/Userland/Applications/PixelPaint/VectorscopeWidget.h new file mode 100644 index 0000000000..03c9a7e37c --- /dev/null +++ b/Userland/Applications/PixelPaint/VectorscopeWidget.h @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "Image.h" +#include "ScopeWidget.h" +#include <AK/Array.h> + +namespace PixelPaint { + +// Gfx::Color can produce 64-bit floating-point HSV. +// However, as it internally only uses 8 bits for each color channel, the hue can never have a higher usable resolution than 256 steps. +static constexpr size_t const u_v_steps = 160; + +// Convert to and from U or V (-1 to +1) and an index suitable for the vectorscope table. +constexpr size_t u_v_to_index(float u_v) +{ + float normalized_u_v = (u_v + 1.0f) / 2.0f; + return static_cast<size_t>(floorf(normalized_u_v * u_v_steps)) % u_v_steps; +} +constexpr float u_v_from_index(size_t index) +{ + float normalized_u_v = static_cast<float>(index) / u_v_steps; + return normalized_u_v * 2.0f - 1.0f; +} + +struct PrimaryColorVector; + +struct ColorVector { + constexpr ColorVector(float u, float v) + : u(u) + , v(v) + { + } + + constexpr explicit ColorVector(Color color) + : ColorVector(color.to_yuv().u, color.to_yuv().v) + { + } + + static constexpr ColorVector from_indices(size_t u_index, size_t v_index) + { + return ColorVector(u_v_from_index(u_index), u_v_from_index(v_index)); + } + + constexpr Gfx::FloatPoint to_vector(float scope_size) const + { + auto x = u * scope_size / 2.0f; + // Computer graphics y increases downwards, but mathematical y increases upwards. + auto y = -v * scope_size / 2.0f; + return { x, y }; + } + + constexpr operator PrimaryColorVector() const; + + float u; + float v; +}; + +struct PrimaryColorVector : public ColorVector { + constexpr PrimaryColorVector(Color::NamedColor named_color, char symbol) + : ColorVector(Color { named_color }) + , symbol(symbol) + { + } + + constexpr PrimaryColorVector(Color color, char symbol) + : ColorVector(color.to_yuv().u, color.to_yuv().v) + , symbol(symbol) + { + } + + constexpr PrimaryColorVector(float u, float v, char symbol) + : ColorVector(u, v) + , symbol(symbol) + { + } + + char symbol; +}; + +constexpr ColorVector::operator PrimaryColorVector() const +{ + return PrimaryColorVector { u, v, 'X' }; +} + +// Color vectors that are found in this percentage of pixels and above are displayed with maximum brightness in the scope. +static constexpr float const pixel_percentage_for_max_brightness = 0.01f; +// Which normalized brightness value (and above) gets to be rendered at 100% opacity. +static constexpr float const alpha_range = 2.5f; +// Skin tone line. This was determined manually with a couple of common hex skin tone colors. +static constexpr PrimaryColorVector const skin_tone_color = { Color::from_hsv(18.0, 1.0, 1.0), 'S' }; +// Used for primary color box graticules. +static constexpr Array<PrimaryColorVector, 6> const primary_colors = { { + { Color::Red, 'R' }, + { Color::Magenta, 'M' }, + { Color::Blue, 'B' }, + { Color::Cyan, 'C' }, + { Color::Green, 'G' }, + { Color::Yellow, 'Y' }, +} }; + +// Vectorscopes are a standard tool in professional video/film color grading. +// The Vectorscope shows image colors along the I and Q axis (from YIQ color space), which, to oversimplify, means that you get a weirdly shifted hue circle with the radius being the saturation. +// The brightness for each point in the scope is determined by the number of "color vectors" at that point. +// FIXME: We would want a lot of the scope settings to be user-adjustable. For example: scale, color/bw scope, graticule brightness +class VectorscopeWidget final + : public ScopeWidget { + C_OBJECT(VectorscopeWidget); + +public: + virtual ~VectorscopeWidget() override = default; + + virtual void image_changed() override; + +private: + virtual void paint_event(GUI::PaintEvent&) override; + + ErrorOr<void> rebuild_vectorscope_data(); + void rebuild_vectorscope_image(); + + // First index is u, second index is v. The value is y. + Array<Array<float, u_v_steps + 1>, u_v_steps + 1> m_vectorscope_data; + RefPtr<Gfx::Bitmap> m_vectorscope_image; +}; + +} |