Skip to content

Commit c078430

Browse files
committed
OWColor: Saving and loading color schemata
1 parent b511e66 commit c078430

File tree

1 file changed

+243
-6
lines changed

1 file changed

+243
-6
lines changed

Orange/widgets/data/owcolor.py

Lines changed: 243 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
import os
2+
from collections import defaultdict
13
from itertools import chain
4+
import json
25

36
import numpy as np
47

5-
from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex, QTimer
8+
from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex, QTimer, \
9+
QSettings
610
from AnyQt.QtGui import QColor, QFont, QBrush
7-
from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView, QComboBox
11+
from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView, QComboBox, \
12+
QFileDialog, QMessageBox
813

914
import Orange
1015
from Orange.preprocess.transformation import Identity
11-
from Orange.util import color_to_hex
16+
from Orange.util import color_to_hex, hex_to_color
1217
from Orange.widgets import widget, settings, gui
1318
from Orange.widgets.gui import HorizontalGridDelegate
1419
from Orange.widgets.utils import itemmodels, colorpalettes
@@ -21,6 +26,16 @@
2126
StripRole = next(gui.OrangeUserRole)
2227

2328

29+
class InvalidFileFormat(Exception):
30+
pass
31+
32+
33+
def _check_dict_str_str(d):
34+
if not isinstance(d, dict) or \
35+
not all(isinstance(val, str) for val in chain(d, d.values())):
36+
raise InvalidFileFormat
37+
38+
2439
class AttrDesc:
2540
"""
2641
Describes modifications that will be applied to variable.
@@ -46,6 +61,22 @@ def name(self):
4661
def name(self, name):
4762
self.new_name = name
4863

64+
def to_dict(self):
65+
d = {}
66+
if self.new_name is not None:
67+
d["rename"] = self.new_name
68+
return d
69+
70+
@classmethod
71+
def from_dict(cls, var, data):
72+
desc = cls(var)
73+
new_name = data.get("rename")
74+
if new_name is not None:
75+
if not isinstance(desc.name, str):
76+
raise InvalidFileFormat
77+
desc.name = new_name
78+
return desc, []
79+
4980

5081
class DiscAttrDesc(AttrDesc):
5182
"""
@@ -96,6 +127,49 @@ def create_variable(self):
96127
new_var.colors = np.asarray(self.colors)
97128
return new_var
98129

130+
def to_dict(self):
131+
d = super().to_dict()
132+
if self.new_values is not None:
133+
d["renamed_values"] = \
134+
{k: v
135+
for k, v in zip(self.var.values, self.new_values)
136+
if k != v}
137+
if self.new_colors is not None:
138+
d["colors"] = {value: color_to_hex(color)
139+
for value, color in zip(self.values, self.colors)}
140+
return d
141+
142+
@classmethod
143+
def from_dict(cls, var, data):
144+
obj, warnings = super().from_dict(var, data)
145+
146+
val_map = data.get("renamed_values")
147+
if val_map is not None:
148+
_check_dict_str_str(val_map)
149+
mapped_values = [val_map.get(value, value) for value in var.values]
150+
if len(set(mapped_values)) != len(mapped_values):
151+
warnings.append(
152+
f"{var.name}: "
153+
"renaming of values ignored due to duplicate names")
154+
else:
155+
obj.new_values = mapped_values
156+
157+
new_colors = data.get("colors")
158+
if new_colors is not None:
159+
_check_dict_str_str(new_colors)
160+
colors = []
161+
for value, def_color in zip(var.values, var.palette):
162+
if value in new_colors:
163+
try:
164+
color = hex_to_color(new_colors[value])
165+
except (IndexError, ValueError) as exc:
166+
raise InvalidFileFormat from exc
167+
colors.append(color)
168+
else:
169+
colors.append(def_color)
170+
obj.new_colors = colors
171+
return obj, warnings
172+
99173

