#!/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/gimp/-/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 struct import gi gi.require_version('Babl', '0.1') from gi.repository import Babl gi.require_version('Gimp', '3.0') from gi.repository import Gimp gi.require_version('GimpUi', '3.0') from gi.repository import GimpUi 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.get_rgba()[0]), (lambda v, i: v.get_rgba()[1]), (lambda v, i: v.get_rgba()[2]), (lambda v, i: rgb_to_yiq(v.get_rgba()[0], v.get_rgba()[1], v.get_rgba()[1])[0]), # TODO: Replace with gegl_color_get_hsv () when available in Gegl (lambda v, i: gegl_color_bytes_convert (v, "HSV float", 'fff', 0)), (lambda v, i: gegl_color_bytes_convert (v, "HSV float", 'fff', 1)), (lambda v, i: gegl_color_bytes_convert (v, "HSV float", 'fff', 2)), (lambda v, i: gegl_color_bytes_convert (v, "HSL float", 'fff', 1)), (lambda v, i: gegl_color_bytes_convert (v, "HSL float", 'fff', 2)), (lambda v, i: i), (lambda v, i: randint(0, 0x7fffffff)), (lambda v, i: gegl_color_bytes_convert (v, "CIE Lab float", 'fff', 0)), (lambda v, i: gegl_color_bytes_convert (v, "CIE Lab float", 'fff', 1)), (lambda v, i: gegl_color_bytes_convert (v, "CIE Lab float", 'fff', 2)), (lambda v, i: gegl_color_bytes_convert (v, "CIE LCH(ab) float", 'fff', 1)), (lambda v, i: gegl_color_bytes_convert (v, "CIE LCH(ab) float", 'fff', 2)) ] GRAIN_SCALE = (1.0, 1.0 , 1.0, 1.0, 360., 100., 100., 100., 100., 16384., float(0x7ffffff), 100., 256., 256., 256., 360.,) def gegl_color_bytes_convert (color, format, precision, index): color_bytes = color.get_bytes(Babl.format(format)) data = color_bytes.get_data() result = struct.unpack (precision, data) return result[index] slice_expr_doc = N_(""" 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 == "auto-slice": def find_index(color, startindex=0): for i in range(startindex, num_colors): c = palette.entry_get_color(i) rgba = c.get_rgba() if rgba[0] == color[1].r and rgba[1] == color[1].g and rgba[2] == color[1].b: return i return None def hexcolor(c): return "#%02x%02x%02x" % (int(255 * rgba[0]), int(255 * rgba[1]), int(255 * rgba[2])) fg = Gimp.context_get_foreground() bg = Gimp.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 "slice-array" 'inheritance'. selection = "slice-array" elif selection in ("slice-array", "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)) 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 print (selection) if selection == "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 == "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) 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) 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(palette, "slice-array", '%d:1,%d' % (base, size), channel1, ascending1, channel2, ascending2, quantize, 0, 1.0) base += size else: # "slice-array" and "auto-slice" 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/gimp/-/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 GIMP's Python to get even more channels to choose from. """ class PaletteSort (Gimp.PlugIn): ## GimpPlugIn virtual methods ## def do_set_i18n(self, procname): return True, 'gimp30-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 = Gimp.Procedure.new(self, name, Gimp.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 Menu') procedure.add_enum_argument ("run-mode", _("Run mode"), _("The run mode"), Gimp.RunMode, Gimp.RunMode.INTERACTIVE, GObject.ParamFlags.READWRITE) procedure.add_palette_argument ("palette", _("_Palette"), _("Palette"), True, GObject.ParamFlags.READWRITE) selection_choice = Gimp.Choice.new() selection_choice.add("all", 0, _("All"), "") selection_choice.add("slice-array", 1, _("Slice / Array"), "") selection_choice.add("auto-slice", 2, _("Autoslice (fg->bg)"), "") selection_choice.add("partitioned", 3, _("Partitioned"), "") procedure.add_choice_argument ("selections", _("Select_ions"), _("Selections"), selection_choice, "all", 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 procedure.add_string_argument ("slice-expr", _("Slice _expression"), _(slice_expr_doc), "", GObject.ParamFlags.READWRITE) channel_choice = Gimp.Choice.new() self.add_choices (channel_choice) procedure.add_choice_argument ("channel1", _("Channel _to sort"), _("Channel to sort"), channel_choice, "luma", GObject.ParamFlags.READWRITE) procedure.add_boolean_argument ("ascending1", _("_Ascending"), _("Ascending"), True, GObject.ParamFlags.READWRITE) channel_choice2 = Gimp.Choice.new() self.add_choices (channel_choice2) procedure.add_choice_argument ("channel2", _("Secondary C_hannel to sort"), _("Secondary Channel to sort"), channel_choice2, "saturation", GObject.ParamFlags.READWRITE) procedure.add_boolean_argument ("ascending2", _("Ascen_ding"), _("Ascending"), True, GObject.ParamFlags.READWRITE) procedure.add_double_argument ("quantize", _("_Quantization"), _("Quantization"), 0.0, 1.0, 0.0, GObject.ParamFlags.READWRITE) pchannel_choice = Gimp.Choice.new() self.add_choices (pchannel_choice) procedure.add_choice_argument ("pchannel", _("Partitionin_g channel"), _("Partitioning channel"), pchannel_choice, "luma", GObject.ParamFlags.READWRITE) procedure.add_double_argument ("pquantize", _("Partition q_uantization"), _("Partition quantization"), 0.0, 1.0, 0.0, GObject.ParamFlags.READWRITE) procedure.add_palette_return_value ("new-palette", _("Palette"), _("Palette"), GObject.ParamFlags.READWRITE) 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 GIMP project.\n') sys.stderr.write('Falling back to context palette instead.\n') palette = Gimp.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(Gimp.PDBStatusType.CALLING_ERROR, GLib.Error(error)) if run_mode == Gimp.RunMode.INTERACTIVE: GimpUi.init('python-fu-palette-sort') dialog = GimpUi.ProcedureDialog(procedure=procedure, config=config) dialog.fill (["palette"]) dialog.fill (["selections","slice-expr"]) dialog.fill (["channel1", "ascending1"]) dialog.fill (["channel2","ascending2", "quantize"]) dialog.fill (["pchannel","pquantize"]) if not dialog.run(): dialog.destroy() return procedure.new_return_values(Gimp.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_choice_id("channel1") ascending1 = config.get_property ("ascending1") channel2 = config.get_choice_id("channel2") ascending2 = config.get_property ("ascending2") quantize = config.get_property ("quantize") pchannel = config.get_choice_id("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(Gimp.PDBStatusType.EXECUTION_ERROR, GLib.Error(str(err))) return_val = procedure.new_return_values(Gimp.PDBStatusType.SUCCESS, GLib.Error()) value = GObject.Value(Gimp.Palette, new_palette) return_val.remove(1) return_val.insert(1, value) return return_val def add_choices (self, choice): #TODO: Re-incorporate LAB and LCHab options with GeglColor choice.add("red", 0, _("Red"), "") choice.add("green", 1, _("Green"), "") choice.add("blue", 2, _("Blue"), "") choice.add("luma", 3, _("Luma (Y)"), "") choice.add("hue", 4, _("Hue"), "") choice.add("saturation", 5, _("Saturation"), "") choice.add("value", 6, _("Value"), "") choice.add("saturation-hsl", 7, _("Saturation (HSL)"), "") choice.add("lightness-hsl", 8, _("Lightness (HSL)"), "") choice.add("index", 9, _("Index"), "") choice.add("random", 10, _("Random"), "") choice.add("lightness-lab", 11, _("Lightness (LAB)"), "") choice.add("a-color", 12, _("A-color"), "") choice.add("b-color", 13, _("B-color"), "") choice.add("chroma-lchab", 14, _("Chroma (LCHab)"), "") choice.add("hue-lchab", 15, _("Hue (LCHab)"), "") Gimp.main(PaletteSort.__gtype__, sys.argv)