Skip to content

Commit 536974f

Browse files
authored
Merge pull request #245 from janezd/imageviewer-context-attr
ImageViewer: Fix and improve setting default attributes
2 parents 6e99e5d + f2b77d6 commit 536974f

File tree

2 files changed

+150
-65
lines changed

2 files changed

+150
-65
lines changed

orangecontrib/imageanalytics/widgets/owimageviewer.py

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
QNetworkRequest,
4242
)
4343
from AnyQt.QtWidgets import QApplication, QShortcut
44-
from Orange.data import DiscreteVariable, Domain, StringVariable, Table, Variable
44+
from Orange.data import DiscreteVariable, StringVariable, Table, Variable
4545
from Orange.widgets import gui, settings
4646
from Orange.widgets.utils.annotated_data import create_annotated_table
4747
from Orange.widgets.utils.itemmodels import DomainModel
@@ -50,10 +50,7 @@
5050
from orangewidget.utils.itemmodels import PyListModel
5151
from orangewidget.widget import Message, Msg
5252

53-
from orangecontrib.imageanalytics.utils.image_utils import (
54-
extract_paths,
55-
filter_image_attributes,
56-
)
53+
from orangecontrib.imageanalytics.utils.image_utils import extract_paths
5754
from orangecontrib.imageanalytics.widgets.utils.imagepreview import Preview
5855
from orangecontrib.imageanalytics.widgets.utils.thumbnailview import (
5956
IconView as _IconView,
@@ -206,11 +203,6 @@ def _intersectSet(self, rect: QRect) -> List[QModelIndex]:
206203
return indices
207204

208205

209-
class MetaDomainModel(DomainModel):
210-
def set_domain(self, domain: Domain):
211-
super().set_domain(None if domain is None else Domain([], metas=domain.metas))
212-
213-
214206
class OWImageViewer(OWWidget):
215207
name = "Image Viewer"
216208
description = "View images referred to in the data."
@@ -226,13 +218,20 @@ class Outputs:
226218
selected_data = Output("Selected Images", Orange.data.Table)
227219
data = Output("Data", Orange.data.Table)
228220

229-
class Error(OWWidget.Error):
221+
class Warning(OWWidget.Warning):
230222
no_images_shown = Msg(
231-
"Unable to display images! Please ensure that the chosen "
232-
"Image Filename Attribute store the correct paths to the images."
223+
"Unable to display images. Check that the chosen "
224+
"Image Filename Attribute stores correct paths to images."
225+
)
226+
227+
class Error(OWWidget.Error):
228+
no_image_attr = Msg(
229+
"Data does not contain any variables with image file names or URLs.\n"
230+
"Data contains no text variables."
233231
)
234232

235233
settingsHandler = settings.DomainContextHandler()
234+
settings_version = 2
236235

237236
image_attr: Optional[Variable] = settings.ContextSetting(None)
238237
title_attr: Optional[Variable] = settings.ContextSetting(None)
@@ -257,9 +256,7 @@ def __init__(self):
257256
self._errcount = 0
258257
self._successcount = 0
259258

260-
self.image_model = MetaDomainModel(
261-
valid_types=(StringVariable, DiscreteVariable)
262-
)
259+
self.image_model = DomainModel(valid_types=StringVariable)
263260
gui.comboBox(
264261
self.controlArea,
265262
self,
@@ -311,44 +308,65 @@ def sizeHint(self):
311308

312309
@Inputs.data
313310
def setData(self, data):
314-
self.closeContext()
311+
if self.image_attr is not None:
312+
# Don't store invalid contexts because they will match anything
313+
# and crash the widget when they're used.
314+
self.closeContext()
315315
self.clear()
316-
self.data = data
317-
318-
if data is not None:
319-
self.image_model.set_domain(data.domain)
320-
self.title_model.set_domain(data.domain)
321-
im_attr = filter_image_attributes(self.data)
322-
self.image_attr = im_attr[0] if im_attr else None
323-
self.title_attr = self.title_model[0] if self.title_model else None
316+
if data is None:
317+
self.commit.now()
318+
return
324319

325-
self.openContext(data)
320+
self.image_model.set_domain(data.domain)
321+
self.title_model.set_domain(data.domain)
322+
if not self.image_model:
323+
self.Error.no_image_attr()
324+
self.commit.now()
325+
return
326326

327-
if self.image_model:
328-
self.setupModel()
327+
self.data = data
328+
self._propose_image_and_title_attr()
329+
self.openContext(data)
330+
self.setupModel()
329331
self.commit.now()
330332

331-
def __select_image_attr(self):
332-
for attr in self.image_model:
333-
if attr.attributes.get("type").lower() == "image":
334-
return attr
335-
# check if function already exist
336-
return self.image_model[0] if self.image_model else None
333+
def _propose_image_and_title_attr(self):
334+
self.image_attr = max(
335+
self.image_model,
336+
key=lambda attr: attr.attributes.get("type", "").lower() == "image"
337+
)
338+
# Use class variable if it exists. Otherwise,
339+
# prefer string variables (there will be at least one, otherwise
340+
# image_model is empty and widget reports an error,
341+
# but avoid those marked as "image" and in particular the one used
342+
# for image_attr
343+
self.title_attr = self.data.domain.class_var or max(
344+
# exclude separators
345+
(attr for attr in self.title_model if isinstance(attr, Variable)),
346+
key=lambda attr:
347+
isinstance(attr, StringVariable)
348+
and (3
349+
- (attr.attributes.get("type", "").lower() == "image")
350+
- (attr is self.image_attr))
351+
)
337352

338353
def clear(self):
339354
self.data = None
340-
self.Error.no_images_shown.clear()
355+
self.Warning.no_images_shown.clear()
356+
self.Error.no_image_attr.clear()
341357
if self.__watcher is not None:
342358
self.__watcher.finishedAt.disconnect(self.__on_load_finished)
343359
self.__watcher = None
344360
self._cancelAllTasks()
345361
self.clearModel()
362+
self.image_attr = None
363+
self.title_attr = None
346364
self.image_model.set_domain(None)
347365
self.title_model.set_domain(None)
348366
self.selected_items = set()
349367

350368
def setupModel(self):
351-
self.Error.no_images_shown.clear()
369+
self.Warning.no_images_shown.clear()
352370
if self.data is not None:
353371
urls = column_data_as_qurl(self.data, self.image_attr)
354372
titles = column_data_as_str(self.data, self.title_attr)
@@ -377,11 +395,11 @@ def setupModel(self):
377395
self.thumbnailView.selectionModel().selectionChanged.connect(
378396
self.onSelectionChanged
379397
)
380-
self.__watcher = FutureSetWatcher()
381-
self.__watcher.setFutures([it.future for it in self.items])
382-
self.__watcher.finishedAt.connect(self.__on_load_finished)
383-
self.__set_selected_items()
384-
self._updateStatus()
398+
self.__watcher = FutureSetWatcher()
399+
self.__watcher.setFutures([it.future for it in self.items])
400+
self.__watcher.finishedAt.connect(self.__on_load_finished)
401+
self.__set_selected_items()
402+
self._updateStatus()
385403

386404
def __set_selected_items(self):
387405
model = self.thumbnailView.model()
@@ -463,12 +481,23 @@ def commit(self):
463481
def _updateStatus(self):
464482
count = len([item for item in self.items if item.future is not None])
465483
if self._errcount == count:
466-
self.Error.no_images_shown()
484+
self.Warning.no_images_shown()
467485

468486
def onDeleteWidget(self):
469487
self.clear()
470488
super().onDeleteWidget()
471489

490+
@classmethod
491+
def migrate_context(cls, context, version):
492+
if version < 2:
493+
# Remove contexts in which image_attr is None because they match
494+
# anything and crash the widget.
495+
# Also remove context in which image_attr is not a string variable
496+
# because widget now requires a string variable.
497+
image_attr = context.values.get("image_attr")
498+
if image_attr is None or image_attr[1] != 103:
499+
raise settings.IncompatibleContext
500+
472501

473502
def column_data_as_qurl(
474503
table: Table, var: [StringVariable, DiscreteVariable]

orangecontrib/imageanalytics/widgets/tests/test_owimageviewer.py

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
from AnyQt.QtCore import QItemSelection, QItemSelectionModel
2-
from typing import Set
3-
41
import os
2+
from typing import Set
53
import unittest
4+
from unittest.mock import Mock
5+
66
import numpy as np
77
from numpy.testing import assert_array_equal
88

9+
from AnyQt.QtCore import QItemSelection, QItemSelectionModel
10+
911
from Orange.data import (
1012
Table,
1113
StringVariable,
@@ -106,7 +108,7 @@ def test_selection(self):
106108
d = self.image_data
107109
str_var.attributes["origin"] = d.domain["Image"].attributes["origin"]
108110
new_data = Table(
109-
Domain([], metas=(str_var,) + d.domain.metas[1:]),
111+
Domain([], metas=(str_var,) + d.domain.metas[:-1]),
110112
np.empty((len(d), 0)),
111113
metas=d.metas,
112114
)
@@ -132,44 +134,98 @@ def test_selection_schema(self):
132134
assert_array_equal(self.image_data[2:3].metas, output.metas)
133135

134136
def test_settings_schema(self):
135-
data = Table("zoo")
136-
data = data.transform(
137-
Domain([], metas=data.domain.metas + data.domain.attributes)
138-
)
137+
domain = Domain([], None, [StringVariable(n) for n in "abc"])
138+
data = Table.from_list(domain, [list("abc")] * 3)
139139
self.send_signal(self.widget.Inputs.data, data)
140140

141-
simulate.combobox_activate_item(self.widget.controls.image_attr, "toothed")
142-
simulate.combobox_activate_item(self.widget.controls.title_attr, "toothed")
141+
simulate.combobox_activate_item(self.widget.controls.image_attr, "b")
142+
simulate.combobox_activate_item(self.widget.controls.title_attr, "c")
143143

144144
settings = self.widget.settingsHandler.pack_data(self.widget)
145145
widget = self.create_widget(OWImageViewer, stored_settings=settings)
146146
self.send_signal(widget.Inputs.data, data, widget=widget)
147147

148-
self.assertEqual(data.domain["toothed"], self.widget.image_attr)
149-
self.assertEqual(data.domain["toothed"], self.widget.title_attr)
148+
self.assertEqual(data.domain["b"], self.widget.image_attr)
149+
self.assertEqual(data.domain["c"], self.widget.title_attr)
150150

151151
def test_set_attributes(self):
152152
data = self.image_data
153153
# by default - image attribute is one with type image
154154
self.send_signal(self.widget.Inputs.data, data)
155155
self.assertEqual(data.domain["Image"], self.widget.image_attr)
156-
self.assertEqual(data.domain["b"], self.widget.title_attr)
156+
self.assertEqual(data.domain["Image"], self.widget.title_attr)
157+
self.__select_images({"afternoon-4175917_640.jpg", "atomium-4179270_640.jpg"})
158+
self.assertIsNotNone(self.get_output(self.widget.Outputs.selected_data))
159+
self.assertIsNotNone(self.get_output(self.widget.Outputs.data))
157160

158-
# none of attributes have type image select first non-continuous from meta
161+
# no suitable attributes
159162
data = data.transform(
160163
Domain(data.domain.attributes, metas=data.domain.metas[:2])
161164
)
162165
self.send_signal(self.widget.Inputs.data, data)
163-
self.assertEqual(data.domain["c"], self.widget.image_attr)
164-
self.assertEqual(data.domain["b"], self.widget.title_attr)
166+
self.assertEqual(None, self.widget.image_attr)
167+
self.assertEqual(None, self.widget.title_attr)
168+
self.assertTrue(self.widget.Error.no_image_attr.is_shown())
169+
self.assertIsNone(self.get_output(self.widget.Outputs.selected_data))
170+
self.assertIsNone(self.get_output(self.widget.Outputs.data))
165171

166-
# no suitable attributes - image_attr is None
167-
data = data.transform(
168-
Domain(data.domain.attributes, metas=data.domain.metas[:1])
169-
)
170-
self.send_signal(self.widget.Inputs.data, data)
171-
self.assertIsNone(self.widget.image_attr)
172-
self.assertEqual(data.domain["b"], self.widget.title_attr)
172+
self.send_signal(self.widget.Inputs.data, None)
173+
self.assertFalse(self.widget.Error.no_image_attr.is_shown())
174+
175+
def test_default_attr_priority(self):
176+
w = self.widget
177+
w.data = Mock()
178+
w.data.domain.class_var = None
179+
180+
attrs = [
181+
DiscreteVariable("a", values=["a", "b", "c"]),
182+
ContinuousVariable("b")
183+
]
184+
class_var = DiscreteVariable("c", values=["a", "b", "c"])
185+
metas = [
186+
ContinuousVariable("d"),
187+
DiscreteVariable("e", values=["a", "b", "c"])
188+
] + [StringVariable(f"s{i}") for i in range(4)]
189+
*_, s0, s1, s2, s3 = metas
190+
s1.attributes = s2.attributes = {"type": "image"}
191+
192+
domain = Domain(attrs, class_var, metas)
193+
w.image_model.set_domain(domain)
194+
w.title_model.set_domain(domain)
195+
w._propose_image_and_title_attr()
196+
self.assertIs(s1, w.image_attr)
197+
self.assertIs(s0, w.title_attr)
198+
199+
w.data.domain.class_var = class_var
200+
w.image_attr = w.title_attr = None
201+
w._propose_image_and_title_attr()
202+
self.assertIs(s1, w.image_attr)
203+
self.assertIs(class_var, w.title_attr)
204+
205+
w.data.domain.class_var = None
206+
domain = Domain(attrs, class_var, metas[3:])
207+
w.image_model.set_domain(domain)
208+
w.title_model.set_domain(domain)
209+
w.image_attr = w.title_attr = None
210+
w._propose_image_and_title_attr()
211+
self.assertIs(s1, w.image_attr)
212+
self.assertIs(s3, w.title_attr)
213+
214+
domain = Domain(attrs, class_var, metas[3:-1])
215+
w.image_model.set_domain(domain)
216+
w.title_model.set_domain(domain)
217+
w.image_attr = w.title_attr = None
218+
w._propose_image_and_title_attr()
219+
self.assertIs(s1, w.image_attr)
220+
self.assertIs(s2, w.title_attr)
221+
222+
domain = Domain(attrs, class_var, metas[3:4])
223+
w.image_model.set_domain(domain)
224+
w.title_model.set_domain(domain)
225+
w.image_attr = w.title_attr = None
226+
w._propose_image_and_title_attr()
227+
self.assertIs(s1, w.image_attr)
228+
self.assertIs(s1, w.title_attr)
173229

174230

175231
if __name__ == "__main__":

0 commit comments

Comments
 (0)