Skip to content

Commit

Permalink
OWColor: Saving and loading color schemata
Browse files Browse the repository at this point in the history
  • Loading branch information
janezd committed Sep 9, 2020
1 parent b511e66 commit 47a6a9b
Showing 1 changed file with 243 additions and 6 deletions.
249 changes: 243 additions & 6 deletions Orange/widgets/data/owcolor.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import os
from collections import defaultdict
from itertools import chain
import json

import numpy as np

from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex, QTimer
from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex, QTimer, \
QSettings
from AnyQt.QtGui import QColor, QFont, QBrush
from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView, QComboBox
from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView, QComboBox, \
QFileDialog, QMessageBox

import Orange
from Orange.preprocess.transformation import Identity
from Orange.util import color_to_hex
from Orange.util import color_to_hex, hex_to_color
from Orange.widgets import widget, settings, gui
from Orange.widgets.gui import HorizontalGridDelegate
from Orange.widgets.utils import itemmodels, colorpalettes
Expand All @@ -21,6 +26,16 @@
StripRole = next(gui.OrangeUserRole)


class InvalidFileFormat(Exception):
pass


def _check_dict_str_str(d):
if not isinstance(d, dict) or \
not all(isinstance(val, str) for val in chain(d, d.values())):
raise InvalidFileFormat


class AttrDesc:
"""
Describes modifications that will be applied to variable.
Expand All @@ -46,6 +61,22 @@ def name(self):
def name(self, name):
self.new_name = name

def to_dict(self):
d = {}
if self.new_name is not None:
d["rename"] = self.new_name
return d

@classmethod
def from_dict(cls, var, data):
desc = cls(var)
new_name = data.get("rename")
if new_name is not None:
if not isinstance(desc.name, str):
raise InvalidFileFormat
desc.name = new_name
return desc, []


class DiscAttrDesc(AttrDesc):
"""
Expand Down Expand Up @@ -96,6 +127,49 @@ def create_variable(self):
new_var.colors = np.asarray(self.colors)
return new_var

def to_dict(self):
d = super().to_dict()
if self.new_values is not None:
d["renamed_values"] = \
{k: v
for k, v in zip(self.var.values, self.new_values)
if k != v}
if self.new_colors is not None:
d["colors"] = {value: color_to_hex(color)
for value, color in zip(self.values, self.colors)}
return d

@classmethod
def from_dict(cls, var, data):
obj, warnings = super().from_dict(var, data)

val_map = data.get("renamed_values")
if val_map is not None:
_check_dict_str_str(val_map)
mapped_values = [val_map.get(value, value) for value in var.values]
if len(set(mapped_values)) != len(mapped_values):
warnings.append(
f"{var.name}: "
"renaming of values ignored due to duplicate names")
else:
obj.new_values = mapped_values

new_colors = data.get("colors")
if new_colors is not None:
_check_dict_str_str(new_colors)
colors = []
for value, def_color in zip(var.values, var.palette):
if value in new_colors:
try:
color = hex_to_color(new_colors[value])
except (IndexError, ValueError):
raise InvalidFileFormat
colors.append(color)
else:
colors.append(def_color)
obj.new_colors = colors
return obj, warnings


class ContAttrDesc(AttrDesc):
"""
Expand Down Expand Up @@ -136,6 +210,22 @@ def create_variable(self):
new_var.attributes["palette"] = self.palette_name
return new_var

def to_dict(self):
d = super().to_dict()
if self.new_palette_name is not None:
d["colors"] = self.palette_name
return d

@classmethod
def from_dict(cls, var, data):
obj, warnings = super().to_dict(var, d)
colors = data.get("colors")
if colors is not None:
if colors not in colorpalettes.ContinuousPalettes:
raise InvalidFileFormat
obj.palette_name = colors
return obj, warnings


class ColorTableModel(QAbstractTableModel):
"""
Expand Down Expand Up @@ -454,14 +544,17 @@ class Outputs:
match_values=settings.PerfectDomainContextHandler.MATCH_VALUES_ALL)
disc_descs = settings.ContextSetting([])
cont_descs = settings.ContextSetting([])
color_settings = settings.Setting(None)
selected_schema_index = settings.Setting(0)
auto_apply = settings.Setting(True)

settings_version = 2

want_main_area = False

FileFilters = [
"Settings for individual variables (*.vdefs)",
"General color encoding for values (*.colors)"]

def __init__(self):
super().__init__()
self.data = None
Expand All @@ -481,9 +574,13 @@ def __init__(self):

box = gui.auto_apply(self.controlArea, self, "auto_apply")
box.button.setFixedWidth(180)
save = gui.button(None, self, "Save", callback=self.save)
load = gui.button(None, self, "Load", callback=self.load)
reset = gui.button(None, self, "Reset", callback=self.reset)
box.layout().insertWidget(0, reset)
box.layout().insertStretch(1)
box.layout().insertWidget(0, save)
box.layout().insertWidget(0, load)
box.layout().insertWidget(2, reset)
box.layout().insertStretch(3)

