#!/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 . # 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 ('') 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 def run(self, procedure, config, data): run_mode = config.get_property("run-mode") palette = config.get_property("palette") 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() config.set_property("palette", palette) 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()) 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") 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)