summaryrefslogtreecommitdiff
path: root/lib/threads
diff options
context:
space:
mode:
Diffstat (limited to 'lib/threads')
-rw-r--r--lib/threads/__init__.py3
-rw-r--r--lib/threads/catalog.py95
-rw-r--r--lib/threads/color.py82
-rw-r--r--lib/threads/palette.py72
4 files changed, 252 insertions, 0 deletions
diff --git a/lib/threads/__init__.py b/lib/threads/__init__.py
new file mode 100644
index 00000000..03cd777b
--- /dev/null
+++ b/lib/threads/__init__.py
@@ -0,0 +1,3 @@
+from color import ThreadColor
+from palette import ThreadPalette
+from catalog import ThreadCatalog
diff --git a/lib/threads/catalog.py b/lib/threads/catalog.py
new file mode 100644
index 00000000..cebae4ff
--- /dev/null
+++ b/lib/threads/catalog.py
@@ -0,0 +1,95 @@
+import os
+from os.path import dirname, realpath
+import sys
+from glob import glob
+from collections import Sequence
+from .palette import ThreadPalette
+
+class _ThreadCatalog(Sequence):
+ """Holds a set of ThreadPalettes."""
+
+ def __init__(self):
+ self.palettes = []
+ self.load_palettes(self.get_palettes_path())
+
+ def get_palettes_path(self):
+ if getattr(sys, 'frozen', None) is not None:
+ path = os.path.join(sys._MEIPASS, "..")
+ else:
+ path = dirname(dirname(dirname(realpath(__file__))))
+
+ return os.path.join(path, 'palettes')
+
+ def load_palettes(self, path):
+ for palette_file in glob(os.path.join(path, '*.gpl')):
+ self.palettes.append(ThreadPalette(palette_file))
+
+ def palette_names(self):
+ return list(sorted(palette.name for palette in self))
+
+ def __getitem__(self, item):
+ return self.palettes[item]
+
+ def __len__(self):
+ return len(self.palettes)
+
+ def _num_exact_color_matches(self, palette, threads):
+ """Number of colors in stitch plan with an exact match in this palette."""
+
+ return sum(1 for thread in threads if thread in palette)
+
+ def match_and_apply_palette(self, stitch_plan, palette=None):
+ if palette is None:
+ palette = self.match_palette(stitch_plan)
+ else:
+ palette = self.get_palette_by_name(palette)
+
+ if palette is not None:
+ self.apply_palette(stitch_plan, palette)
+
+ return palette
+
+ def match_palette(self, stitch_plan):
+ """Figure out which color palette was used
+
+ Scans the catalog of color palettes and chooses one that seems most
+ likely to be the one that the user used. A palette will only be
+ chosen if more tha 80% of the thread colors in the stitch plan are
+ exact matches for threads in the palette.
+ """
+
+ threads = [color_block.color for color_block in stitch_plan]
+ palettes_and_matches = [(palette, self._num_exact_color_matches(palette, threads))
+ for palette in self]
+ palette, matches = max(palettes_and_matches, key=lambda item: item[1])
+
+ if matches < 0.8 * len(stitch_plan):
+ # if less than 80% of the colors are an exact match,
+ # don't use this palette
+ return None
+ else:
+ return palette
+
+ def apply_palette(self, stitch_plan, palette):
+ for color_block in stitch_plan:
+ nearest = palette.nearest_color(color_block.color)
+
+ color_block.color.name = nearest.name
+ color_block.color.number = nearest.number
+ color_block.color.manufacturer = nearest.manufacturer
+
+ def get_palette_by_name(self, name):
+ for palette in self:
+ if palette.name == name:
+ return palette
+
+_catalog = None
+
+def ThreadCatalog():
+ """Singleton _ThreadCatalog factory"""
+
+ global _catalog
+ if _catalog is None:
+ _catalog = _ThreadCatalog()
+
+ return _catalog
diff --git a/lib/threads/color.py b/lib/threads/color.py
new file mode 100644
index 00000000..af474127
--- /dev/null
+++ b/lib/threads/color.py
@@ -0,0 +1,82 @@
+import simplestyle
+import re
+import colorsys
+
+
+class ThreadColor(object):
+ hex_str_re = re.compile('#([0-9a-z]{3}|[0-9a-z]{6})', re.I)
+
+ def __init__(self, color, name=None, number=None, manufacturer=None):
+ if color is None:
+ self.rgb = (0, 0, 0)
+ elif isinstance(color, (list, tuple)):
+ self.rgb = tuple(color)
+ elif self.hex_str_re.match(color):
+ self.rgb = simplestyle.parseColor(color)
+ else:
+ raise ValueError("Invalid color: " + repr(color))
+
+ self.name = name
+ self.number = number
+ self.manufacturer = manufacturer
+
+ def __eq__(self, other):
+ if isinstance(other, ThreadColor):
+ return self.rgb == other.rgb
+ else:
+ return self == ThreadColor(other)
+
+ def __hash__(self):
+ return hash(self.rgb)
+
+ def __ne__(self, other):
+ return not(self == other)
+
+ def __repr__(self):
+ return "ThreadColor" + repr(self.rgb)
+
+ def to_hex_str(self):
+ return "#%s" % self.hex_digits
+
+ @property
+ def hex_digits(self):
+ return "%02X%02X%02X" % self.rgb
+
+ @property
+ def rgb_normalized(self):
+ return tuple(channel / 255.0 for channel in self.rgb)
+
+ @property
+ def font_color(self):
+ """Pick a color that will allow text to show up on a swatch in the printout."""
+ hls = colorsys.rgb_to_hls(*self.rgb_normalized)
+
+ # We'll use white text unless the swatch color is too light.
+ if hls[1] > 0.7:
+ return (1, 1, 1)
+ else:
+ return (254, 254, 254)
+
+ @property
+ def visible_on_white(self):
+ """A ThreadColor similar to this one but visible on white.
+
+ If the thread color is white, we don't want to try to draw white in the
+ simulation view or print white in the print-out. Choose a color that's
+ as close as possible to the actual thread color but is still at least
+ somewhat visible on a white background.
+ """
+
+ hls = list(colorsys.rgb_to_hls(*self.rgb_normalized))
+
+ # Capping lightness should make the color visible without changing it
+ # too much.
+ if hls[1] > 0.85:
+ hls[1] = 0.85
+
+ color = colorsys.hls_to_rgb(*hls)
+
+ # convert back to values in the range of 0-255
+ color = tuple(value * 255 for value in color)
+
+ return ThreadColor(color, name=self.name, number=self.number, manufacturer=self.manufacturer)
diff --git a/lib/threads/palette.py b/lib/threads/palette.py
new file mode 100644
index 00000000..e1f47c7f
--- /dev/null
+++ b/lib/threads/palette.py
@@ -0,0 +1,72 @@
+from collections import Set
+from .color import ThreadColor
+from colormath.color_objects import sRGBColor, LabColor
+from colormath.color_conversions import convert_color
+from colormath.color_diff import delta_e_cie1994
+
+
+def compare_thread_colors(color1, color2):
+ # K_L=2 indicates textiles
+ return delta_e_cie1994(color1, color2, K_L=2)
+
+
+class ThreadPalette(Set):
+ """Holds a set of ThreadColors all from the same manufacturer."""
+
+ def __init__(self, palette_file):
+ self.threads = dict()
+ self.parse_palette_file(palette_file)
+
+ def parse_palette_file(self, palette_file):
+ """Read a GIMP palette file and load thread colors.
+
+ Example file:
+
+ GIMP Palette
+ Name: Ink/Stitch: Metro
+ Columns: 4
+ # RGB Value Color Name Number
+ 240 186 212 Sugar Pink 1624
+ 237 171 194 Carnatio 1636
+
+ """
+
+ with open(palette_file) as palette:
+ line = palette.readline().strip()
+ if line.lower() != "gimp palette":
+ raise ValueError("Invalid gimp palette header")
+
+ self.name = palette.readline().strip()
+ if self.name.lower().startswith('name: ink/stitch: '):
+ self.name = self.name[18:]
+
+ columns_line = palette.readline()
+ headers_line = palette.readline()
+
+ for line in palette:
+ fields = line.split("\t", 3)
+ thread_color = [int(field) for field in fields[:3]]
+ thread_name, thread_number = fields[3].strip().rsplit(" ", 1)
+ thread_name = thread_name.strip()
+
+ thread = ThreadColor(thread_color, thread_name, thread_number, manufacturer=self.name)
+ self.threads[thread] = convert_color(sRGBColor(*thread_color, is_upscaled=True), LabColor)
+
+ def __contains__(self, thread):
+ return thread in self.threads
+
+ def __iter__(self):
+ return iter(self.threads)
+
+ def __len__(self):
+ return len(self.threads)
+
+ def nearest_color(self, color):
+ """Find the thread in this palette that looks the most like the specified color."""
+
+ if isinstance(color, ThreadColor):
+ color = color.rgb
+
+ color = convert_color(sRGBColor(*color, is_upscaled=True), LabColor)
+
+ return min(self, key=lambda thread: compare_thread_colors(self.threads[thread], color))