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>
This commit is contained in:
parent
f9b08272db
commit
c1c2e6f7d7
Notes:
sideshowbarker
2024-07-17 08:13:43 +09:00
Author: https://github.com/kleinesfilmroellchen Commit: https://github.com/SerenityOS/serenity/commit/c1c2e6f7d7 Pull-request: https://github.com/SerenityOS/serenity/pull/14895 Reviewed-by: https://github.com/Hendiadyoin1 Reviewed-by: https://github.com/MacDue Reviewed-by: https://github.com/linusg Reviewed-by: https://github.com/trflynn89
6 changed files with 295 additions and 0 deletions
|
@ -72,6 +72,7 @@ set(SOURCES
|
|||
Tools/Tool.cpp
|
||||
Tools/WandSelectTool.cpp
|
||||
Tools/ZoomTool.cpp
|
||||
VectorscopeWidget.cpp
|
||||
main.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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
140
Userland/Applications/PixelPaint/VectorscopeWidget.cpp
Normal file
140
Userland/Applications/PixelPaint/VectorscopeWidget.cpp
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
131
Userland/Applications/PixelPaint/VectorscopeWidget.h
Normal file
131
Userland/Applications/PixelPaint/VectorscopeWidget.h
Normal file
|
@ -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;
|
||||
};
|
||||
|
||||
}
|
Loading…
Add table
Reference in a new issue