Repository: biolab/orange3

Issue:
Slow plotting in Scatter Plot
<!--
This is an issue template. Please fill in the relevant details in the
sections below.

Wrap code and verbatim terminal window output into triple backticks, see:
https://help.github.com/articles/basic-writing-and-formatting-syntax/#quoting-code

If you're raising an issue about an add-on (e.g. installed via
Options > Add-ons), raise an issue on the relevant add-on's issue
tracker instead. See: https://github.com/biolab?q=orange3
-->

##### Orange version
<!-- From menu _Help→About→Version_ or code `Orange.version.full_version` -->
3.21.0.dev0+920e474

##### Expected behavior
Plots should not slow down drastically when receiving big(ish) datasets.


##### Actual behavior
Plotting 40000 points in scatterplot is already very slow.


##### Steps to reproduce the behavior
Load big dataset
Connect Scatter Plot
Change Axes and observe replotting speed


##### Additional info (worksheets, data, screenshots, ...)
Using the following code in command line pyqtgraph plots 100000 points in  about 1 sec.
```
import pyqtgraph as pg
import numpy as np
a = np.random.random(100000)
b = np.random.random(100000)
pg.plot(a, b, pen=None, symbol='o')
```



Discussion:
I've looked into this a bit. Behind the scenes, pyqtgraph's scatterplot builds an atlas of symbols to use, but at least for same color scatterplots and scatterplots colored with a continuous feature, we prepare data in a way which prevents pyqtgraph to be efficient.

If I make the following change to `OWScatterPlotBase._get_same_colors` I get more than 5x speedup on data with 40000 rows.

```
@@ -845,7 +845,8 @@ class OWScatterPlotBase(gui.OWComponent, QObject):
             (tuple): a list of pens and list of brushes
         """
         color = self.plot_widget.palette().color(OWPalette.Data)
-        pen = [_make_pen(color, 1.5) for _ in range(self.n_shown)]
+        single_pen = _make_pen(color, 1.5)
+        pen = [single_pen for _ in range(self.n_shown)]
         if subset is not None:
             brush = np.where(
                 subset,
@@ -854,7 +855,8 @@ class OWScatterPlotBase(gui.OWComponent, QObject):
         else:
             color = QColor(*self.COLOR_DEFAULT)
             color.setAlpha(self.alpha_value)
-            brush = [QBrush(color) for _ in range(self.n_shown)]
+            single_brush = QBrush(color)
+            brush = [single_brush for _ in range(self.n_shown)]
         return pen, brush
```
Also, for new pairs of features, graph is redrawn each time a certain property (positions, colors, sizes, ...) is set. For a new pair of attributes, I see 7 calls to `updateSpots` in pyqtgraph, but only one is needed.

When I profile with the changes from the previous comment, `updateSpots` takes approximately 70% of time for updates, so reducing the number of calls could make plots a bit more than 50% faster. 

So altogether, we could make it 10x faster.

