Skip to content

Commit

Permalink
Merge pull request #6990 from ales-erjavec/paint-data-opt
Browse files Browse the repository at this point in the history
[ENH] Paint Data: Slight optimization
  • Loading branch information
markotoplak authored Feb 14, 2025
2 parents e67650f + e7fd9d9 commit d460207
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 33 deletions.
87 changes: 55 additions & 32 deletions Orange/widgets/data/owpaintdata.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@

from __future__ import annotations
import os
import unicodedata
import itertools
Expand Down Expand Up @@ -90,6 +90,7 @@ def stack_on_condition(a, b, condition):
Magnet = namedtuple("Magnet", ["pos", "radius", "density"])
SelectRegion = namedtuple("SelectRegion", ["region"])
DeleteSelection = namedtuple("DeleteSelection", [])
DeleteAll = namedtuple("DeleteAll", [])
MoveSelection = namedtuple("MoveSelection", ["delta"])


Expand All @@ -112,7 +113,7 @@ def transform(command, data):
def append(command, data):
np.clip(command.points[:, :2], 0, 1, out=command.points[:, :2])
return (np.vstack([data, command.points]),
DeleteIndices(slice(len(data),
DeleteIndices(range(len(data),
len(data) + len(command.points))))


Expand All @@ -125,7 +126,7 @@ def insert(command, data):

@transform.register(DeleteIndices)
def delete(command, data, ):
if isinstance(command.indices, slice):
if isinstance(command.indices, range):
condition = indices_to_mask(command.indices, len(data))
else:
indices = np.asarray(command.indices)
Expand All @@ -139,7 +140,11 @@ def delete(command, data, ):

@transform.register(Move)
def move(command, data):
data[command.indices] += command.delta
if isinstance(command.indices, tuple):
idx = np.ix_(*command.indices)
else:
idx = command.indices
data[idx] += command.delta
return data, Move(command.indices, -command.delta)


Expand Down Expand Up @@ -565,7 +570,7 @@ class ClearTool(DataTool):

def activate(self):
self.editingStarted.emit()
self.issueCommand.emit(DeleteIndices(slice(None, None, None)))
self.issueCommand.emit(DeleteAll())
self.editingFinished.emit()


Expand Down Expand Up @@ -631,18 +636,23 @@ def indices_eq(ind1, ind2):
if len(ind1) != len(ind2):
return False
return all(indices_eq(i1, i2) for i1, i2 in zip(ind1, ind2))
elif isinstance(ind1, slice) and isinstance(ind2, slice):
elif isinstance(ind1, range) and isinstance(ind2, range):
return ind1 == ind2
elif ind1 is ... and ind2 is ...:
return True

ind1, ind1 = np.array(ind1), np.array(ind2)
ind1, ind2 = np.array(ind1), np.array(ind2)

if ind1.shape != ind2.shape or ind1.dtype != ind2.dtype:
return False
return (ind1 == ind2).all()


def merged_range(r1: range, r2: range) -> range | None:
r1, r2 = sorted([r1, r2], key=lambda r: r.start)
if r1.stop == r2.start and r1.step == r2.step:
return range(r1.start, r2.stop, r1.step)
return None


def merge_cmd(composit):
f = composit.f
g = composit.g
Expand All @@ -656,11 +666,11 @@ def merge_cmd(composit):
if indices_eq(f.indices, g.indices):
return Move(f.indices, f.delta + g.delta)
else:
# TODO: union of indices, ...
return composit
# elif isinstance(f, DeleteIndices) and isinstance(g, DeleteIndices):
# indices = np.array(g.indices)
# return DeleteIndices(indices)
elif isinstance(f, DeleteIndices) and isinstance(g, DeleteIndices) \
and isinstance(f.indices, range) and isinstance(g.indices, range) \
and (r := merged_range(f.indices, g.indices)) is not None:
return DeleteIndices(r)
else:
return composit

Expand Down Expand Up @@ -1064,19 +1074,14 @@ def reset_to_input(self):

self.commit.deferred()

def add_new_class_label(self, undoable=True):

def add_new_class_label(self):
newlabel = next(label for label in namegen('C', 1)
if label not in self.class_model)

command = SimpleUndoCommand(
lambda: self.class_model.append(newlabel),
lambda: self.class_model.__delitem__(-1)
)
if undoable:
self.undo_stack.push(command)
else:
command.redo()
self.undo_stack.push(command)

def remove_selected_class_label(self):
index = self.selected_class_label()
Expand All @@ -1090,7 +1095,7 @@ def remove_selected_class_label(self):

self.undo_stack.beginMacro("Delete class label")
self.undo_stack.push(UndoCommand(DeleteIndices(mask), self))
self.undo_stack.push(UndoCommand(Move((move_mask, 2), -1), self))
self.undo_stack.push(UndoCommand(Move((move_mask, range(2, 3)), -1), self))
self.undo_stack.push(
SimpleUndoCommand(lambda: self.class_model.__delitem__(index),
lambda: self.class_model.insert(index, label)))
Expand Down Expand Up @@ -1158,7 +1163,7 @@ def _on_editing_finished(self):
self.undo_stack.endMacro()

def execute(self, command):
assert isinstance(command, (Append, DeleteIndices, Insert, Move)), \
assert isinstance(command, (Append, DeleteIndices, DeleteAll, Insert, Move)), \
"Non normalized command"
if isinstance(command, (DeleteIndices, Insert)):
self._selected_indices = None
Expand Down Expand Up @@ -1197,12 +1202,17 @@ def _add_command(self, cmd):
self.undo_stack.push(
UndoCommand(DeleteIndices(indices), self, text="Delete")
)
elif isinstance(cmd, DeleteAll):
indices = range(0, len(self.__buffer))
self.undo_stack.push(
UndoCommand(DeleteIndices(indices), self, text="Clear All")
)
elif isinstance(cmd, MoveSelection):
indices = self._selected_indices
if indices is not None and indices.size:
self.undo_stack.push(
UndoCommand(
Move((self._selected_indices, slice(0, 2)),
Move((self._selected_indices, range(0, 2)),
np.array([cmd.delta.x(), cmd.delta.y()])),
self, text="Move")
)
Expand All @@ -1222,12 +1232,12 @@ def _add_command(self, cmd):
point = np.array([cmd.pos.x(), cmd.pos.y()])
delta = - apply_jitter(self.__buffer[:, :2], point,
self.density / 100.0, 0, cmd.rstate)
self._add_command(Move((..., slice(0, 2)), delta))
self._add_command(Move((range(0, len(self.__buffer)), range(0, 2)), delta))
elif isinstance(cmd, Magnet):
point = np.array([cmd.pos.x(), cmd.pos.y()])
delta = - apply_attractor(self.__buffer[:, :2], point,
self.density / 100.0, 0)
self._add_command(Move((..., slice(0, 2)), delta))
self._add_command(Move((range(0, len(self.__buffer)), range(0, 2)), delta))
else:
assert False, "unreachable"

Expand All @@ -1246,16 +1256,17 @@ def pen(color):
y = self.__buffer[:, 1].copy()
else:
y = np.zeros(self.__buffer.shape[0])

colors = self.colors[self.__buffer[:, 2]]
pens = [pen(c) for c in colors]
brushes = [QBrush(c) for c in colors]

color_table, colors_index = prepare_color_table_and_index(
self.colors, self.__buffer[:, 2]
)
pen_table = np.array([pen(c) for c in color_table], dtype=object)
brush_table = np.array([QBrush(c) for c in color_table], dtype=object)
pens = pen_table[colors_index]
brushes = brush_table[colors_index]
self._scatter_item = pg.ScatterPlotItem(
x, y, symbol="+", brush=brushes, pen=pens
x, y, symbol="+", brush=brushes, pen=pens, size=self.symbol_size
)
self.plot.addItem(self._scatter_item)
self.set_symbol_size()

def _attr_name_changed(self):
self.plot.getAxis("bottom").setLabel(self.attr1)
Expand Down Expand Up @@ -1322,5 +1333,17 @@ def send_report(self):
self.report_plot()


def prepare_color_table_and_index(
palette: colorpalettes.IndexedPalette, data: np.ndarray[float]
) -> tuple[np.ndarray[object], np.ndarray[np.intp]]:
# to index array and map nan to -1
index = np.full(data.shape, -1, np.intp)
mask = np.isnan(data)
np.copyto(index, data, where=~mask, casting="unsafe")
color_table = np.array([c for c in palette] + [palette[np.nan]], dtype=object)
return color_table, index



if __name__ == "__main__": # pragma: no cover
WidgetPreview(OWPaintData).run()
120 changes: 119 additions & 1 deletion Orange/widgets/data/tests/test_owpaintdata.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
# Test methods with long descriptive names can omit docstrings
# pylint: disable=missing-docstring, protected-access
import unittest

import numpy as np
from numpy.testing import assert_array_equal, assert_almost_equal
import scipy.sparse as sp

from AnyQt.QtCore import QRectF, QPointF, QEvent, Qt
from AnyQt.QtCore import QRectF, QPointF, QPoint ,QEvent, Qt
from AnyQt.QtGui import QMouseEvent
from AnyQt.QtTest import QTest

from orangecanvas.gui.test import mouseMove

from Orange.data import Table, DiscreteVariable, ContinuousVariable, Domain
from Orange.widgets.utils import itemmodels
from Orange.widgets.data import owpaintdata
from Orange.widgets.data.owpaintdata import OWPaintData
from Orange.widgets.tests.base import WidgetTest, datasets
Expand Down Expand Up @@ -120,3 +126,115 @@ def test_reset_to_input(self):
self.widget.reset_to_input()
output = self.get_output(self.widget.Outputs.data)
self.assertEqual(len(output), len(data))

def test_tools_interaction(self):
def mouse_path(stroke, button=Qt.LeftButton, delay=50):
assert len(stroke) > 2
QTest.mousePress(viewport, button, pos=stroke[0], delay=delay)
for p in stroke[1:-1]:
mouseMove(viewport, button, pos=p, delay=delay)
QTest.mouseRelease(viewport, button, pos=stroke[-1], delay=delay)

def assert_close(p1, p2):
assert_almost_equal(np.array(p1), np.array(p2))

w = self.widget
w.adjustSize()
viewport = w.plotview.viewport()
center = viewport.rect().center()
# Put single point
w.set_current_tool(owpaintdata.PutInstanceTool)
QTest.mouseClick(viewport, Qt.LeftButton)
p0 = w.data[0]
# Air brush stroke
w.set_current_tool(owpaintdata.AirBrushTool)
mouse_path([center, center + QPoint(5, 5), center + QPoint(5, 10), center + QPoint(0, 10)])

w.set_current_tool(owpaintdata.SelectTool)

# Draw selection rect
mouse_path([center - QPoint(100, 100), center, center + QPoint(100, 100)])
# Move selection
mouse_path([center, center + QPoint(30, 30), center + QPoint(50, 50)])
self.assertNotEqual(w.data[0], p0)
count = len(w.data)

w.current_tool.delete() #
self.assertNotEqual(len(w.data), count)

w.set_current_tool(owpaintdata.ClearTool)
self.assertEqual(len(w.data), 0)
w.undo_stack.undo() # clear
w.undo_stack.undo() # delete selection
w.undo_stack.undo() # move
assert_close(w.data[0], p0)

stroke = [center - QPoint(10, 10), center, center + QPoint(10, 10)]

w.set_current_tool(owpaintdata.MagnetTool)
mouse_path(stroke)
w.undo_stack.undo()
assert_close(w.data[0], p0)

w.set_current_tool(owpaintdata.JitterTool)
mouse_path(stroke)
w.undo_stack.undo()
assert_close(w.data[0], p0)

def test_add_remove_class(self):
def put_instance():
w.set_current_tool(owpaintdata.PutInstanceTool)
QTest.mouseClick(viewport, Qt.LeftButton)

def assert_class_column_equal(data):
assert_array_equal(np.array(w.data)[:, 2].ravel(), data)

w = self.widget
viewport = w.plotview.viewport()
put_instance()
itemmodels.select_row(w.classValuesView, 1)
put_instance()
w.add_new_class_label()
itemmodels.select_row(w.classValuesView, 2)
put_instance()
self.assertSequenceEqual(w.class_model, ["C1", "C2", "C3"])
assert_class_column_equal([0, 1, 2])
itemmodels.select_row(w.classValuesView, 0)
w.remove_selected_class_label()
self.assertSequenceEqual(w.class_model, ["C2", "C3"])
assert_class_column_equal([0, 1])
w.undo_stack.undo()
self.assertSequenceEqual(w.class_model, ["C1", "C2", "C3"])
assert_class_column_equal([0, 1, 2])


class TestCommands(unittest.TestCase):
def test_merge_cmd(self): # pylint: disable=import-outside-toplevel
from Orange.widgets.data.owpaintdata import (
Append, Move, DeleteIndices, Composite, merge_cmd
)

def merge(a, b):
return merge_cmd(Composite(a, b))

a1 = Append(np.array([[0., 0., 1.], [1., 1., 0.]]))
a2 = Append(np.array([[2., 2., 1,]]))
c = merge(a1, a2)
self.assertIsInstance(c, Append)
assert_array_equal(c.points, np.array([[0., 0., 1.], [1, 1, 0], [2, 2, 1]]))
m1 = Move(range(2), np.array([1., 1., 0.]))
m2 = Move(range(2), np.array([.5, .5, -1]))
c = merge(m1, m2)
self.assertIsInstance(c, Move)
assert_array_equal(c.delta, np.array([1.5, 1.5, -1]))
c = merge(m1, Move(range(100, 102), np.array([1., 1., 1.])))
self.assertIsInstance(c, Composite)

c = merge(m1, Move([100, 105], np.array([0., 0, 0])))
self.assertIsInstance(c, Composite)

d1 = DeleteIndices(range(0, 3))
d2 = DeleteIndices(range(3, 5))
c = merge(d1, d2)
self.assertIsInstance(c, DeleteIndices)
self.assertEqual(c.indices, range(0, 5))
4 changes: 4 additions & 0 deletions i18n/si/msgs.jaml
Original file line number Diff line number Diff line change
Expand Up @@ -6757,6 +6757,7 @@ widgets/data/owpaintdata.py:
SelectRegion: false
region: false
DeleteSelection: false
DeleteAll: false
MoveSelection: false
class `PaintViewBox`:
def `__init__`:
Expand Down Expand Up @@ -6852,6 +6853,7 @@ widgets/data/owpaintdata.py:
def `_add_command`:
Name: Ime
Delete: Brisanje
Clear All: Pobriši vse
Move: Premik
unreachable: false
def `_replot`:
Expand All @@ -6871,6 +6873,8 @@ widgets/data/owpaintdata.py:
Axis y: Os y
Number of points: Število točk
Painted data: Narisani podatki
def `prepare_color_table_and_index`:
unsafe: false
__main__: false
widgets/data/owpivot.py:
class `Pivot`:
Expand Down

0 comments on commit d460207

Please sign in to comment.