Using the Zoom Line Example I have made a Python QChartView class that can scroll with the arrow keys and zoom with the plus and minus keys. (see my code below).
When I scroll left I would expect that the grid lines and axis ticks scroll the same amount as the data. However, only the data (the QLineSeries) scrolls to the left. The 5 grid lines remain at the same positions but their tick values are updated. This is undesirable as the new tick values can be anything.
I have looked in the documentation but could not find how to make the grid scroll together with the data. Am I missing something?
I would also like to be able to set the ticks to explicit values (so that I can perhaps implement the scrolling behavior myself). Is it possible to set the axis tick values to specific values?
My example code:
import sys
from math import pi, sin, sqrt
from PyQt5.QtChart import QLineSeries, QChart, QChartView
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication
class ZoomPanChartView(QChartView):
""" QChartView that can zoom/pan with the keys
"""
def __init__(self, chart):
super().__init__(chart)
self.zoomFactor = sqrt(2) # QCharts default is 2
self.panPixels = 10
def keyPressEvent(self, keyEvent):
""" Panning (scrolling) is done with the arrow keys.
Zooming goes with the plus and minus keys.
The '=' key resets.
"""
key = keyEvent.key()
if key == Qt.Key_Equal:
self.chart().zoomReset()
if key == Qt.Key_Plus:
self.chart().zoom(self.zoomFactor)
elif key == Qt.Key_Minus:
self.chart().zoom(1/self.zoomFactor)
elif key == Qt.Key_Left:
self.chart().scroll(-self.panPixels, 0)
elif key == Qt.Key_Right:
self.chart().scroll(+self.panPixels, 0)
elif key == Qt.Key_Up:
self.chart().scroll(0, +self.panPixels)
elif key == Qt.Key_Down:
self.chart().scroll(0, -self.panPixels)
elif key == Qt.Key_0:
self.chart().axisX().applyNiceNumbers() # changes the range
else:
super().keyPressEvent(keyEvent)
def main():
app = QApplication(sys.argv)
chart = QChart()
series = QLineSeries()
for i in range(0, 100):
x = i * pi / 20
y = sin(x)
series.append(x, y)
chart.addSeries(series)
chart.createDefaultAxes()
chart.axisY().setRange(-1, 1)
chart.legend().hide()
chartView = ZoomPanChartView(chart)
chartView.show()
chartView.resize(400, 300)
sys.exit(app.exec_())
if __name__ == "__main__":
main()
You can use QCategoryAxis to place ticks where you want:
initialize:
ch = self.chView.chart()
self.chartAxisX = QCategoryAxis(labelsPosition=QCategoryAxis.AxisLabelsPositionOnValue, startValue=0.0)
ch.setAxisX(self.chartAxisX)
self.chartAxisY = QCategoryAxis(labelsPosition=QCategoryAxis.AxisLabelsPositionOnValue, startValue=0.0)
ch.setAxisY(self.chartAxisY)
add series:
ch.addSeries(s)
s.attachAxis(self.chartAxisX)
s.attachAxis(self.chartAxisY)
set ticks at multiples of 5:
for s in self.chartAxisX.categoriesLabels():
self.chartAxisX.remove(s)
for i in range(0, int(max_x_value) + 1, 5):
self.chartAxisX.append(str(i), i)
self.chartAxisX.setRange(0.0, max_x_value)
or use this generic function for any interval:
def format_axis(axis, min_value, max_value, step):
for s in axis.categoriesLabels():
axis.remove(s)
axis.setStartValue(min_value)
for i in range(ceil(min_value / step), floor(max_value / step) + 1):
v = i * step
axis.append('%g' % v, v)
axis.setRange(min_value, max_value)
format_axis(self.chartAxisX, -1.1, 0.98, 0.25)
The best I could find is setting a QValueAxis as the axis on QChart and calling QValueAxis::applyNiceNumbers() to adjust the range, i.e. max and min of the current scale, so that the numbers shown are a bit more human readable. But this will alter data's position instead of gridlines' positions. You can check the function's behaviour on the horizontalBarChart example.
I thought of using a QLineSeries data-set to make the grid myself, but I would need to change the tick's positions on the axis, which, as far as I was able to determine, is not easily made with current QChart.
Short answer: you can't do it with QCharts..
I've been working with Qwt library for some time and I can attest that the grid there behaves as expected and other behaviors are a bit more mature as well. Panning moves the grip around and zooming makes the grid resize in steps to stay human-readable. Maybe it's worth checking.
IMO you can do this with QCharts and QValueAxis:
QValueAxis *axisY = new QValueAxis;
axisY->setTickType(QValueAxis::TicksDynamic);
axisY->setTickAnchor(0.0);
axisY->setTickInterval(0.2);
See e.g. Nice Label Algoritm on how to determine nice tick intervals.
Related
I created an interactive scatterplot using bqplot where you are allowed to drag points around (using enable_move=True).
I don't want the user to drag points above the line y=x.
If they do, I want the point to snap back to where it was most recently.
The problem is that I'm not sure how to avoid infinite recursion here.
The scatterplot needs to be aware of when its points are moved in order to check the move and possibly snap back.
However, when it begins to snap back, this change (of the point positions) seems to trigger that same callback.
Can anyone tell me the "correct" way to deal with this basic issue?
import bqplot.pyplot as plt
import numpy as np
def on_point_move(change, scat):
if np.any(newx < scat.y):
scat.x = change['old']
fig = plt.figure(animation_duration=400)
xs = 1.0*np.arange(3) # make sure these are floats
ys = 1.0*np.arange(3)
scat = plt.scatter(xs, ys, colors=['Red'], default_size=400, enable_move=True)
scat.observe(lambda change: on_point_move(change, scat), names=['x'])
fig
You can temporarily disable the observe in the on_point_move function. I've changed the logic a bit too.
import bqplot.pyplot as plt
import numpy as np
def on_point_move(change):
if np.any(scat.x < scat.y):
scat.unobserve_all()
if change['name'] == 'x':
scat.x = change['old']
elif change['name'] == 'y':
scat.y = change['old']
scat.observe(on_point_move, names=['x','y'])
fig = plt.figure(animation_duration=400)
xs = 1.0*np.arange(3) # make sure these are floats
ys = 1.0*np.arange(3)
scat = plt.scatter(xs, ys, colors=['Red'], default_size=400, enable_move=True)
scat.observe(on_point_move, names=['x','y'])
fig
Is it possible to enable hover tool on the image (the glyph created by image(), image_rgba() or image_url()) so that it will display some context data when hovering on points of the image. In the documentation I found only references and examples for the hover tool for glyphs like lines or markers.
Possible workaround solution:
I think it's possible to convert the 2d signal data into a columnar Dataframe format with columns for x,y and value. And use rect glyph instead of image. But this will also require proper handling of color mapping. Particularly, handling the case when the values are real numbers instead of integers that you can pass to some color palette.
Update for bokeh version 0.12.16
Bokeh version 0.12.16 supports HoverTool for image glyphs. See:
bokeh release 0.12.16
for erlier bokeh versions:
Here is the approach I've been using for Hovering over images using bokeh.plotting.image and adding in top of it an invisible (alpha=0) bokeh.plotting.quad that has Hovering capabilities for the data coordinates. And I'm using it for images with approximately 1500 rows and 40000 columns.
# This is used for hover and taptool
imquad = p.quad(top=[y1], bottom=[y0], left=[x0], right=[x1],alpha=0)
A complete example of and image with capabilities of selecting the minimum and maximum values of the colorbar, also selecting the color_mapper is presented here: Utilities for interactive scientific plots using python, bokeh and javascript. Update: Latest bokeh already support matplotlib cmap palettes, but when I created this code, I needed to generate them from matplotlib.cm.get_cmap
In the examples shown there I decided not to show the tooltip on the image with tooltips=None inside the bokeh.models.HoverTool function. Instead I display them in a separate bokeh.models.Div glyph.
Okay, after digging more deeply into docs and examples, I'll probably answer this question by myself.
The hover effect on image (2d signal) data makes no sense in the way how this functionality is designed in Bokeh. If one needs to add some extra information attached to the data point it needs to put the data into the proper data model - the flat one.
tidying the data
Basically, one needs to tidy his data into a tabular format with x,y and value columns (see Tidy Data article by H.Wickham). Now every row represents a data point, and one can naturally add any contextual information as additional columns.
For example, the following code will do the work:
def flatten(matrix: np.ndarray,
extent: Optional[Tuple[float, float, float, float]] = None,
round_digits: Optional[int] = 0) -> pd.DataFrame:
if extent is None:
extent = (0, matrix.shape[1], 0, matrix.shape[0])
x_min, x_max, y_min, y_max = extent
df = pd.DataFrame(data=matrix)\
.stack()\
.reset_index()\
.rename(columns={'level_0': 'y', 'level_1': 'x', 0: 'value'})
df.x = df.x / df.x.max() * (x_max - x_min) + x_min
df.y = df.y / df.y.max() * (y_max - y_min) + y_min
if round_digits is not None:
df = df.round({'x': round_digits, 'y': round_digits})
return df
rect glyph and ColumnDataSource
Then, use rect glyph instead of image with x,y mapped accordingly and the value column color-mapped properly to the color aesthetics of the glyph.
color mapping for values
here you can use a min-max normalization with the following multiplication by the number of colors you want to use and the round
use bokeh builtin palettes to map from computed integer value to a particular color value.
With all being said, here's an example chart function:
def InteractiveImage(img: pd.DataFrame,
x: str,
y: str,
value: str,
width: Optional[int] = None,
height: Optional[int] = None,
color_pallete: Optional[List[str]] = None,
tooltips: Optional[List[Tuple[str]]] = None) -> Figure:
"""
Notes
-----
both x and y should be sampled with a constant rate
Parameters
----------
img
x
Column name to map on x axis coordinates
y
Column name to map on y axis coordinates
value
Column name to map color on
width
Image width
height
Image height
color_pallete
Optional. Color map to use for values
tooltips
Optional.
Returns
-------
bokeh figure
"""
if tooltips is None:
tooltips = [
(value, '#' + value),
(x, '#' + x),
(y, '#' + y)
]
if color_pallete is None:
color_pallete = bokeh.palettes.viridis(50)
x_min, x_max = img[x].min(), img[x].max()
y_min, y_max = img[y].min(), img[y].max()
if width is None:
width = 500 if height is None else int(round((x_max - x_min) / (y_max - y_min) * height))
if height is None:
height = int(round((y_max - y_min) / (x_max - x_min) * width))
img['color'] = (img[value] - img[value].min()) / (img[value].max() - img[value].min()) * (len(color_pallete) - 1)
img['color'] = img['color'].round().map(lambda x: color_pallete[int(x)])
source = ColumnDataSource(data={col: img[col] for col in img.columns})
fig = figure(width=width,
height=height,
x_range=(x_min, x_max),
y_range=(y_min, y_max),
tools='pan,wheel_zoom,box_zoom,reset,hover,save')
def sampling_period(values: pd.Series) -> float:
# #TODO think about more clever way
return next(filter(lambda x: not pd.isnull(x) and 0 < x, values.diff().round(2).unique()))
x_unit = sampling_period(img[x])
y_unit = sampling_period(img[y])
fig.rect(x=x, y=y, width=x_unit, height=y_unit, color='color', line_color='color', source=source)
fig.select_one(HoverTool).tooltips = tooltips
return fig
#### Note: however this comes with a quite high computational price
Building off of Alexander Reshytko's self-answer above, I've implemented a version that's mostly ready to go off the shelf, with some examples. It should be a bit more straightforward to modify to suit your own application, and doesn't rely on Pandas dataframes, which I don't really use or understand. Code and examples at Github: Bokeh - Image with HoverTool
In a web app, I would like to let the user select a region of interest in a plotted image using the nice box/lasso selection tools of bokeh. I would the like to receive the selected pixels for further operations in python.
For scatter plots, this is easy to do in analogy with the gallery,
import bokeh.plotting
import numpy as np
# data
X = np.linspace(0, 10, 20)
def f(x): return np.random.random(len(x))
# plot and add to document
fig = bokeh.plotting.figure(x_range=(0, 10), y_range=(0, 10),
tools="pan,wheel_zoom,box_select,lasso_select,reset")
plot = fig.scatter(X, f(X))
#plot = fig.image([np.random.random((10,10))*255], dw=[10], dh=[10])
bokeh.plotting.curdoc().add_root(fig)
# callback
def callback(attr, old, new):
# easily access selected points:
print sorted(new['1d']['indices'])
print sorted(plot.data_source.selected['1d']['indices'])
plot.data_source.data = {'x':X, 'y':f(X)}
plot.data_source.on_change('selected', callback)
however if I replace the scatter plot with
plot = fig.image([np.random.random((10,10))*255], dw=[10], dh=[10])
then using the selection tools on the image does not change anything in plot.data_source.selected.
I'm sure this is the intended behavior (and it makes sense too), but what if I want to select pixels of an image? I could of course put a grid of invisible scatter points on top of the image, but is there some more elegant way to accomplish this?
It sounds like the tool you're looking for is actually the BoxEditTool. Note that the BoxEditTool requires a list of glyphs (normally these will be Rect instances) that will render the ROIs, and that listening to changes should be set using:
rect_glyph_source.on_change('data', callback)
This will trigger the callback function any time you make any changes to your ROIs.
The relevant ColumnDataSource instance (rect_glyph_source in this example) will be updated so that the 'x' and 'y' keys list the center of each ROI in the image's coordinates space, and of course 'width' and 'height' describe its size. As far as I know there isn't currently a built-in method for extracting the data itself, so you will have to do something like:
rois = rect_glyph_source.data
roi_index = 0 # x, y, width and height are lists, and each ROI has its own index
x_center = rois['x'][roi_index]
width = rois['width'][roi_index]
y_center = rois['y'][roi_index]
height = rois['height'][roi_index]
x_start = int(x_center - 0.5 * width)
x_end = int(x_center + 0.5 * width)
y_start = int(y_center - 0.5 * height)
y_end = int(y_center + 0.5 * height)
roi_data = image_plot.source.data['image'][0][y_start:y_end, x_start:x_end]
IMPORTANT: In the current version of Bokeh (0.13.0) there is a problem with the synchronization of the BoxEditTool at the server and it isn't functional. This should be fixed in the next official Bokeh release. For more information and a temporary solution see this answer or this discussion.
i tried to express the trajectory of bullet when there is a drag force.
however, i am not able to express the graph precisely.
how to depict trajectory from ode equation?.
this is my graph. this graph does not plausible. although i struggled setting different sign of vydot value, this is not working correctly.
from pylab import*
from scipy.integrate import odeint
import matplotlib.pyplot as plt
import numpy as np
g=10
m=1
k=0.01
y=zeros([2])
vy0=0
vydot=200
vx0=0
vxdot=200
y[0]=vy0
y[1]=vydot
x=zeros([2])
x[0]=vx0
x[1]=vxdot
t=linspace(0,1000,5000)
def fy(y,t):
g0=y[1]
g1=-k*y[1]
return array([g0,g1])
def fx(z,t):
g0=-x[1]
g1=-k*(x[1])-g
return array([g0,g1])
ans1=odeint(fy,y,t)
ans2=odeint(fx,x,t)
ydata=(ans1[:,])
xdata=(ans2[:,])
plt.plot(ydata,xdata)
show()"""
In air, as opposed to liquids, the bullet not only displaces the volume along its path, but also increases the impulse of the displaced air molecules proportional to the velocity. Thus the drag force is
vn=sqrt(vx²+vy²)
dragx = -k*vn*vx
dragy = -k*vn*vy
Thus use
def f(z,t):
x,y,vx,vy = z
vn = sqrt(vx*vx+vy*vy)
return array([vx, vy, -k*vn*vx, -k*vn*vy-g ])
For a first overview, consider the problem without drag. Then the solution is
x(t) = vx*t = 200m/s*t
y(t) = vy*t-g/2*t² = 200m/s*t - 5m/s²*t²
y(t)=0 is again met for t=2*vy/g at the x coordinate 2*vx*vy/g = 8000m. Maximum height is reached for t=vy/g at height vy²/(2g)=2000m.
Is there an algorithm for snapping to an isometric grid?
This is the one I came up with:
def Iso(argument0,argument1):
a = round(pygame.mouse.get_pos()[1]/argument1 - pygame.mouse.get_pos()[0]/argument0);
b = round(pygame.mouse.get_pos()[1]/argument1 + pygame.mouse.get_pos()[0]/argument0);
x = (b - a)/2*argument0;
y = (b + a)/2*argument1;
return (x,y)
and it looks like this:
Anyone got any ideas??
Here is my code:
import pygame
from pygame.locals import *
pygame.init()
screen=pygame.display.set_mode((640,480))
curs=pygame.image.load('white-0.gif').convert()
curs.set_alpha(100)
g1=pygame.image.load('green-0.gif').convert()
tiles=[]
def Iso(argument0,argument1):
a = round(pygame.mouse.get_pos()[1]/argument1 - pygame.mouse.get_pos()[0]/argument0);
b = round(pygame.mouse.get_pos()[1]/argument1 + pygame.mouse.get_pos()[0]/argument0);
x = (b - a)/2*argument0;
y = (b + a)/2*argument1;
return (x,y)
class Tile(object):
def __init__(self,spr,pos1,pos2):
self.pos=(pos1,pos2)
self.spr=spr
while True:
screen.fill((90,90,0))
mse=pygame.mouse.get_pos()
for e in pygame.event.get():
if e.type==QUIT:
exit()
if e.type==MOUSEBUTTONUP:
if e.button==1:
pos=Iso(16,16)
tiles.append(Tile(g1,pos[0],pos[1]))
pos=Iso(16,16)
screen.blit(curs, (pos[0],pos[1]))
for t in tiles:
screen.blit(t.spr,t.pos)
pygame.display.update()
UPDATE:
Managed to get it to work like this:
Just having a few depth issues..
You are converting pixels to an isometric view. Presumably, you want to snap to (isometric) tiles instead.
Multiply your isometric x,y by (width/2),(height/2) where width and height are your isometric tile dimensions. Since that radically changes the scale, you might want to divide both by a constant; if you don't do that, only moving the mouse in the very top left of your screen will make something show up.
Apart from the isometric part, this is exactly what one would do for a top-down grid.