Resolving patch:
diff --git a/Orange/widgets/visualize/owscatterplotgraph.py b/Orange/widgets/visualize/owscatterplotgraph.py
--- a/Orange/widgets/visualize/owscatterplotgraph.py
+++ b/Orange/widgets/visualize/owscatterplotgraph.py
@@ -221,7 +221,20 @@ def __init__(self, scatter_widget, parent=None, _="None", view_box=InteractiveVi
 
 
 class ScatterPlotItem(pg.ScatterPlotItem):
+    """PyQtGraph's ScatterPlotItem calls updateSpots at any change of sizes/colors/symbols,
+    which then rebuilds the stored pixmaps for each symbol. Because Orange calls
+    set* function in succession, we postpone updateSpots() to paint()."""
+
+    _update_spots_in_paint = False
+
+    def updateSpots(self, dataSet=None):  # pylint: disable=unused-argument
+        self._update_spots_in_paint = True
+        self.update()
+
     def paint(self, painter, option, widget=None):
+        if self._update_spots_in_paint:
+            self._update_spots_in_paint = False
+            super().updateSpots()
         painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
         super().paint(painter, option, widget)
 
@@ -714,6 +727,7 @@ def get_sizes(self):
 
         The method is called by `update_sizes`. It gets the sizes
         from the widget and performs the necessary scaling and sizing.
+        The output is rounded to half a pixel for faster drawing.
 
         Returns:
             (np.ndarray): sizes
@@ -732,7 +746,11 @@ def get_sizes(self):
             size_column /= mx
         else:
             size_column[:] = 0.5
-        return self.MinShapeSize + (5 + self.point_width) * size_column
+
+        sizes = self.MinShapeSize + (5 + self.point_width) * size_column
+        # round sizes to half pixel for smaller pyqtgraph's symbol pixmap atlas
+        sizes = (sizes * 2).round() / 2
+        return sizes
 
     def update_sizes(self):
         """
@@ -861,7 +879,7 @@ def _get_same_colors(self, subset):
             (tuple): a list of pens and list of brushes
         """
         color = self.plot_widget.palette().color(OWPalette.Data)
-        pen = [_make_pen(color, 1.5) for _ in range(self.n_shown)]
+        pen = [_make_pen(color, 1.5)] * self.n_shown  # use a single QPen instance
         if subset is not None:
             brush = np.where(
                 subset,
@@ -870,7 +888,7 @@ def _get_same_colors(self, subset):
         else:
             color = QColor(*self.COLOR_DEFAULT)
             color.setAlpha(self.alpha_value)
-            brush = [QBrush(color) for _ in range(self.n_shown)]
+            brush = [QBrush(color)] * self.n_shown  # use a single QBrush instance
         return pen, brush
 
     def _get_continuous_colors(self, c_data, subset):
@@ -894,12 +912,31 @@ def _get_continuous_colors(self, c_data, subset):
             [pen, np.full((len(pen), 1), self.alpha_value, dtype=int)])
         pen *= 100
         pen //= self.DarkerValue
-        pen = [_make_pen(QColor(*col), 1.5) for col in pen.tolist()]
+
+        # Reuse pens and brushes with the same colors because PyQtGraph then builds
+        # smaller pixmap atlas, which makes the drawing faster
+
+        def reuse(cache, fn, *args):
+            if args not in cache:
+                cache[args] = fn(args)
+            return cache[args]
+
+        def create_pen(col):
+            return _make_pen(QColor(*col), 1.5)
+
+        def create_brush(col):
+            return QBrush(QColor(*col))
+
+        cached_pens = {}
+        pen = [reuse(cached_pens, create_pen, *col) for col in pen.tolist()]
 
         if subset is not None:
             brush[:, 3] = 0
             brush[subset, 3] = 255
-        brush = np.array([QBrush(QColor(*col)) for col in brush.tolist()])
+
+        cached_brushes = {}
+        brush = np.array([reuse(cached_brushes, create_brush, *col) for col in brush.tolist()])
+
         return pen, brush
 
     def _get_discrete_colors(self, c_data, subset):


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_owscatterplotbase.py b/Orange/widgets/visualize/tests/test_owscatterplotbase.py
--- a/Orange/widgets/visualize/tests/test_owscatterplotbase.py
+++ b/Orange/widgets/visualize/tests/test_owscatterplotbase.py
@@ -202,13 +202,9 @@ def test_sampling(self):
         scatterplot_item = graph.scatterplot_item
         x, y = scatterplot_item.getData()
         data = scatterplot_item.data
-        s0, s1, s2, s3 = data["size"] - graph.MinShapeSize
-        np.testing.assert_almost_equal(
-            (s2 - s1) / (s1 - s0),
-            (x[2] - x[1]) / (x[1] - x[0]))
-        np.testing.assert_almost_equal(
-            (s2 - s1) / (s1 - s3),
-            (x[2] - x[1]) / (x[1] - x[3]))
+        s = data["size"] - graph.MinShapeSize
+        precise_s = (x - min(x)) / (max(x) - min(x)) * max(s)
+        np.testing.assert_almost_equal(s, precise_s, decimal=0)
         self.assertEqual(
             list(data["symbol"]),
             [graph.CurveSymbols[int(xi)] for xi in x])
@@ -358,16 +354,24 @@ def test_size_normalization(self):
         graph.reset_graph()
         scatterplot_item = graph.scatterplot_item
         size = scatterplot_item.data["size"]
-        diffs = [round(y - x, 2) for x, y in zip(size, size[1:])]
-        self.assertEqual(len(set(diffs)), 1)
-        self.assertGreater(diffs[0], 0)
+        np.testing.assert_equal(size, [6, 7.5, 9.5, 11, 12.5, 14.5, 16, 17.5, 19.5, 21])
 
         d = np.arange(10, 20, dtype=float)
         graph.update_sizes()
         self.assertIs(scatterplot_item, graph.scatterplot_item)
+        size2 = scatterplot_item.data["size"]
+        np.testing.assert_equal(size, size2)
+
+    def test_size_rounding_half_pixel(self):
+        graph = self.graph
+
+        self.master.get_size_data = lambda: d
+        d = np.arange(10, dtype=float)
+
+        graph.reset_graph()
+        scatterplot_item = graph.scatterplot_item
         size = scatterplot_item.data["size"]
-        diffs2 = [round(y - x, 2) for x, y in zip(size, size[1:])]
-        self.assertEqual(diffs, diffs2)
+        np.testing.assert_equal(size*2 - (size*2).round(), 0)
 
     def test_size_with_nans(self):
         graph = self.graph
@@ -493,12 +497,17 @@ def test_colors_discrete(self):
         d = np.arange(10, dtype=float) % 2
 
         graph.reset_graph()
+        data = graph.scatterplot_item.data
         self.assertTrue(
             all(pen.color().hue() is palette[i % 2].hue()
-                for i, pen in enumerate(graph.scatterplot_item.data["pen"])))
+                for i, pen in enumerate(data["pen"])))
         self.assertTrue(
             all(pen.color().hue() is palette[i % 2].hue()
-                for i, pen in enumerate(graph.scatterplot_item.data["brush"])))
+                for i, pen in enumerate(data["brush"])))
+
+        # confirm that QPen/QBrush were reused
+        self.assertEqual(len(set(map(id, data["pen"]))), 2)
+        self.assertEqual(len(set(map(id, data["brush"]))), 2)
 
     def test_colors_discrete_nan(self):
         self.master.is_continuous_color = lambda: False