100174
class ContAttrDesc(AttrDesc):
101175
"""
@@ -136,6 +210,22 @@ def create_variable(self):
136210
new_var.attributes["palette"] = self.palette_name
137211
return new_var
138212

213+
def to_dict(self):
214+
d = super().to_dict()
215+
if self.new_palette_name is not None:
216+
d["colors"] = self.palette_name
217+
return d
218+
219+
@classmethod
220+
def from_dict(cls, var, data):
221+
obj, warnings = super().from_dict(var, data)
222+
colors = data.get("colors")
223+
if colors is not None:
224+
if colors not in colorpalettes.ContinuousPalettes:
225+
raise InvalidFileFormat
226+
obj.palette_name = colors
227+
return obj, warnings
228+
139229

140230
class ColorTableModel(QAbstractTableModel):
141231
"""
@@ -454,14 +544,17 @@ class Outputs:
454544
match_values=settings.PerfectDomainContextHandler.MATCH_VALUES_ALL)
455545
disc_descs = settings.ContextSetting([])
456546
cont_descs = settings.ContextSetting([])
457-
color_settings = settings.Setting(None)
458547
selected_schema_index = settings.Setting(0)
459548
auto_apply = settings.Setting(True)
460549

461550
settings_version = 2
462551

463552
want_main_area = False
464553

554+
FileFilters = [
555+
"Settings for individual variables (*.vdefs)",
556+
"General color encoding for values (*.colors)"]
557+
465558
def __init__(self):
466559
super().__init__()
467560
self.data = None
@@ -481,9 +574,13 @@ def __init__(self):
481574

482575
box = gui.auto_apply(self.controlArea, self, "auto_apply")
483576
box.button.setFixedWidth(180)
577+
save = gui.button(None, self, "Save", callback=self.save)
578+
load = gui.button(None, self, "Load", callback=self.load)
484579
reset = gui.button(None, self, "Reset", callback=self.reset)
485-
box.layout().insertWidget(0, reset)
486-
box.layout().insertStretch(1)
580+
box.layout().insertWidget(0, save)
581+
box.layout().insertWidget(0, load)
582+
box.layout().insertWidget(2, reset)
583+
box.layout().insertStretch(3)
487584

488585
self.info.set_input_summary(self.info.NoInput)
489586
self.info.set_output_summary(self.info.NoOutput)
@@ -524,6 +621,146 @@ def reset(self):
524621
self.cont_model.reset()
525622
self.commit()
526623