self.info.set_input_summary(self.info.NoInput)
self.info.set_output_summary(self.info.NoOutput)
Expand Down Expand Up @@ -524,6 +621,146 @@ def reset(self):
self.cont_model.reset()
self.commit()

def save(self):
fname, ffilter = QFileDialog.getSaveFileName(
self, "File name", self._start_dir(), ";;".join(self.FileFilters))
if not fname:
return
QSettings().setValue("colorwidget/last-location",
os.path.split(fname)[0])
if ffilter == self.FileFilters[0]:
self._save_var_defs(fname)
else:
self._save_value_colors(fname)

def _save_var_defs(self, fname):
json.dump(
{vartype: {
var.name: var_data
for var, var_data in (
(desc.var, desc.to_dict()) for desc in repo)
if var_data}
for vartype, repo in (("categorical", self.disc_descs),
("numeric", self.cont_descs))
},
open(fname, "w"),
indent=4)

def _save_value_colors(self, fname):
color_map = defaultdict(set)
for desc in self.disc_descs:
if desc.new_colors is None:
continue
for value, old_color, new_color in zip(
desc.var.values, desc.var.palette.palette, desc.new_colors):
old_hex, new_hex = map(color_to_hex, (old_color, new_color))
if old_hex != new_hex:
color_map[value].add(new_hex)
js = {value: colors.pop()
for value, colors in color_map.items()
if len(colors) == 1}
json.dump(js, open(fname, "w"), indent=4)

def load(self):
try:
fname, ffilter = QFileDialog.getOpenFileName(
self, "File name", self._start_dir(),
";;".join(self.FileFilters))
if not fname:
return
try:
js = json.load(open(fname)) #: dict
except IOError:
QMessageBox.critical(self, "File error",
"File cannot be opened.")
return
except json.JSONDecodeError:
raise InvalidFileFormat
if ffilter == self.FileFilters[0]:
self._parse_var_defs(js)
else:
self._parse_value_colors(js)
except InvalidFileFormat:
QMessageBox.critical(self, "File error", "Invalid file format.")
else:
self.unconditional_commit()

def _parse_var_defs(self, js):
if not isinstance(js, dict):
raise InvalidFileFormat
renames = {
var_name: desc["rename"]
for repo in js.values() for var_name, desc in repo.items()
if "rename" in desc
}
if not all(isinstance(val, str)
for val in chain(renames, renames.values())):
raise InvalidFileFormat
renamed_vars = {
renames.get(desc.var.name, desc.var.name)
for desc in chain(self.disc_descs, self.cont_descs)
}
if len(renamed_vars) != len(self.disc_descs) + len(self.cont_descs):
QMessageBox.warning(
self,
"Duplicated variable names",
"Variables will not be renamed due to duplicated names.")
for repo in js.values():
for desc in repo.values():
desc.pop("rename", None)

# First, construct all descriptions; assign later, after we know
# there won't be exceptions due to invalid file format
both_descs = []
warnings = []
for old_desc, repo, desc_type in (
(self.disc_descs, "categorical", DiscAttrDesc),
(self.cont_descs, "numeric", ContAttrDesc)):
var_by_name = {desc.var.name: desc.var for desc in old_desc}
new_descs = {}
for var_name, var_data in js[repo].items():
var = var_by_name.get(var_name)
if var is None:
continue
# This can throw InvalidFileFormat
new_descs[var_name], warn = desc_type.from_dict(var, var_data)
warnings += warn
both_descs.append(new_descs)

self.disc_descs = [both_descs[0].get(desc.var.name, desc)
for desc in self.disc_descs]
self.cont_descs = [both_descs[0].get(desc.var.name, desc)
for desc in self.cont_descs]
if warnings:
QMessageBox.warning(
self, "Invalid definitions", "\n".join(warnings))

self.disc_model.set_data(self.disc_descs)
self.cont_model.set_data(self.cont_descs)
self.unconditional_commit()

def _parse_value_colors(self, js):
if not isinstance(js, dict) or \
any(not isinstance(obj, str) for obj in chain(js, js.values())):
raise InvalidFileFormat
try:
js = {k: hex_to_color(v) for k, v in js.items()}
except (ValueError, IndexError):
raise InvalidFileFormat

for desc in self.disc_descs:
for i, value in enumerate(desc.var.values):
if value in js:
desc.set_color(i, js[value])

self.disc_model.set_data(self.disc_descs)
self.unconditional_commit()

def _start_dir(self):
return self.workflowEnv().get("basedir") \
or QSettings().value("colorwidget/last-location") \
or os.path.expanduser(f"~{os.sep}")

def commit(self):
def make(variables):
new_vars = []
Expand Down

0 comments on commit 47a6a9b

Please sign in to comment.