PIKApp/plug-ins/python/palette-sort.py

509 lines
20 KiB
Python
Raw Normal View History

2023-09-26 00:35:21 +02:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# For a detailed explanation of the parameters of this plugin, see :
# https://gitlab.gnome.org/GNOME/pika/-/issues/4368#note_763460
# little known, colorsys is part of Python's stdlib
from colorsys import rgb_to_yiq
from textwrap import dedent
from random import randint
import gi
gi.require_version('Pika', '3.0')
from gi.repository import Pika
gi.require_version('PikaUi', '3.0')
from gi.repository import PikaUi
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
import sys
def N_(message): return message
def _(message): return GLib.dgettext(None, message)
AVAILABLE_CHANNELS = (_("Red"), _("Green"), _("Blue"),
_("Luma (Y)"),
_("Hue"), _("Saturation"), _("Value"),
_("Saturation (HSL)"), _("Lightness (HSL)"),
_("Index"),
_("Random"))
channel_getters = [
(lambda v, i: v.r),
(lambda v, i: v.g),
(lambda v, i: v.r),
(lambda v, i: rgb_to_yiq(v.r, v.g, v.b)[0]),
(lambda v, i: v.to_hsv().h),
(lambda v, i: v.to_hsv().s),
(lambda v, i: v.to_hsv().v),
(lambda v, i: v.to_hsl().s),
(lambda v, i: v.to_hsl().l),
(lambda v, i: i),
(lambda v, i: randint(0, 0x7fffffff))
]
GRAIN_SCALE = (1.0, 1.0 , 1.0,
1.0,
360., 100., 100.,
100., 100.,
16384.,
float(0x7ffffff),
100., 256., 256.,
256., 360.,)
SELECT_ALL = 0
SELECT_SLICE = 1
SELECT_AUTOSLICE = 2
SELECT_PARTITIONED = 3
SELECTIONS = (SELECT_ALL, SELECT_SLICE, SELECT_AUTOSLICE, SELECT_PARTITIONED)
try:
from colormath.color_objects import RGBColor, LabColor, LCHabColor
def to_lab(v):
return RGBColor(v.r, v.g, v.b).convert_to('LAB').get_value_tuple()
def to_lchab(v):
return RGBColor(v.r, v.g, v.b).convert_to('LCHab').get_value_tuple()
AVAILABLE_CHANNELS = AVAILABLE_CHANNELS + (_("Lightness (LAB)"),
_("A-color"), _("B-color"),
_("Chroma (LCHab)"),
_("Hue (LCHab)"))
channel_getters.extend([
(lambda v, i: to_lab(v)[0]),
(lambda v, i: to_lab(v)[1]),
(lambda v, i: to_lab(v)[2]),
(lambda v, i: to_lchab(v)[1]),
(lambda v, i: to_lchab(v)[2])
])
except ImportError:
pass
slice_expr_doc = """
Format is 'start:nrows,length' . All items are optional.
The empty string selects all items, as does ':'
':4,' makes a 4-row selection out of all colors (length auto-determined)
':4' also.
':1,4' selects the first 4 colors
':,4' selects rows of 4 colors (nrows auto-determined)
':3,4' selects 3 rows of 4 colors
'4:' selects a single row of all colors after 4, inclusive.
'3:,4' selects rows of 4 colors, starting at 3 (nrows auto-determined)
'2:3,4' selects 3 rows of 4 colors (12 colors total), beginning at index 2.
'4' is illegal (ambiguous)
"""
def parse_slice(s, numcolors):
"""Parse a slice spec and return (start, nrows, length)
All items are optional. Omitting them makes the largest possible selection that
exactly fits the other items.
In general, slices are comparable to a numpy sub-array.
'start at element START, with shape (NROWS, LENGTH)'
"""
s = s.strip()
def notunderstood():
raise ValueError('Slice %r not understood. Should be in format'
' START?:NROWS?,ROWLENGTH? eg. "0:4,16".' % s)
def _int(v):
try:
return int(v)
except ValueError:
notunderstood()
if s in ('', ':', ':,'):
return 0, 1, numcolors # entire palette, one row
if s.count(':') != 1:
notunderstood()
rowpos = s.find(':')
start = 0
if rowpos > 0:
start = _int(s[:rowpos])
numcolors -= start
nrows = 1
if ',' in s:
commapos = s.find(',')
nrows = s[rowpos+1:commapos]
length = s[commapos+1:]
if not nrows:
if not length:
notunderstood()
else:
length = _int(length)
if length == 0:
notunderstood()
nrows = numcolors // length
if numcolors % length:
nrows = -nrows
elif not length:
nrows = _int(nrows)
if nrows == 0:
notunderstood()
length = numcolors // nrows
if numcolors % nrows:
length = -length
else:
nrows = _int(nrows)
if nrows == 0:
notunderstood()
length = _int(length)
if length == 0:
notunderstood()
else:
nrows = _int(s[rowpos+1:])
if nrows == 0:
notunderstood()
length = numcolors // nrows
if numcolors % nrows:
length = -length
return start, nrows, length
def quantization_grain(channel, g):
"Given a channel and a quantization, return the size of a quantization grain"
g = max(1.0, g)
if g <= 1.0:
g = 0.00001
else:
g = max(0.00001, GRAIN_SCALE[channel] / g)
return g
def palette_sort(palette, selection, slice_expr, channel1, ascending1,
channel2, ascending2, quantize, pchannel, pquantize):
grain1 = quantization_grain(channel1, quantize)
grain2 = quantization_grain(channel2, quantize)
pgrain = quantization_grain(pchannel, pquantize)
# If palette is read only, work on a copy:
editable = palette.is_editable()
if not editable:
palette = palette.duplicate()
num_colors = palette.get_color_count()
start, nrows, length = None, None, None
if selection == SELECT_AUTOSLICE:
def find_index(color, startindex=0):
for i in range(startindex, num_colors):
c = palette.entry_get_color(i)
if c[1].r == color[1].r and c[1].g == color[1].g and c[1].b == color[1].b:
return i
return None
def hexcolor(c):
return "#%02x%02x%02x" % (int(255 * c[1].r), int(255 * c[1].b), int(255 * c[1].g))
fg = Pika.context_get_foreground()
bg = Pika.context_get_background()
start = find_index(fg)
end = find_index(bg)
if start is None:
raise ValueError("Couldn't find foreground color %s in palette" % hexcolor(fg))
if end is None:
raise ValueError("Couldn't find background color %s in palette" % hexcolor(bg))
if find_index(fg, start + 1):
raise ValueError('Autoslice cannot be used when more than one'
' instance of an endpoint'
' (%s) is present' % hexcolor(fg))
if find_index(bg, end + 1):
raise ValueError('Autoslice cannot be used when more than one'
' instance of an endpoint'
' (%s) is present' % hexcolor(bg))
if start > end:
end, start = start, end
length = (end - start) + 1
try:
_, nrows, _ = parse_slice(slice_expr, length)
nrows = abs(nrows)
if length % nrows:
raise ValueError('Total length %d not evenly divisible'
' by number of rows %d' % (length, nrows))
length //= nrows
except ValueError:
# bad expression is okay here, just assume one row
nrows = 1
# remaining behavior is implemented by SELECT_SLICE 'inheritance'.
selection = SELECT_SLICE
elif selection in (SELECT_SLICE, SELECT_PARTITIONED):
start, nrows, length = parse_slice(slice_expr, num_colors)
channels_getter_1 = channel_getters[channel1]
channels_getter_2 = channel_getters[channel2]
def get_colors(start, end):
result = []
for i in range(start, end):
entry = (palette.entry_get_name(i)[1],
palette.entry_get_color(i)[1])
index1 = channels_getter_1(entry[1], i)
index2 = channels_getter_2(entry[1], i)
index = ((index1 - (index1 % grain1)) * (1 if ascending1 else -1),
(index2 - (index2 % grain2)) * (1 if ascending2 else -1)
)
result.append((index, entry))
return result
if selection == SELECT_ALL:
entry_list = get_colors(0, num_colors)
entry_list.sort(key=lambda v: v[0])
for i in range(num_colors):
palette.entry_set_name(i, entry_list[i][1][0])
palette.entry_set_color(i, entry_list[i][1][1])
elif selection == SELECT_PARTITIONED:
if num_colors < (start + length * nrows) - 1:
raise ValueError('Not enough entries in palette to '
'sort complete rows! Got %d, expected >=%d' %
(num_colors, start + length * nrows))
pchannels_getter = channel_getters[pchannel]
for row in range(nrows):
partition_spans = [1]
rowstart = start + (row * length)
old_color = palette.entry_get_color(rowstart)[1]
old_partition = pchannels_getter(old_color, rowstart)
old_partition = old_partition - (old_partition % pgrain)
for i in range(rowstart + 1, rowstart + length):
this_color = palette.entry_get_color(i)[1]
this_partition = pchannels_getter(this_color, i)
this_partition = this_partition - (this_partition % pgrain)
if this_partition == old_partition:
partition_spans[-1] += 1
else:
partition_spans.append(1)
old_partition = this_partition
base = rowstart
for size in partition_spans:
palette_sort(SELECT_SLICE, '%d:1,%d' % (base, size),
channel1, ascending1,
channel2, ascending2,
quantize, 0, 1.0)
base += size
else:
# SELECT_SLICE and SELECT_AUTOSLICE
stride = length
if num_colors < (start + stride * nrows) - 1:
raise ValueError('Not enough entries in palette to sort '
'complete rows! Got %d, expected >=%d' %
(num_colors, start + stride * nrows))
for row_start in range(start, start + stride * nrows, stride):
sublist = get_colors(row_start, row_start + stride)
sublist.sort(key=lambda v: v[0])
for i, entry in zip(range(row_start, row_start + stride), sublist):
palette.entry_set_name(i, entry[1][0])
palette.entry_set_color(i, entry[1][1])
return palette
# FIXME: Write humanly readable help -
# See for reference: https://gitlab.gnome.org/GNOME/pika/-/issues/4368#note_763460
# Important to describe the general effect on palettes rather than details of the sort.
help_doc = r"""
Sorts a palette, or part of a palette.
Sorts the given palette when it is editable, otherwise creates a new sorted palette.
The default is a 1D sort, but you can also sort over two color channels
or create a 2D sorted palette with sorted rows.
You can optionally install colormath (https://pypi.python.org/pypi/colormath/1.0.8)
to PIKA's Python to get even more channels to choose from.
"""
selections_option = [ _("All"), _("Slice / Array"), _("Autoslice (fg->bg)"), _("Partitioned") ]
class PaletteSort (Pika.PlugIn):
## Parameters ##
__gproperties__ = {
"run-mode": (Pika.RunMode,
_("Run mode"),
"The run mode",
Pika.RunMode.INTERACTIVE,
GObject.ParamFlags.READWRITE),
"palette": (Pika.Palette,
_("_Palette"),
_("Palette"),
GObject.ParamFlags.READWRITE),
"selections": (int,
_("Select_ions"),
str(selections_option),
0, 3, 0,
GObject.ParamFlags.READWRITE),
# TODO: It would be much simpler to replace the slice expression with three
# separate parameters: start-index, number-of-rows, row_length
"slice_expr": (str,
_("Slice _expression"),
slice_expr_doc,
"",
GObject.ParamFlags.READWRITE),
"channel1": (int,
_("Channel _to sort"),
"Channel to sort: " + str(AVAILABLE_CHANNELS),
0, len(AVAILABLE_CHANNELS), 3,
GObject.ParamFlags.READWRITE),
"ascending1": (bool,
_("_Ascending"),
_("Ascending"),
True,
GObject.ParamFlags.READWRITE),
"channel2": (int,
_("Secondary C_hannel to sort"),
"Secondary Channel to sort: " + str(AVAILABLE_CHANNELS),
0, len(AVAILABLE_CHANNELS), 5,
GObject.ParamFlags.READWRITE),
"ascending2": (bool,
_("Ascen_ding"),
_("Ascending"),
True,
GObject.ParamFlags.READWRITE),
"quantize": (float,
_("_Quantization"),
_("Quantization"),
0.0, 1.0, 0.0,
GObject.ParamFlags.READWRITE),
"pchannel": (int,
_("_Partitioning channel"),
"Partitioning channel: " + str(AVAILABLE_CHANNELS),
0, len(AVAILABLE_CHANNELS), 3,
GObject.ParamFlags.READWRITE),
"pquantize": (float,
_("Partition q_uantization"),
_("Partition quantization"),
0.0, 1.0, 0.0,
GObject.ParamFlags.READWRITE),
# Returned value
"new_palette": (Pika.Palette,
_("Palette"),
_("Palette"),
GObject.ParamFlags.READWRITE),
}
## PikaPlugIn virtual methods ##
def do_set_i18n(self, procname):
return True, 'pika30-python', None
def do_query_procedures(self):
return ["python-fu-palette-sort"]
def do_create_procedure(self, name):
procedure = None
if name == "python-fu-palette-sort":
procedure = Pika.Procedure.new(self, name,
Pika.PDBProcType.PLUGIN,
self.run, None)
procedure.set_menu_label(_("_Sort Palette..."))
procedure.set_documentation(
_("Sort the colors in a palette"),
help_doc,
""
)
procedure.set_attribution("João S. O. Bueno, Carol Spears, David Gowers",
"João S. O. Bueno, Carol Spears, David Gowers",
"2006-2014")
procedure.add_menu_path ('<Palettes>')
procedure.add_argument_from_property(self, "run-mode")
procedure.add_argument_from_property(self, "palette")
procedure.add_argument_from_property(self, "selections")
procedure.add_argument_from_property(self, "slice_expr")
procedure.add_argument_from_property(self, "channel1")
procedure.add_argument_from_property(self, "ascending1")
procedure.add_argument_from_property(self, "channel2")
procedure.add_argument_from_property(self, "ascending2")
procedure.add_argument_from_property(self, "quantize")
procedure.add_argument_from_property(self, "pchannel")
procedure.add_argument_from_property(self, "pquantize")
procedure.add_return_value_from_property(self, "new_palette")
return procedure
2023-10-30 23:55:30 +01:00
def run(self, procedure, config, data):
run_mode = config.get_property("run-mode")
palette = config.get_property("palette")
2023-09-26 00:35:21 +02:00
if palette is None or not palette.is_valid():
if palette is not None:
sys.stderr.write(f'Invalid palette id: {palette.get_id()}\n')
sys.stderr.write('This should not happen. Please report to PIKA project.\n')
sys.stderr.write('Falling back to context palette instead.\n')
palette = Pika.context_get_palette()
2023-10-30 23:55:30 +01:00
config.set_property("palette", palette)
2023-09-26 00:35:21 +02:00
if not palette.is_valid():
palette_name = palette.get_id()
error = f'Invalid palette id: {palette_name}'
return procedure.new_return_values(Pika.PDBStatusType.CALLING_ERROR,
GLib.Error(error))
if run_mode == Pika.RunMode.INTERACTIVE:
PikaUi.init('python-fu-palette-sort')
dialog = PikaUi.ProcedureDialog(procedure=procedure, config=config)
dialog.fill (["palette"])
dialog.get_int_combo("selections", PikaUi.IntStore.new (selections_option))
dialog.fill (["selections","slice-expr"])
dialog.get_int_combo("channel1", PikaUi.IntStore.new (AVAILABLE_CHANNELS))
dialog.fill (["channel1", "ascending1"])
dialog.get_int_combo("channel2", PikaUi.IntStore.new (AVAILABLE_CHANNELS))
dialog.fill (["channel2","ascending2", "quantize"])
dialog.get_int_combo("pchannel", PikaUi.IntStore.new (AVAILABLE_CHANNELS))
dialog.fill (["pchannel","pquantize"])
if not dialog.run():
dialog.destroy()
return procedure.new_return_values(Pika.PDBStatusType.CANCEL, GLib.Error())
2023-10-30 23:55:30 +01:00
dialog.destroy()
palette = config.get_property("palette")
selection = config.get_property("selections")
slice_expr = config.get_property ("slice_expr")
channel1 = config.get_property("channel1")
ascending1 = config.get_property ("ascending1")
channel2 = config.get_property("channel2")
ascending2 = config.get_property ("ascending2")
quantize = config.get_property ("quantize")
pchannel = config.get_property("pchannel")
pquantize = config.get_property ("pquantize")
2023-09-26 00:35:21 +02:00
try:
new_palette = palette_sort(palette, selection, slice_expr, channel1, ascending1,
channel2, ascending2, quantize, pchannel, pquantize)
except ValueError as err:
return procedure.new_return_values(Pika.PDBStatusType.EXECUTION_ERROR,
GLib.Error(str(err)))
return_val = procedure.new_return_values(Pika.PDBStatusType.SUCCESS, GLib.Error())
value = GObject.Value(Pika.Palette, new_palette)
return_val.remove(1)
return_val.insert(1, value)
return return_val
Pika.main(PaletteSort.__gtype__, sys.argv)