@@ -529,6 +538,24 @@ def test_colors_continuous(self):
         d[4] = np.nan
         graph.update_colors()  # Ditto
 
+    def test_colors_continuous_reused(self):
+        self.master.is_continuous_color = lambda: True
+        graph = self.graph
+
+        self.xy = (np.arange(100, dtype=float),
+                   np.arange(100, dtype=float))
+
+        d = np.arange(100, dtype=float)
+        self.master.get_color_data = lambda: d
+        graph.reset_graph()
+
+        data = graph.scatterplot_item.data
+
+        self.assertEqual(len(data["pen"]), 100)
+        self.assertLessEqual(len(set(map(id, data["pen"]))), 10)
+        self.assertEqual(len(data["brush"]), 100)
+        self.assertLessEqual(len(set(map(id, data["brush"]))), 10)
+
     def test_colors_continuous_nan(self):
         self.master.is_continuous_color = lambda: True
         graph = self.graph
@@ -602,12 +629,16 @@ def test_colors_none(self):
         data = graph.scatterplot_item.data
         self.assertTrue(all(pen.color().hue() == hue for pen in data["pen"]))
         self.assertTrue(all(pen.color().hue() == hue for pen in data["brush"]))
+        self.assertEqual(len(set(map(id, data["pen"]))), 1)  # test QPen/QBrush reuse
+        self.assertEqual(len(set(map(id, data["brush"]))), 1)
 
         self.master.get_subset_mask = lambda: np.arange(10) < 5
         graph.update_colors()
         data = graph.scatterplot_item.data
         self.assertTrue(all(pen.color().hue() == hue for pen in data["pen"]))
         self.assertTrue(all(pen.color().hue() == hue for pen in data["brush"]))
+        self.assertEqual(len(set(map(id, data["pen"]))), 1)
+        self.assertEqual(len(set(map(id, data["brush"]))), 2)  # transparent and colored
 
     def test_colors_update_legend_and_density(self):
         graph = self.graph
@@ -1247,6 +1278,11 @@ def test_hiding_too_many_labels(self):
         self.assertFalse(spy[-1][0])
         self.assertFalse(bool(self.graph.labels))
 
+    def test_no_needless_buildatlas(self):
+        graph = self.graph
+        graph.reset_graph()
+        self.assertIsNone(graph.scatterplot_item.fragmentAtlas.atlas)
+
 
 if __name__ == "__main__":
     import unittest