624+
def save(self):
625+
fname, ffilter = QFileDialog.getSaveFileName(
626+
self, "File name", self._start_dir(), ";;".join(self.FileFilters))
627+
if not fname:
628+
return
629+
QSettings().setValue("colorwidget/last-location",
630+
os.path.split(fname)[0])
631+
if ffilter == self.FileFilters[0]:
632+
self._save_var_defs(fname)
633+
else:
634+
self._save_value_colors(fname)
635+
636+
def _save_var_defs(self, fname):
637+
json.dump(
638+
{vartype: {
639+
var.name: var_data
640+
for var, var_data in (
641+
(desc.var, desc.to_dict()) for desc in repo)
642+
if var_data}
643+
for vartype, repo in (("categorical", self.disc_descs),
644+
("numeric", self.cont_descs))
645+
},
646+
open(fname, "w"),
647+
indent=4)
648+
649+
def _save_value_colors(self, fname):
650+
color_map = defaultdict(set)
651+
for desc in self.disc_descs:
652+
if desc.new_colors is None:
653+
continue
654+
for value, old_color, new_color in zip(
655+
desc.var.values, desc.var.palette.palette, desc.new_colors):
656+
old_hex, new_hex = map(color_to_hex, (old_color, new_color))
657+
if old_hex != new_hex:
658+
color_map[value].add(new_hex)
659+
js = {value: colors.pop()
660+
for value, colors in color_map.items()
661+
if len(colors) == 1}
662+
json.dump(js, open(fname, "w"), indent=4)
663+
664+
def load(self):
665+
try:
666+
fname, ffilter = QFileDialog.getOpenFileName(
667+
self, "File name", self._start_dir(),
668+
";;".join(self.FileFilters))
669+
if not fname:
670+
return
671+
try:
672+
js = json.load(open(fname)) #: dict
673+
except IOError:
674+
QMessageBox.critical(self, "File error",
675+
"File cannot be opened.")
676+
return
677+
except json.JSONDecodeError as exc:
678+
raise InvalidFileFormat from exc
679+
if ffilter == self.FileFilters[0]:
680+
self._parse_var_defs(js)
681+
else:
682+
self._parse_value_colors(js)
683+
except InvalidFileFormat:
684+
QMessageBox.critical(self, "File error", "Invalid file format.")
685+
else:
686+
self.unconditional_commit()
687+
688+
def _parse_var_defs(self, js):
689+
if not isinstance(js, dict):
690+
raise InvalidFileFormat
691+
renames = {
692+
var_name: desc["rename"]
693+
for repo in js.values() for var_name, desc in repo.items()
694+
if "rename" in desc
695+
}
696+
if not all(isinstance(val, str)
697+
for val in chain(renames, renames.values())):
698+
raise InvalidFileFormat
699+
renamed_vars = {
700+
renames.get(desc.var.name, desc.var.name)
701+
for desc in chain(self.disc_descs, self.cont_descs)
702+
}
703+
if len(renamed_vars) != len(self.disc_descs) + len(self.cont_descs):
704+
QMessageBox.warning(
705+
self,
706+
"Duplicated variable names",
707+
"Variables will not be renamed due to duplicated names.")
708+
for repo in js.values():
709+
for desc in repo.values():
710+
desc.pop("rename", None)
711+
712+
# First, construct all descriptions; assign later, after we know
713+
# there won't be exceptions due to invalid file format
714+
both_descs = []
715+
warnings = []
716+
for old_desc, repo, desc_type in (
717+
(self.disc_descs, "categorical", DiscAttrDesc),
718+
(self.cont_descs, "numeric", ContAttrDesc)):
719+
var_by_name = {desc.var.name: desc.var for desc in old_desc}
720+
new_descs = {}
721+
for var_name, var_data in js[repo].items():
722+
var = var_by_name.get(var_name)
723+
if var is None:
724+
continue
725+
# This can throw InvalidFileFormat
726+
new_descs[var_name], warn = desc_type.from_dict(var, var_data)
727+
warnings += warn
728+
both_descs.append(new_descs)
729+
730+
self.disc_descs = [both_descs[0].get(desc.var.name, desc)
731+
for desc in self.disc_descs]
732+
self.cont_descs = [both_descs[0].get(desc.var.name, desc)
733+
for desc in self.cont_descs]
734+
if warnings:
735+
QMessageBox.warning(
736+
self, "Invalid definitions", "\n".join(warnings))
737+
738+
self.disc_model.set_data(self.disc_descs)
739+
self.cont_model.set_data(self.cont_descs)
740+
self.unconditional_commit()
741+
742+
def _parse_value_colors(self, js):
743+
if not isinstance(js, dict) or \
744+
any(not isinstance(obj, str) for obj in chain(js, js.values())):
745+
raise InvalidFileFormat
746+
try:
747+
js = {k: hex_to_color(v) for k, v in js.items()}
748+
except (ValueError, IndexError) as exc:
749+
raise InvalidFileFormat from exc
750+
751+
for desc in self.disc_descs:
752+
for i, value in enumerate(desc.var.values):
753+
if value in js:
754+
desc.set_color(i, js[value])
755+
756+
self.disc_model.set_data(self.disc_descs)
757+
self.unconditional_commit()
758+
759+
def _start_dir(self):
760+
return self.workflowEnv().get("basedir") \
761+
or QSettings().value("colorwidget/last-location") \
762+
or os.path.expanduser(f"~{os.sep}")
763+
527764
def commit(self):
528765
def make(variables):
529766
new_vars = []

0 commit comments

Comments
 (0)