Get `QPainterPath` "append" working

Pertains further to #109.

Instead of redrawing the entire `QPainterPath` every time there's
a historical bars update just use `.addPath()` to slap in latest
history. It seems to work and is fast. This also seems like it will be
a great strategy for filling in earlier data, woot!
to_qpainterpath_and_beyond
Tyler Goodlet 2020-11-24 12:01:06 -05:00
parent 413c703e34
commit f083f537b1
1 changed files with 32 additions and 231 deletions

View File

@ -18,12 +18,11 @@
Chart graphics for displaying a slew of different data types. Chart graphics for displaying a slew of different data types.
""" """
import time
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from numba import jit, float64, optional, int64 from numba import jit, float64, int64
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QLineF, QPointF from PyQt5.QtCore import QLineF, QPointF
@ -335,15 +334,6 @@ class CrossHair(pg.GraphicsObject):
return self.plots[0].boundingRect() return self.plots[0].boundingRect()
# @jit(
# # float64[:](
# # float64[:],
# # optional(float64),
# # optional(int16)
# # ),
# nopython=True,
# nogil=True
# )
def _mk_lines_array( def _mk_lines_array(
data: List, data: List,
size: int, size: int,
@ -382,77 +372,6 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
return [hl, o, c] return [hl, o, c]
# TODO: `numba` this?
# @jit(
# # float64[:](
# # float64[:],
# # optional(float64),
# # optional(int16)
# # ),
# nopython=True,
# nogil=True
# )
def bars_from_ohlc(
data: np.ndarray,
w: float,
start: int = 0,
) -> np.ndarray:
"""Generate an array of lines objects from input ohlc data.
"""
lines = _mk_lines_array(data, data.shape[0], 3)
for i, q in enumerate(data[start:], start=start):
open, high, low, close, index = q[
['open', 'high', 'low', 'close', 'index']]
# high -> low vertical (body) line
if low != high:
hl = QLineF(index, low, index, high)
else:
# XXX: if we don't do it renders a weird rectangle?
# see below for filtering this later...
hl = None
# NOTE: place the x-coord start as "middle" of the drawing range such
# that the open arm line-graphic is at the left-most-side of
# the index's range according to the view mapping.
# open line
o = QLineF(index - w, open, index, open)
# close line
c = QLineF(index, close, index + w, close)
# indexing here is as per the below comments
lines[i] = (hl, o, c)
# XXX: in theory we could get a further speedup by using a flat
# array and avoiding the call to `np.ravel()` below?
# lines[3*i:3*i+3] = (hl, o, c)
# XXX: legacy code from candles custom graphics:
# if not _tina_mode:
# else _tina_mode:
# self.lines = lines = np.concatenate(
# [high_to_low, open_sticks, close_sticks])
# use traditional up/down green/red coloring
# long_bars = np.resize(Quotes.close > Quotes.open, len(lines))
# short_bars = np.resize(
# Quotes.close < Quotes.open, len(lines))
# ups = lines[long_bars]
# downs = lines[short_bars]
# # draw "up" bars
# p.setPen(self.bull_brush)
# p.drawLines(*ups)
# # draw "down" bars
# p.setPen(self.bear_brush)
# p.drawLines(*downs)
return lines
# @timeit # @timeit
@jit( @jit(
@ -490,9 +409,8 @@ def path_arrays_from_ohlc(
dtype=float64, dtype=float64,
) )
# TODO: report bug for assert # TODO: report bug for assert @
# @ /home/goodboy/repos/piker/env/lib/python3.8/site-packages/numba/core/typing/builtins.py:991 # /home/goodboy/repos/piker/env/lib/python3.8/site-packages/numba/core/typing/builtins.py:991
# for i, q in enumerate(data[start:], start):
for i, q in enumerate(data[start:], start): for i, q in enumerate(data[start:], start):
# TODO: ask numba why this doesn't work.. # TODO: ask numba why this doesn't work..
@ -541,7 +459,6 @@ def gen_qpath(
return pg.functions.arrayToQPath(x, y, connect=c) return pg.functions.arrayToQPath(x, y, connect=c)
class BarItems(pg.GraphicsObject): class BarItems(pg.GraphicsObject):
"""Price range bars graphics rendered from a OHLC sequence. """Price range bars graphics rendered from a OHLC sequence.
""" """
@ -554,10 +471,6 @@ class BarItems(pg.GraphicsObject):
# so probably don't do it until we figure that out. # so probably don't do it until we figure that out.
bars_pen = pg.mkPen(hcolor('bracket')) bars_pen = pg.mkPen(hcolor('bracket'))
# XXX: tina mode, see below
# bull_brush = pg.mkPen('#00cc00')
# bear_brush = pg.mkPen('#fa0000')
def __init__( def __init__(
self, self,
# scene: 'QGraphicsScene', # noqa # scene: 'QGraphicsScene', # noqa
@ -567,22 +480,18 @@ class BarItems(pg.GraphicsObject):
self.last_bar = QtGui.QPicture() self.last_bar = QtGui.QPicture()
self.history = QtGui.QPicture() self.history = QtGui.QPicture()
# TODO: implement updateable pixmap solution # TODO: implement updateable pixmap solution
self._pi = plotitem self._pi = plotitem
# self._scene = plotitem.vb.scene()
# self.picture = QtGui.QPixmap(1000, 300)
# plotitem.addItem(self.picture)
# self._pmi = None
# self._pmi = self._scene.addPixmap(self.picture)
# XXX: not sure this actually needs to be an array other # XXX: not sure this actually needs to be an array other
# then for the old tina mode calcs for up/down bars below? # then for the old tina mode calcs for up/down bars below?
# lines container # lines container
day_in_s = 60 * 60 * 12
self.lines = _mk_lines_array([], 50e3, 6) self.lines = _mk_lines_array([], 50e3, 6)
# TODO: don't render the full backing array each time # TODO: don't render the full backing array each time
# self._path_data = None # self._path_data = None
self._last_bar_lines = None self._last_bar_lines: Optional[Tuple[QLineF, ...]] = None
# track the current length of drawable lines within the larger array # track the current length of drawable lines within the larger array
self.index: int = 0 self.index: int = 0
@ -597,32 +506,13 @@ class BarItems(pg.GraphicsObject):
This routine is usually only called to draw the initial history. This routine is usually only called to draw the initial history.
""" """
# start_lines = time.time()
# lines = bars_from_ohlc(data, self.w, start=start)
# start_path = time.time()
# assert len(data) == 2000
self.path = gen_qpath(data, self.w, start=start) self.path = gen_qpath(data, self.w, start=start)
# end = time.time()
# print(f"paths took {end - start_path}\n lines took {start_path - start_lines}")
# save graphics for later reference and keep track # save graphics for later reference and keep track
# of current internal "last index" # of current internal "last index"
# index = len(lines)
# index = len(data)
# self.lines[:index] = lines
# lines = bars_from_ohlc(data[-1:], self.w, start=start)
self.index = len(data) self.index = len(data)
# up to last to avoid double draw of last bar # up to last to avoid double draw of last bar
# self.draw_lines(just_history=True, iend=self.index - 1, path=self.path)
# self.draw_lines(iend=self.index)
self._last_bar_lines = lines_from_ohlc(data[-1], self.w) self._last_bar_lines = lines_from_ohlc(data[-1], self.w)
# create pics # create pics
@ -634,99 +524,23 @@ class BarItems(pg.GraphicsObject):
self.update() self.update()
def draw_last_bar(self) -> None: def draw_last_bar(self) -> None:
"""Currently this draws lines to a cached ``QPicture`` which
is supposed to speed things up on ``.paint()`` calls (which
is a call to ``QPainter.drawPicture()`` but I'm not so sure.
# pic = self.last_bar """
# pre-computing a QPicture object allows paint() to run much
# more quickly, rather than re-drawing the shapes every time.
p = QtGui.QPainter(self.last_bar) p = QtGui.QPainter(self.last_bar)
p.setPen(self.bars_pen) p.setPen(self.bars_pen)
# print(self._last_bar_lines)
p.drawLines(*tuple(filter(bool, self._last_bar_lines))) p.drawLines(*tuple(filter(bool, self._last_bar_lines)))
p.end() p.end()
# trigger re-render
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
# self.update()
def draw_history(self) -> None: def draw_history(self) -> None:
p = QtGui.QPainter(self.history) p = QtGui.QPainter(self.history)
p.setPen(self.bars_pen) p.setPen(self.bars_pen)
p.drawPath(self.path) p.drawPath(self.path)
p.end() p.end()
# self.update()
@timeit @timeit
def draw_lines(
self,
iend=None,
just_history=False,
istart=0,
path: QtGui.QPainterPath = None,
# TODO: could get even fancier and only update the single close line?
lines=None,
) -> None:
"""Draw the current line set using the painter.
Currently this draws lines to a cached ``QPicture`` which
is supposed to speed things up on ``.paint()`` calls (which
is a call to ``QPainter.drawPicture()`` but I'm not so sure.
"""
# if path is None:
# if just_history:
# raise RuntimeError
# # draw bars for the "history" picture
# iend = iend or self.index - 1
# pic = self.history
# else:
# # draw the last bar
# istart = self.index - 1
# iend = iend or self.index
# pic = self.last_bar
# # use 2d array of lines objects, see conlusion on speed:
# # https://stackoverflow.com/a/60089929
# flat = np.ravel(self.lines[istart:iend])
# # TODO: do this with numba for speed gain:
# # https://stackoverflow.com/questions/58422690/filtering-a-numpy-array-what-is-the-best-approach
# to_draw = flat[np.where(flat != None)] # noqa
# else:
# pic = self.history
pic = self.last_bar
# pre-computing a QPicture object allows paint() to run much
# more quickly, rather than re-drawing the shapes every time.
p = QtGui.QPainter(pic)
p.setPen(self.bars_pen)
p.drawLines(*self._last_bar_lines)
p.end()
# trigger re-render
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
self.update()
# TODO: is there any way to not have to pass all the lines every
# iteration? It seems they won't draw unless it's done this way..
# if path is None:
# # p.drawLines(*to_draw)
# p.drawLines(*self._last_bars_lines)
# else:
# p.drawPath(path)
# p.end()
# trigger re-render
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
# self.update()
def update_from_array( def update_from_array(
self, self,
array: np.ndarray, array: np.ndarray,
@ -740,57 +554,49 @@ class BarItems(pg.GraphicsObject):
graphics object, and then update/rerender, but here we're graphics object, and then update/rerender, but here we're
assuming the prior graphics havent changed (OHLC history rarely assuming the prior graphics havent changed (OHLC history rarely
does) so this "should" be simpler and faster. does) so this "should" be simpler and faster.
This routine should be made (transitively) as fast as possible.
""" """
index = self.index index = self.index
length = len(array) length = len(array)
extra = length - index extra = length - index
# start_bar_to_update = index - 100
# TODO: allow mapping only a range of lines thus # TODO: allow mapping only a range of lines thus
# only drawing as many bars as exactly specified. # only drawing as many bars as exactly specified.
if extra > 0: if extra > 0:
# generate new graphics to match provided array
# lines = bars_from_ohlc(new, self.w) # generate new lines objects for updatable "current bar"
# lines = bars_from_ohlc(array[-1:], self.w)
self._last_bar_lines = lines_from_ohlc(array[-1], self.w) self._last_bar_lines = lines_from_ohlc(array[-1], self.w)
self.draw_last_bar()
# TODO: only draw these new bars to the backing binary # generate new graphics to match provided array
# path array and then call arrayToQpath() on the whole # path appending logic:
# -> will avoid multiple passes for path data we've already # we need to get the previous "current bar(s)" for the time step
# already generated # and convert it to a path to append to the historical set
new = array[index:index + extra] new_history_istart = length - 2
to_history = array[new_history_istart:new_history_istart + extra]
# generate a new sub-path for this now-ready-for-history bar set
new_history_qpath = gen_qpath(to_history, self.w, 0)
self.path = gen_qpath(array[:-1], self.w, start=0) # move to position of placement for the next bar in history
# and append new sub-path
# self.path.connectPath(path) new_bars = array[index:index + extra]
self.path.moveTo(float(index - self.w), float(new_bars[0]['open']))
# bars_added = len(new) self.path.addPath(new_history_qpath)
# bars_added = extra
# self.lines[index:index + bars_added] = lines
self.index += extra self.index += extra
# start_bar_to_update = index - bars_added
# self.draw_lines(just_history=True, path=self.path)
# self.update()
self.draw_history() self.draw_history()
if just_history: if just_history:
self.update() self.update()
return return
# last bar update # last bar update
i, o, h, l, last, v = array[-1][ i, o, h, l, last, v = array[-1][
['index', 'open', 'high', 'low', 'close', 'volume'] ['index', 'open', 'high', 'low', 'close', 'volume']
] ]
# assert i == self.index - 1 assert i == self.index - 1
# body, larm, rarm = self.lines[i]
# body, larm, rarm = self._bars
body, larm, rarm = self._last_bar_lines body, larm, rarm = self._last_bar_lines
# XXX: is there a faster way to modify this? # XXX: is there a faster way to modify this?
@ -800,7 +606,6 @@ class BarItems(pg.GraphicsObject):
if l != h: # noqa if l != h: # noqa
if body is None: if body is None:
# body = self.lines[index - 1][0] = QLineF(i, l, i, h)
body = self._last_bar_lines[0] = QLineF(i, l, i, h) body = self._last_bar_lines[0] = QLineF(i, l, i, h)
else: else:
# update body # update body
@ -819,7 +624,6 @@ class BarItems(pg.GraphicsObject):
# if body is not None: # if body is not None:
# body = self.lines[index - 1][0] = None # body = self.lines[index - 1][0] = None
# self.draw_lines(just_history=False)
self.draw_last_bar() self.draw_last_bar()
self.update() self.update()
@ -842,15 +646,9 @@ class BarItems(pg.GraphicsObject):
# p.drawPicture(0, 0, self.history) # p.drawPicture(0, 0, self.history)
p.drawPicture(0, 0, self.last_bar) p.drawPicture(0, 0, self.last_bar)
# p = QtGui.QPainter(pic)
p.setPen(self.bars_pen) p.setPen(self.bars_pen)
# p.drawLines(*self._last_bar_lines)
# TODO: is there any way to not have to pass all the lines every
# iteration? It seems they won't draw unless it's done this way..
p.drawPath(self.path) p.drawPath(self.path)
# TODO: if we can ever make pixmaps work... # TODO: if we can ever make pixmaps work...
# p.drawPixmap(0, 0, self.picture) # p.drawPixmap(0, 0, self.picture)
# self._pmi.setPixmap(self.picture) # self._pmi.setPixmap(self.picture)
@ -860,7 +658,10 @@ class BarItems(pg.GraphicsObject):
# @timeit # @timeit
def boundingRect(self): def boundingRect(self):
# TODO: can we do rect caching to make this faster? # TODO: can we do rect caching to make this faster
# like `pg.PlotCurveItem` does? In theory it's just
# computing max/min stuff again like we do in the udpate loop
# anyway.
# Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect # Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
# boundingRect _must_ indicate the entire area that will be # boundingRect _must_ indicate the entire area that will be